diff --git a/CLAUDE.md b/CLAUDE.md index 7813bc1..a7b4c82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,12 +63,22 @@ Rules: - **Sources.** `ExtensionProviderRegistry` turns installed catalog entries into providers. `StreamingRepositoryImpl` resolves them in parallel, ranks with `ServerRanker`, and runs - `fallback` providers only when direct ones return nothing (or on "Find more" / failover). + `fallback` providers only when direct ones return nothing, or have found nothing after 8 s (or on + "Find more" / failover). Each provider gets 25 s, enforced by awaiting rather than by cancelling + (OkHttp's blocking `execute()` ignores coroutine cancellation); the IMDb id lookup gets 8 s. + Cancelled resolutions record nothing; five failures in a row skip a provider for two minutes. + Users rank installed sources (Marketplace > Installed, `ExtensionStateStore.sourceOrder`); a + provider's priority is its position. Search mode (`SourceSearchMode` in `AppSettings`): all at + once (rank by quality, order breaks ties) or in order (one at a time, stop at the first with + streams; ranking puts the order before quality; "Find more" searches everything). Engines: `vidking-direct` (`VidkingDirectApi`, encrypted payload, prefers the master playlist so quality switches in-player), `web-embed` and `vidking-webview` (`WebEmbedResolver`: first a native pass through `HosterExtractors` (Filemoon, StreamWish/VidHide, Voe, Mp4Upload, Vidmoly, ok.ru) when the embed is or frames a known hoster, else a hidden WebView that records media requests). Anime engines (`data/streaming/anime`): `anikoto`, `reanime`, `animepahe`, `fouranimo`, `animegg`. + `stremio` (`StremioAddonProvider`): any Stremio add-on by base URL; direct-`url` streams only + (no torrents/debrid). A pasted Stremio `manifest.json` becomes a one-add-on repository + (`ExtensionIndexParser`), installed right away when it can play. `VideoServer` can carry a MIME hint (HLS for URLs without `.m3u8`) and the source's own intro/outro times, which the player prefers over AniSkip. `AnimeEpisodeMapper` maps TMDB season/episode to an AniList episode (ani.zip + AniList GraphQL, both keyless); @@ -94,11 +104,21 @@ Rules: and `SubSourceRepository` (keyless, mirrors subsource.net's own API: IMDb search -> list -> download token -> zip), plus any the stream carries. `SubtitleFetcher` downloads and unwraps them (gzip, zip, charset) for both the player and the cast proxy; promo cues are stripped. + `SavedSubtitleRepository` keeps subtitles on the device (`filesDir/subtitles//`, files + plus `index.json`), saved from Downloads (any language, or an imported file). Downloads also + record the stream's own subtitles there (`stream.json`) so the sheet can offer them; older + downloads re-resolve the stream sources to find them. `DownloadSubtitleSaver` saves every + subtitle in the languages chosen in Settings (default English) when a download starts: the + stream's own, plus OpenSubtitles/SubSource unless turned off. The player lists + them first for that title/episode, online or offline; `SubtitleFetcher` reads `file:` URLs only + from that folder because the cast proxy fetches whatever URL it is given. Deleting a download + deletes its saved subtitles. - **Network.** `AppDns` (DNS-over-HTTPS, default AdGuard, chosen in Settings) backs every OkHttp client and Coil's image loader (`OpenStreamApp`), because some ISPs block TMDB at the DNS level. Media3 playback/downloads and WebView sources still use the system resolver. -- **Settings.** `AppSettingsStore` (SharedPreferences) holds theme, dynamic color, DNS and - Wi-Fi-only downloads; `MainActivity` applies the theme. +- **Settings.** `AppSettingsStore` (SharedPreferences) holds theme, dynamic color, DNS, + Wi-Fi-only downloads and the subtitle languages saved with downloads; `MainActivity` applies the + theme. - **Skip intro.** `SkipTimesRepository`: `AnimeEpisodeMapper` gives the MAL id and the episode within that entry (AniList title search only as a fallback), AniSkip v2 gives the intro/recap/credits times (anime only), and the player picks the submission timed on the closest file length. All diff --git a/app/src/main/assets/extensions/official-repo.json b/app/src/main/assets/extensions/official-repo.json index 9ed8de0..e80bb18 100644 --- a/app/src/main/assets/extensions/official-repo.json +++ b/app/src/main/assets/extensions/official-repo.json @@ -266,6 +266,111 @@ "priority": 13 } }, + { + "id": "hdhub", + "name": "HdHub", + "description": "Stremio add-on with many direct releases per title, up to 4K, including Hindi and dual-audio files. Large files: best on a fast connection.", + "version": "1.0.7", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Multi", + "iconUrl": "https://hdhub.thevolecitor.qzz.io/logo.png", + "tags": ["movies", "series", "stremio", "4k"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://hdhub.thevolecitor.qzz.io", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://hdhub.thevolecitor.qzz.io/eyJ0b3Jib3giOiJ1bnNldCIsInF1YWxpdGllcyI6IjIxNjBwLDEwODBwLDcyMHAiLCJzb3J0IjoiZGVzYyJ9", + "priority": 14 + } + }, + { + "id": "yastream", + "name": "yastream", + "description": "Stremio add-on for Asian dramas, series and movies from several providers.", + "version": "0.11.1", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Multi", + "iconUrl": "https://yastream.tamthai.de/img/yas.png", + "tags": ["series", "movies", "stremio", "asian-drama"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://yastream.tamthai.de", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://yastream.tamthai.de", + "priority": 15 + } + }, + { + "id": "zeus", + "name": "Zeus", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies, series and anime.", + "version": "0.39.4", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://i.imgur.com/7Jr9ng6.jpg", + "tags": ["movies", "series", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://398fe185fed6-zeus.baby-beamup.club", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://398fe185fed6-zeus.baby-beamup.club/v1-p1kv-q3j", + "priority": 16 + } + }, + { + "id": "froststream", + "name": "FrostStream", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies and series.", + "version": "2.2.7", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://i.imgur.com/yuG0ZIU.jpeg", + "tags": ["movies", "series", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://froststream.cloutteam.com", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://froststream.cloutteam.com", + "priority": 17 + } + }, + { + "id": "fenixflix", + "name": "FenixFlix", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled series, novelas and movies.", + "version": "1.1.9", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://fenixflix.fenixhub.online/logo.png", + "tags": ["series", "movies", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://fenixflix.fenixhub.online", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://fenixflix.fenixhub.online", + "priority": 18 + } + }, { "id": "jett", "name": "Jett", diff --git a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt index f72ae14..e3c18e0 100644 --- a/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt +++ b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt @@ -14,6 +14,7 @@ import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.settings.DnsProvider import com.ivor.openstream.data.settings.PipAction +import com.ivor.openstream.data.settings.SourceSearchMode import com.ivor.openstream.data.settings.ThemeMode import kotlinx.coroutines.flow.first import kotlinx.serialization.Serializable @@ -266,7 +267,10 @@ private data class BackupSettings( val autoPlayNext: Boolean? = null, val downloadMaxHeight: Int? = null, val pipLeftAction: String? = null, - val pipRightAction: String? = null + val pipRightAction: String? = null, + val subtitleDownloadLanguages: List? = null, + val subtitleDownloadFromSites: Boolean? = null, + val sourceSearchMode: String? = null ) { fun applyTo(current: AppSettings) = current.copy( themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode, @@ -278,7 +282,10 @@ private data class BackupSettings( autoPlayNext = autoPlayNext ?: current.autoPlayNext, downloadMaxHeight = downloadMaxHeight?.takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: current.downloadMaxHeight, pipLeftAction = PipAction.entries.firstOrNull { it.name == pipLeftAction } ?: current.pipLeftAction, - pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction + pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction, + subtitleDownloadLanguages = subtitleDownloadLanguages?.filter { it.isNotBlank() }?.distinct() ?: current.subtitleDownloadLanguages, + subtitleDownloadFromSites = subtitleDownloadFromSites ?: current.subtitleDownloadFromSites, + sourceSearchMode = SourceSearchMode.entries.firstOrNull { it.name == sourceSearchMode } ?: current.sourceSearchMode ) } @@ -292,5 +299,8 @@ private fun AppSettings.toBackup() = BackupSettings( autoPlayNext = autoPlayNext, downloadMaxHeight = downloadMaxHeight, pipLeftAction = pipLeftAction.name, - pipRightAction = pipRightAction.name + pipRightAction = pipRightAction.name, + subtitleDownloadLanguages = subtitleDownloadLanguages, + subtitleDownloadFromSites = subtitleDownloadFromSites, + sourceSearchMode = sourceSearchMode.name ) diff --git a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionIndexParser.kt b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionIndexParser.kt index 39ba1f4..3dbf2d6 100644 --- a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionIndexParser.kt +++ b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionIndexParser.kt @@ -7,6 +7,8 @@ import com.ivor.openstream.domain.model.ExtensionStatus import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import java.time.LocalDate import java.time.ZoneOffset import java.time.format.DateTimeParseException @@ -26,9 +28,13 @@ class ExtensionIndexParser @Inject constructor() { isLenient = true } - /** Parses a repository document: an object, or a bare array of entries. */ - fun parseRepo(raw: String): ExtensionRepoDto { + /** + * Parses a repository document: an object, a bare array of entries, or a Stremio add-on + * manifest ([sourceUrl] is where it was fetched, which is how the add-on is addressed). + */ + fun parseRepo(raw: String, sourceUrl: String? = null): ExtensionRepoDto { val root = json.parseToJsonElement(raw) + if (root is JsonObject && isStremioManifest(root)) return stremioRepo(root, sourceUrl) return when (root) { is JsonArray -> ExtensionRepoDto(extensions = decodeEntries(root)) is JsonObject -> { @@ -105,6 +111,11 @@ class ExtensionIndexParser @Inject constructor() { } } + private companion object { + /** After the built-in direct routes, before WebView fallbacks. */ + const val STREMIO_PRIORITY = 40 + } + private fun decodeEntries(array: JsonArray?): List = array.orEmpty().mapNotNull { element -> runCatching { @@ -112,6 +123,74 @@ class ExtensionIndexParser @Inject constructor() { }.getOrNull() } + private fun isStremioManifest(root: JsonObject): Boolean = + "resources" in root && "id" in root && "extensions" !in root && "extensionLists" !in root + + /** + * A Stremio add-on as a one-entry repository, so pasting its link in "Add repository" is all + * it takes. Installed straight away when it can play here: it serves streams and doesn't + * need configuring first (a configured add-on's link already carries its settings). + */ + private fun stremioRepo(manifest: JsonObject, sourceUrl: String?): ExtensionRepoDto { + val addonId = manifest.text("id")?.takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Stremio manifest has no id") + val name = manifest.text("name")?.trim().orEmpty().ifEmpty { addonId } + val baseUrl = sourceUrl?.substringBefore('?')?.removeSuffix("/manifest.json")?.trimEnd('/').orEmpty() + val resources = (manifest["resources"] as? JsonArray).orEmpty().mapNotNull { resource -> + when (resource) { + is JsonPrimitive -> resource.contentOrNull + is JsonObject -> resource.text("name") + else -> null + } + } + val hints = manifest["behaviorHints"] as? JsonObject + val needsConfiguring = hints?.text("configurationRequired") == "true" + val servesStreams = "stream" in resources + val playable = servesStreams && !needsConfiguring && baseUrl.isNotEmpty() + val types = (manifest["types"] as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + val note = when { + !servesStreams -> "This add-on has no streams (only catalogs, metadata or subtitles), so it has nothing to play here." + needsConfiguring -> "Configure this add-on on its own page first, then add the link it gives you." + else -> null + } + val logo = manifest.text("logo")?.takeIf { it.startsWith("https://") } + val entry = ExtensionEntryDto( + id = addonId.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "addon" }, + name = name, + description = listOfNotNull(manifest.text("description")?.trim()?.takeIf { it.isNotEmpty() }, note) + .joinToString("\n\n"), + version = manifest.text("version")?.trim().orEmpty().ifEmpty { "1.0.0" }, + versionCode = semverCode(manifest.text("version")), + authors = listOfNotNull(baseUrl.substringAfter("://").substringBefore('/').takeIf { it.isNotEmpty() }), + iconUrl = logo, + tags = (listOf("stremio") + types.map { if (it == "movie") "movies" else it }).distinct(), + status = if (playable) 3 else 0, + homepage = baseUrl.takeIf { it.isNotEmpty() && hints?.text("configurable") == "true" }?.let { "$it/configure" }, + installedByDefault = playable, + engine = ExtensionEngineDto( + type = if (playable) "stremio" else "unsupported", + endpoint = baseUrl, + priority = STREMIO_PRIORITY + ) + ) + return ExtensionRepoDto( + name = "$name (Stremio)", + description = "Stremio add-on", + iconUrl = logo, + website = baseUrl.takeIf { it.isNotEmpty() }, + extensions = listOf(entry) + ) + } + + /** "1.4.12" -> 1004012, so a newer add-on version shows as an update. */ + private fun semverCode(version: String?): Int { + val parts = version.orEmpty().split('.', '-', '+').take(3).map { it.toIntOrNull()?.coerceIn(0, 999) ?: 0 } + return (parts.getOrElse(0) { 0 } * 1_000_000 + parts.getOrElse(1) { 0 } * 1_000 + parts.getOrElse(2) { 0 }) + .coerceAtLeast(1) + } + + private fun JsonObject.text(key: String): String? = (get(key) as? JsonPrimitive)?.contentOrNull + /** Entries are decoded one by one, so keep them out of the strict repository decode. */ private fun stripEntries(root: JsonObject): JsonObject = JsonObject(root.filterKeys { it != "extensions" }) diff --git a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepoClient.kt b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepoClient.kt index 026aa62..70756a4 100644 --- a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepoClient.kt +++ b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepoClient.kt @@ -17,7 +17,7 @@ class ExtensionRepoClient @Inject constructor( ) { suspend fun fetch(url: String): CachedRepoSnapshot = withContext(Dispatchers.IO) { - val repo = parser.parseRepo(get(url)) + val repo = parser.parseRepo(get(url), sourceUrl = url) val linked = repo.extensionLists .mapNotNull { RepoUrlNormalizer.normalize(it) } .flatMap { listUrl -> diff --git a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepositoryImpl.kt index 18c3407..3bc03de 100644 --- a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionRepositoryImpl.kt @@ -115,6 +115,8 @@ class ExtensionRepositoryImpl @Inject constructor( rebuildManifests() toRepo(repoId, snapshot) } + // A pasted Stremio add-on is ready to use without a separate install step. + seedDefaultInstalls() publish(isSyncing = false) repo } @@ -190,11 +192,18 @@ class ExtensionRepositoryImpl @Inject constructor( return updates.size } + /** Already in source order: [publish] sorts the catalog that way. */ override fun activeExtensions(): List { ensureLoaded() - return _catalog.value.extensions - .filter { it.isActive } - .sortedBy { it.manifest.engine.priority } + return _catalog.value.extensions.filter { it.isActive } + } + + override fun setSourceOrder(keys: List) { + ensureLoaded() + // Keep ranks for sources not in this list (uninstalled ones the user may reinstall). + val rest = store.sourceOrder().filterNot { it in keys } + store.setSourceOrder(keys + rest) + publish(isSyncing = _catalog.value.isSyncing) } override fun recordOutcome(key: String, success: Boolean) { @@ -284,7 +293,14 @@ class ExtensionRepositoryImpl @Inject constructor( private fun publish(isSyncing: Boolean) { val installs = store.installs() val usage = store.usage() - val extensions = manifests.map { manifest -> + val order = store.sourceOrder().withIndex().associate { (index, key) -> key to index } + // The user's ranking first; the rest by the catalog's priority, as before ranking existed. + val ranked = manifests.sortedWith( + compareBy { order[it.key] ?: Int.MAX_VALUE } + .thenBy { it.engine.priority } + .thenBy { it.name.lowercase() } + ) + val extensions = ranked.map { manifest -> val record = installs[manifest.key] val stats = usage[manifest.key] MarketplaceExtension( diff --git a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionStateStore.kt b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionStateStore.kt index 54ada1e..fa0c08d 100644 --- a/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionStateStore.kt +++ b/app/src/main/java/com/ivor/openstream/data/extensions/ExtensionStateStore.kt @@ -99,6 +99,13 @@ class ExtensionStateStore @Inject constructor( write(KEY_REPOS, repoSerializer, customRepos().filterNot { it.id == repoId }) } + /** Extension keys in the order the user ranked them; anything missing ranks after these. */ + fun sourceOrder(): List = read(KEY_ORDER, seededSerializer, emptyList()) + + fun setSourceOrder(keys: List) { + write(KEY_ORDER, seededSerializer, keys.distinct()) + } + fun lastSyncedAt(): Long = preferences.getLong(KEY_LAST_SYNC, 0L) fun setLastSyncedAt(timestamp: Long) { @@ -148,6 +155,7 @@ class ExtensionStateStore @Inject constructor( const val KEY_REPOS = "marketplace.repos" const val KEY_LAST_SYNC = "marketplace.last_sync" const val KEY_SEEDED = "marketplace.seeded" + const val KEY_ORDER = "marketplace.source_order" const val MAX_SAMPLES = 40 } } diff --git a/app/src/main/java/com/ivor/openstream/data/extensions/RepoUrlNormalizer.kt b/app/src/main/java/com/ivor/openstream/data/extensions/RepoUrlNormalizer.kt index 0086d8f..86057d9 100644 --- a/app/src/main/java/com/ivor/openstream/data/extensions/RepoUrlNormalizer.kt +++ b/app/src/main/java/com/ivor/openstream/data/extensions/RepoUrlNormalizer.kt @@ -16,8 +16,13 @@ object RepoUrlNormalizer { ) fun normalize(input: String): String? { - val trimmed = input.trim().trim('"', '\'') - if (trimmed.isEmpty()) return null + val pasted = input.trim().trim('"', '\'') + if (pasted.isEmpty()) return null + // Stremio's install links (stremio://host/manifest.json) are plain HTTPS underneath, and an + // add-on's settings page (…/configure) sits next to its manifest. + val trimmed = pasted + .replaceFirst(Regex("^stremio://", RegexOption.IGNORE_CASE), "https://") + .replace(Regex("/configure/?$", RegexOption.IGNORE_CASE), "/manifest.json") val withScheme = when { trimmed.startsWith("http://", ignoreCase = true) -> "https://" + trimmed.removePrefix("http://") diff --git a/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt index af5b9ee..4de0088 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/CombinedSubtitleRepository.kt @@ -26,4 +26,12 @@ class CombinedSubtitleRepository @Inject constructor( .distinctBy { listOf(it.language, it.release?.lowercase()?.trim(), it.isHearingImpaired).takeIf { _ -> it.release != null } ?: it.url } .sortedBy { if (it.language == "en") 0 else 1 } } + + override suspend fun searchLanguage(identity: MediaIdentity, language: String): List = coroutineScope { + val fromOpenSubtitles = async { + runCatching { openSubtitles.searchLanguage(identity, language) }.getOrDefault(emptyList()) + } + val fromSubSource = async { subSource.searchLanguage(identity, language) } + (fromOpenSubtitles.await() + fromSubSource.await()).distinctBy { it.url } + } } diff --git a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt index dc0c5b6..26b8a02 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/DownloadRepositoryImpl.kt @@ -15,6 +15,8 @@ import com.ivor.openstream.data.local.entity.DownloadEntity import com.ivor.openstream.data.service.HlsDownloadService import com.ivor.openstream.data.settings.AppSettingsStore import com.ivor.openstream.data.streaming.DownloadRequestHeaderStore +import com.ivor.openstream.data.subtitles.DownloadSubtitleSaver +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository import com.ivor.openstream.domain.model.DownloadStatus import com.ivor.openstream.domain.model.DownloadTarget import com.ivor.openstream.domain.model.VideoServer @@ -67,7 +69,9 @@ class DownloadRepositoryImpl @Inject constructor( private val streamingRepository: StreamingRepository, @Named("StreamingClient") private val client: OkHttpClient, private val json: Json, - private val appSettings: AppSettingsStore + private val appSettings: AppSettingsStore, + private val savedSubtitles: SavedSubtitleRepository, + private val subtitleSaver: DownloadSubtitleSaver ) : DownloadRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -137,14 +141,25 @@ class DownloadRepositoryImpl @Inject constructor( scope.launch { val entity = dao.getDownloadById(downloadId) ?: return@launch val target = entity.toTarget() - if (downloadId != target.id) removeDownload(downloadId) + if (downloadId != target.id) removeEntry(downloadId) removeFromMedia3(target.id) dao.insertDownload(placeholder(target)) resolveAndStart(target) } } + /** Also deletes the subtitles saved for it; they were saved to go with this download. */ override suspend fun removeDownload(downloadId: String) { + val entity = dao.getDownloadById(downloadId) + subtitleSaver.cancel(downloadId) + removeEntry(downloadId) + if (entity != null) { + runCatching { savedSubtitles.deleteAll(entity.mediaType, entity.tmdbId, entity.season, entity.episode) } + .onFailure { Log.w(TAG, "Could not delete saved subtitles for $downloadId", it) } + } + } + + private suspend fun removeEntry(downloadId: String) { resolutionJobs.remove(downloadId)?.cancel() if (isMedia3(downloadId)) { removeFromMedia3(downloadId) @@ -218,6 +233,13 @@ class DownloadRepositoryImpl @Inject constructor( resolvedAt = server.resolvedAt ) ) + // The stream's own subtitles, so Downloads can offer them for saving later, and the ones in + // the languages chosen in Settings saved right away. + val streamSubtitles = SavedSubtitleRepository.streamCandidates(server.providerName, server.subtitles, server.headers) + runCatching { + savedSubtitles.rememberStreamSubtitles(target.mediaType, target.tmdbId, target.season, target.episode, streamSubtitles) + }.onFailure { Log.w(TAG, "Could not record stream subtitles for ${target.id}", it) } + subtitleSaver.saveFor(target, streamSubtitles) withContext(Dispatchers.Main) { // Replace any older copy so segments from two different links never mix. if (media3.downloadIndex.getDownload(target.id) != null) media3.removeDownload(target.id) diff --git a/app/src/main/java/com/ivor/openstream/data/repository/OpenSubtitlesRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/OpenSubtitlesRepository.kt index eee39e7..d6baf9f 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/OpenSubtitlesRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/OpenSubtitlesRepository.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.contentOrNull import okhttp3.OkHttpClient import okhttp3.Request +import java.util.Locale import javax.inject.Inject import javax.inject.Named import javax.inject.Singleton @@ -35,25 +36,41 @@ class OpenSubtitlesRepository @Inject constructor( ) : SubtitleRepository { override suspend fun search(identity: MediaIdentity): List = withContext(Dispatchers.IO) { + val base = basePath(identity) ?: return@withContext emptyList() + // The unfiltered query caps its results and often leaves English out, so ask for it separately. + val results = coroutineScope { + val english = async { query("$base/sublanguageid-eng") } + val all = async { query(base) } + english.await() + all.await() + } + results.toDtos(MAX_PER_LANGUAGE) + } + + /** + * Every release in one language ([language] is ISO 639-1, like "hi"). The unfiltered search + * stops at 100 results, so most languages only show up when asked for by name. + */ + override suspend fun searchLanguage(identity: MediaIdentity, language: String): List = + withContext(Dispatchers.IO) { + val base = basePath(identity) ?: return@withContext emptyList() + query("$base/sublanguageid-${languageId(language)}").toDtos(MAX_PER_LANGUAGE_ASKED) + } + + private suspend fun basePath(identity: MediaIdentity): String? { val imdb = idMappingService.enrich(identity).imdbId ?.removePrefix("tt") ?.toLongOrNull() - ?: return@withContext emptyList() - + ?: return null // Path segments must be in alphabetical order or the API rejects the query. - val base = if (identity.tmdbType == "movie") { + return if (identity.tmdbType == "movie") { "imdbid-$imdb" } else { "episode-${identity.episode}/imdbid-$imdb/season-${identity.season}" } - // The unfiltered query caps its results and often leaves English out, so ask for it separately. - val results = coroutineScope { - val english = async { query("$base/sublanguageid-eng") } - val all = async { query(base) } - english.await() + all.await() - } + } - results + private fun List.toDtos(maxPerLanguage: Int): List = + this .filter { it.format in SUPPORTED_FORMATS && it.url.isNotBlank() } .distinctBy { it.fileId } .sortedWith( @@ -62,7 +79,7 @@ class OpenSubtitlesRepository @Inject constructor( .thenByDescending { it.downloads } ) .groupBy { it.languageName } - .flatMap { (_, sameLanguage) -> sameLanguage.take(MAX_PER_LANGUAGE) } + .flatMap { (_, sameLanguage) -> sameLanguage.take(maxPerLanguage) } .map { subtitle -> SubtitleDto( id = "os_${subtitle.fileId}", @@ -74,7 +91,12 @@ class OpenSubtitlesRepository @Inject constructor( release = subtitle.release ) } - } + + /** OpenSubtitles' own ids: ISO 639-2/B ("ger", not "deu"), and "pob" for Brazilian Portuguese. */ + private fun languageId(language: String): String = + BIBLIOGRAPHIC_CODES[language] + ?: runCatching { Locale.forLanguageTag(language).isO3Language }.getOrNull()?.takeIf { it.isNotEmpty() } + ?: language private fun query(path: String): List = runCatching { val request = Request.Builder() @@ -121,6 +143,13 @@ class OpenSubtitlesRepository @Inject constructor( private const val TAG = "OpenSubtitles" private const val BASE_URL = "https://rest.opensubtitles.org" private const val MAX_PER_LANGUAGE = 3 + private const val MAX_PER_LANGUAGE_ASKED = 12 + private val BIBLIOGRAPHIC_CODES = mapOf( + "pb" to "pob", "de" to "ger", "fr" to "fre", "zh" to "chi", "cs" to "cze", "nl" to "dut", + "el" to "gre", "fa" to "per", "ro" to "rum", "sq" to "alb", "hy" to "arm", "eu" to "baq", + "my" to "bur", "ka" to "geo", "is" to "ice", "mk" to "mac", "ms" to "may", "sk" to "slo", + "cy" to "wel", "bo" to "tib" + ) private val SUPPORTED_FORMATS = setOf("srt", "vtt") } } diff --git a/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt index 0b8e9a5..90b4f44 100644 --- a/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/repository/SubSourceRepository.kt @@ -44,13 +44,30 @@ class SubSourceRepository @Inject constructor( @Named("StreamingClient") private val client: OkHttpClient, private val json: Json ) { - suspend fun search(identity: MediaIdentity): List = withContext(Dispatchers.IO) { - runCatching { searchOrThrow(identity) } + suspend fun search(identity: MediaIdentity): List = search(identity, languages()) + + /** One language by ISO 639-1 code; SubSource names languages in English ("spanish"). */ + suspend fun searchLanguage(identity: MediaIdentity, language: String): List { + val name = Locale.forLanguageTag(language).getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT) + if (name.isBlank() || name == language) return emptyList() + return search(identity, listOf(name to language), MAX_PER_LANGUAGE_ASKED) + } + + private suspend fun search( + identity: MediaIdentity, + languages: List>, + maxPerLanguage: Int = MAX_PER_LANGUAGE + ): List = withContext(Dispatchers.IO) { + runCatching { searchOrThrow(identity, languages, maxPerLanguage) } .onFailure { Log.w(TAG, "SubSource search failed: ${it.message}") } .getOrDefault(emptyList()) } - private suspend fun searchOrThrow(identity: MediaIdentity): List { + private suspend fun searchOrThrow( + identity: MediaIdentity, + languages: List>, + maxPerLanguage: Int + ): List { val imdbId = idMappingService.enrich(identity).imdbId?.takeIf { it.startsWith("tt") } ?: return emptyList() val isMovie = identity.tmdbType == "movie" val link = findLink(imdbId) ?: return emptyList() @@ -62,7 +79,7 @@ class SubSourceRepository @Inject constructor( val episodePattern = if (isMovie) null else episodeRegex(identity.season, identity.episode) return coroutineScope { - languages().map { (name, code) -> + languages.map { (name, code) -> async { list(listPath, name).map { it to code } } }.awaitAll().flatten() } @@ -72,7 +89,7 @@ class SubSourceRepository @Inject constructor( .filter { (row, _) -> episodePattern == null || !EPISODE_RANGE.containsMatchIn(row.releaseInfo) } .distinctBy { (row, _) -> row.id } .groupBy { (row, _) -> row.language } - .flatMap { (_, sameLanguage) -> sameLanguage.take(MAX_PER_LANGUAGE) } + .flatMap { (_, sameLanguage) -> sameLanguage.take(maxPerLanguage) } .map { (row, code) -> SubtitleDto( id = "ss_${row.id}", @@ -150,6 +167,7 @@ class SubSourceRepository @Inject constructor( private const val TAG = "SubSource" private const val API = "https://api.subsource.net/v1" private const val MAX_PER_LANGUAGE = 4 + private const val MAX_PER_LANGUAGE_ASKED = 12 private val EPISODE_RANGE = Regex("(?i)e\\d+\\s*-\\s*e?\\d+") /** `S01E05`, `s1.e5`, `1x05` or a bare `E05`, not followed by another digit. */ diff --git a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt index 78e38dd..f67b83d 100644 --- a/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt +++ b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt @@ -34,6 +34,15 @@ enum class PipAction(val label: String) { SKIP_INTRO("Skip intro") } +/** How the player searches installed sources. */ +enum class SourceSearchMode(val label: String) { + /** Every source at once; the list is sorted by quality, the user's order breaking ties. */ + ALL_AT_ONCE("All at once"), + + /** One source at a time, top of the user's order first, stopping at the first with streams. */ + IN_ORDER("In my order") +} + data class AppSettings( val themeMode: ThemeMode = ThemeMode.SYSTEM, val dynamicColor: Boolean = true, @@ -49,7 +58,12 @@ data class AppSettings( val downloadMaxHeight: Int = 1080, /** Picture-in-picture buttons left and right of play/pause (Android shows three at most). */ val pipLeftAction: PipAction = PipAction.REWIND, - val pipRightAction: PipAction = PipAction.FORWARD + val pipRightAction: PipAction = PipAction.FORWARD, + /** ISO 639-1 codes whose subtitles are saved with every download; empty saves none. */ + val subtitleDownloadLanguages: List = listOf("en"), + /** Besides the video's own subtitles, also save OpenSubtitles and SubSource ones. */ + val subtitleDownloadFromSites: Boolean = true, + val sourceSearchMode: SourceSearchMode = SourceSearchMode.ALL_AT_ONCE ) { companion object { val SEEK_STEPS = listOf(5, 10, 15, 30) @@ -95,6 +109,9 @@ class AppSettingsStore @Inject constructor( .putInt(KEY_DOWNLOAD_HEIGHT, updated.downloadMaxHeight) .putString(KEY_PIP_LEFT, updated.pipLeftAction.name) .putString(KEY_PIP_RIGHT, updated.pipRightAction.name) + .putString(KEY_SUBTITLE_LANGUAGES, updated.subtitleDownloadLanguages.joinToString(",")) + .putBoolean(KEY_SUBTITLE_FROM_SITES, updated.subtitleDownloadFromSites) + .putString(KEY_SOURCE_SEARCH_MODE, updated.sourceSearchMode.name) .apply() } @@ -113,7 +130,12 @@ class AppSettingsStore @Inject constructor( downloadMaxHeight = prefs.getInt(KEY_DOWNLOAD_HEIGHT, defaults.downloadMaxHeight) .takeIf { it in AppSettings.DOWNLOAD_HEIGHTS } ?: defaults.downloadMaxHeight, pipLeftAction = enumOrDefault(prefs.getString(KEY_PIP_LEFT, null), defaults.pipLeftAction), - pipRightAction = enumOrDefault(prefs.getString(KEY_PIP_RIGHT, null), defaults.pipRightAction) + pipRightAction = enumOrDefault(prefs.getString(KEY_PIP_RIGHT, null), defaults.pipRightAction), + subtitleDownloadLanguages = prefs.getString(KEY_SUBTITLE_LANGUAGES, null) + ?.let { parseLanguages(it) } + ?: defaults.subtitleDownloadLanguages, + subtitleDownloadFromSites = prefs.getBoolean(KEY_SUBTITLE_FROM_SITES, defaults.subtitleDownloadFromSites), + sourceSearchMode = enumOrDefault(prefs.getString(KEY_SOURCE_SEARCH_MODE, null), defaults.sourceSearchMode) ) } @@ -121,6 +143,13 @@ class AppSettingsStore @Inject constructor( name?.let { value -> enumValues().firstOrNull { it.name == value } } ?: default private companion object { + /** "en,es" as stored; an empty string means none. */ + fun parseLanguages(stored: String): List = + stored.split(',').map { it.trim() }.filter { it.isNotEmpty() }.distinct() + + const val KEY_SUBTITLE_LANGUAGES = "app_subtitle_download_languages" + const val KEY_SUBTITLE_FROM_SITES = "app_subtitle_download_from_sites" + const val KEY_SOURCE_SEARCH_MODE = "app_source_search_mode" const val KEY_THEME = "app_theme_mode" const val KEY_DYNAMIC_COLOR = "app_dynamic_color" const val KEY_DNS = "app_dns_provider" diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt index 3101d8d..187e96f 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt @@ -11,6 +11,7 @@ import com.ivor.openstream.data.streaming.anime.CloudflareClearance import com.ivor.openstream.data.streaming.anime.MegaplayExtractor import com.ivor.openstream.data.streaming.anime.ReAnimeProvider import com.ivor.openstream.data.streaming.providers.VidkingDirectApi +import com.ivor.openstream.data.streaming.providers.StremioAddonProvider import com.ivor.openstream.data.streaming.providers.VidkingDirectProvider import com.ivor.openstream.data.streaming.providers.VidkingServerSpec import com.ivor.openstream.data.streaming.providers.WebEmbedProvider @@ -62,19 +63,28 @@ class ExtensionProviderRegistry @Inject constructor( @Named("StreamingClient") private val streamingClient: OkHttpClient, private val json: Json ) { - private val cache = ConcurrentHashMap() + private val cache = ConcurrentHashMap() + /** + * In source order. A provider's priority is its position in that order (the user's ranking + * first, then the catalog's), which is what ranking and "search in my order" go by. + */ fun activeProviders(): List = extensionRepository.activeExtensions() - .mapNotNull(::providerFor) - .distinctBy { it.id } - .sortedBy { it.priority } + .mapNotNull { extension -> + val delegate = delegateFor(extension) ?: return@mapNotNull null + extension to delegate + } + .distinctBy { (_, delegate) -> delegate.id } + .mapIndexed { position, (extension, delegate) -> + ExtensionStreamProvider(extensionKey = extension.key, delegate = delegate, priority = position) + } fun recordOutcome(provider: ExtensionStreamProvider, success: Boolean) { extensionRepository.recordOutcome(provider.extensionKey, success) } - private fun providerFor(extension: MarketplaceExtension): ExtensionStreamProvider? { + private fun delegateFor(extension: MarketplaceExtension): StreamProvider? { val manifest = extension.manifest val engine = manifest.engine if (!engine.isRunnable) return null @@ -141,16 +151,20 @@ class ExtensionProviderRegistry @Inject constructor( mapper = animeEpisodeMapper, client = streamingClient ) + ExtensionEngineType.STREMIO -> StremioAddonProvider( + id = "stremio-${manifest.id}", + displayName = manifest.name, + priority = engine.priority, + isFallback = manifest.isFallback, + baseUrl = engine.endpoint.removeSuffix("/manifest.json").trimEnd('/'), + client = streamingClient, + json = json + ) ExtensionEngineType.UNSUPPORTED -> return null } - val provider = ExtensionStreamProvider( - extensionKey = manifest.key, - delegate = delegate, - priority = engine.priority - ) - cache[cacheKey] = provider - return provider + cache[cacheKey] = delegate + return delegate } private fun animeSiteSpec(manifest: ExtensionManifest) = AnimeSiteSpec( diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/ServerRanker.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ServerRanker.kt index 9873d13..5677b64 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/ServerRanker.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/ServerRanker.kt @@ -7,13 +7,15 @@ internal object ServerRanker { existing: List, incoming: List, providerPriorities: Map, - preferredServerId: String? + preferredServerId: String?, + /** The user's source order before quality ("search in my order"); otherwise quality first. */ + providerFirst: Boolean = false ): List { val merged = linkedMapOf() (existing + incoming).forEach { candidate -> val key = candidate.url.substringBefore('#') val current = merged[key] - if (current == null || compare(candidate, current, providerPriorities) < 0) { + if (current == null || compare(candidate, current, providerPriorities, providerFirst) < 0) { merged[key] = candidate } } @@ -22,7 +24,7 @@ internal object ServerRanker { when { left.id == preferredServerId && right.id != preferredServerId -> -1 right.id == preferredServerId && left.id != preferredServerId -> 1 - else -> compare(left, right, providerPriorities) + else -> compare(left, right, providerPriorities, providerFirst) } } } @@ -30,14 +32,16 @@ internal object ServerRanker { private fun compare( left: VideoServer, right: VideoServer, - providerPriorities: Map + providerPriorities: Map, + providerFirst: Boolean ): Int { + val provider = (providerPriorities[left.providerId] ?: Int.MAX_VALUE) + .compareTo(providerPriorities[right.providerId] ?: Int.MAX_VALUE) + if (providerFirst && provider != 0) return provider val quality = right.quality.rank.compareTo(left.quality.rank) if (quality != 0) return quality val audio = right.audio.rank.compareTo(left.audio.rank) if (audio != 0) return audio - val provider = (providerPriorities[left.providerId] ?: Int.MAX_VALUE) - .compareTo(providerPriorities[right.providerId] ?: Int.MAX_VALUE) if (provider != 0) return provider return left.name.compareTo(right.name, ignoreCase = true) } diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/StreamingRepositoryImpl.kt b/app/src/main/java/com/ivor/openstream/data/streaming/StreamingRepositoryImpl.kt index 91057f8..71163ba 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/StreamingRepositoryImpl.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/StreamingRepositoryImpl.kt @@ -5,14 +5,25 @@ import com.ivor.openstream.domain.model.MediaIdentity import com.ivor.openstream.domain.model.ServerResolution import com.ivor.openstream.domain.model.VideoServer import com.ivor.openstream.domain.repository.StreamingRepository +import android.util.Log +import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.data.settings.SourceSearchMode +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.last import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import okhttp3.OkHttpClient import okhttp3.Request import java.io.IOException @@ -26,20 +37,36 @@ class StreamingRepositoryImpl @Inject constructor( private val providerRegistry: ExtensionProviderRegistry, private val idMappingService: IdMappingService, @Named("StreamingClient") private val client: OkHttpClient, - private val preferences: SharedPreferences + private val preferences: SharedPreferences, + private val appSettings: AppSettingsStore ) : StreamingRepository { private val consecutiveFailures = ConcurrentHashMap() + /** When a provider's breaker last opened; it is retried once [BREAKER_COOLDOWN_MS] has passed. */ + private val breakerOpenedAt = ConcurrentHashMap() + + /** + * Provider work runs here rather than inside the flow, so a provider stuck in a blocking HTTP + * call can't hold the flow open past its deadline; it is cancelled when it stops mattering. + */ + private val providerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** + * By default every installed provider runs at once and servers are sent as each one answers, + * so playback can start on the first. Fallback providers join when direct ones have found + * nothing, either once all of them are done or after [FALLBACK_HEAD_START_MS], whichever comes + * first. With [SourceSearchMode.IN_ORDER] providers run one at a time in the user's order and + * the search stops at the first that finds something ("Find more" then searches the rest). + */ override fun resolveServers( identity: MediaIdentity, includeFallbacks: Boolean ): Flow = channelFlow { - val enrichedIdentity = idMappingService.enrich(identity) val installedProviders = withContext(Dispatchers.IO) { providerRegistry.activeProviders() } val providerPriorities = installedProviders.associate { it.id to it.priority } - val enabledProviders = installedProviders.filter { - it.isEnabled && (consecutiveFailures[it.id] ?: 0) < CIRCUIT_BREAKER_THRESHOLD - } + val installedEnabled = installedProviders.filter { it.isEnabled } + // Never let the breaker leave nothing to try: then everything gets another go. + val enabledProviders = installedEnabled.filterNot { isBreakerOpen(it.id) }.ifEmpty { installedEnabled } val directProviders = if (includeFallbacks) { enabledProviders } else { @@ -50,34 +77,28 @@ class StreamingRepositoryImpl @Inject constructor( } else { enabledProviders.filter(ExtensionStreamProvider::isFallback) } + val inOrder = appSettings.current.sourceSearchMode == SourceSearchMode.IN_ORDER val firstStageProviders = directProviders.ifEmpty { fallbackProviders } val deferredFallbackProviders = fallbackProviders.takeIf { directProviders.isNotEmpty() }.orEmpty() - val preferredServerId = preferences.getString(preferenceKey(identity), null) - send(ServerResolution(totalProviders = firstStageProviders.size)) + // Shown straight away, before the id lookup, so the screen says sources are being searched. + send(ServerResolution(totalProviders = if (inOrder) enabledProviders.size else firstStageProviders.size)) if (firstStageProviders.isEmpty()) { send(ServerResolution(isComplete = true)) return@channelFlow } - val outcomes = Channel(enabledProviders.size) - firstStageProviders.forEach { provider -> - launch(Dispatchers.IO) { - val result = runCatching { - withTimeout(PROVIDER_TIMEOUT_MS) { - provider.resolve(enrichedIdentity).getOrThrow() - } - } - outcomes.send(ProviderOutcome(provider, result)) - } - } + // Providers that only know IMDb ids need this; the rest must not wait long on TMDB for it. + val enrichedIdentity = withTimeoutOrNull(ID_LOOKUP_TIMEOUT_MS) { idMappingService.enrich(identity) } ?: identity + val preferredServerId = preferences.getString(preferenceKey(identity), null) var servers = emptyList() val failedProviders = mutableListOf() - repeat(firstStageProviders.size) { completedIndex -> - val outcome = outcomes.receive() + + fun absorb(outcome: ResolutionEvent.Outcome) { outcome.result.fold( onSuccess = { incoming -> - consecutiveFailures[outcome.provider.id] = 0 + consecutiveFailures.remove(outcome.provider.id) + breakerOpenedAt.remove(outcome.provider.id) providerRegistry.recordOutcome(outcome.provider, incoming.isNotEmpty()) if (incoming.isEmpty()) { failedProviders += outcome.provider.displayName @@ -86,81 +107,135 @@ class StreamingRepositoryImpl @Inject constructor( existing = servers, incoming = incoming, providerPriorities = providerPriorities, - preferredServerId = preferredServerId + preferredServerId = preferredServerId, + providerFirst = inOrder ) } }, - onFailure = { - consecutiveFailures.compute(outcome.provider.id) { _, count -> (count ?: 0) + 1 } + onFailure = { error -> + Log.w(TAG, "${outcome.provider.displayName} failed: ${error.message}") + recordFailure(outcome.provider.id) providerRegistry.recordOutcome(outcome.provider, false) failedProviders += outcome.provider.displayName } ) - val completed = completedIndex + 1 - val firstStageComplete = completed == firstStageProviders.size - val shouldTryFallback = firstStageComplete && - servers.isEmpty() && - deferredFallbackProviders.isNotEmpty() - send( - ServerResolution( - servers = servers, - completedProviders = completed, - totalProviders = firstStageProviders.size + - if (shouldTryFallback) deferredFallbackProviders.size else 0, - failedProviders = failedProviders.toList(), - isComplete = firstStageComplete && !shouldTryFallback - ) - ) } - if (servers.isEmpty() && deferredFallbackProviders.isNotEmpty()) { - deferredFallbackProviders.forEach { provider -> - launch(Dispatchers.IO) { - val result = runCatching { - withTimeout(PROVIDER_TIMEOUT_MS) { - provider.resolve(enrichedIdentity).getOrThrow() - } - } - outcomes.send(ProviderOutcome(provider, result)) + if (inOrder && !includeFallbacks) { + // The user's order exactly, fallbacks included wherever they were placed. + enabledProviders.forEachIndexed { index, provider -> + absorb(runProvider(provider, enrichedIdentity)) + val found = servers.isNotEmpty() + send(progress(servers, index + 1, enabledProviders.size, failedProviders, isComplete = found || index == enabledProviders.lastIndex)) + if (found) return@channelFlow + } + return@channelFlow + } + + val events = Channel(Channel.UNLIMITED) + var pending = 0 + var total = 0 + var fallbacksStarted = false + + fun start(providers: List) { + pending += providers.size + total += providers.size + providers.forEach { provider -> launch { events.send(runProvider(provider, enrichedIdentity)) } } + } + + fun startFallbacks() { + if (fallbacksStarted || deferredFallbackProviders.isEmpty()) return + fallbacksStarted = true + start(deferredFallbackProviders) + } + + start(firstStageProviders) + val headStart = if (deferredFallbackProviders.isNotEmpty()) { + launch { + delay(FALLBACK_HEAD_START_MS) + events.send(ResolutionEvent.FallbackDeadline) + } + } else { + null + } + + while (pending > 0) { + when (val event = events.receive()) { + ResolutionEvent.FallbackDeadline -> if (servers.isEmpty()) { + startFallbacks() + send(progress(servers, total - pending, total, failedProviders, isComplete = false)) + } + is ResolutionEvent.Outcome -> { + pending-- + absorb(event) + // Direct providers are all done and found nothing: fallbacks are the last hope. + if (pending == 0 && servers.isEmpty()) startFallbacks() + send(progress(servers, total - pending, total, failedProviders, isComplete = pending == 0)) } } + } + headStart?.cancel() + events.close() + } - repeat(deferredFallbackProviders.size) { completedIndex -> - val outcome = outcomes.receive() - outcome.result.fold( - onSuccess = { incoming -> - consecutiveFailures[outcome.provider.id] = 0 - providerRegistry.recordOutcome(outcome.provider, incoming.isNotEmpty()) - if (incoming.isEmpty()) { - failedProviders += outcome.provider.displayName - } else { - servers = ServerRanker.mergeAndRank( - existing = servers, - incoming = incoming, - providerPriorities = providerPriorities, - preferredServerId = preferredServerId - ) - } - }, - onFailure = { - consecutiveFailures.compute(outcome.provider.id) { _, count -> (count ?: 0) + 1 } - providerRegistry.recordOutcome(outcome.provider, false) - failedProviders += outcome.provider.displayName - } - ) - val completed = firstStageProviders.size + completedIndex + 1 - send( - ServerResolution( - servers = servers, - completedProviders = completed, - totalProviders = firstStageProviders.size + deferredFallbackProviders.size, - failedProviders = failedProviders.toList(), - isComplete = completedIndex == deferredFallbackProviders.lastIndex - ) - ) + /** + * One provider's answer, or a timeout failure at [PROVIDER_TIMEOUT_MS] even if it is stuck in + * blocking I/O (coroutine timeouts can't interrupt OkHttp's execute(), but awaiting can stop). + * When the caller goes away this throws CancellationException, and nothing is recorded: + * leaving a screen is not the provider's fault. + */ + private suspend fun runProvider(provider: ExtensionStreamProvider, identity: MediaIdentity): ResolutionEvent.Outcome { + val work = providerScope.async { + try { + provider.resolve(identity) + } catch (cancelled: CancellationException) { + // Its own internal timeout, not us cancelling it: that is a failure to report. + if (!isActive) throw cancelled + Result.failure(IOException("Timed out", cancelled)) + } catch (error: Throwable) { + Result.failure(error) + } + } + try { + val result = try { + withTimeoutOrNull(PROVIDER_TIMEOUT_MS) { work.await() } + ?: Result.failure(IOException("No answer within ${PROVIDER_TIMEOUT_MS / 1000} s")) + } catch (cancelled: CancellationException) { + // Rethrows only if the caller is really gone; otherwise every provider must + // produce an outcome, or the resolution would wait for it forever. + currentCoroutineContext().ensureActive() + Result.failure(IOException("Cancelled", cancelled)) } + return ResolutionEvent.Outcome(provider, result) + } finally { + work.cancel() } - outcomes.close() + } + + private fun progress( + servers: List, + completed: Int, + total: Int, + failedProviders: List, + isComplete: Boolean + ) = ServerResolution( + servers = servers, + completedProviders = completed, + totalProviders = total, + failedProviders = failedProviders.toList(), + isComplete = isComplete + ) + + private fun recordFailure(providerId: String) { + val failures = consecutiveFailures.merge(providerId, 1) { old, added -> old + added } ?: 1 + if (failures >= CIRCUIT_BREAKER_THRESHOLD) breakerOpenedAt[providerId] = System.currentTimeMillis() + } + + /** Skipped after repeated failures, but only for a while: networks and sites recover. */ + private fun isBreakerOpen(providerId: String): Boolean { + if ((consecutiveFailures[providerId] ?: 0) < CIRCUIT_BREAKER_THRESHOLD) return false + val openedAt = breakerOpenedAt[providerId] ?: return false + return System.currentTimeMillis() - openedAt < BREAKER_COOLDOWN_MS } override suspend fun getServers(identity: MediaIdentity): List = @@ -188,13 +263,21 @@ class StreamingRepositoryImpl @Inject constructor( private fun preferenceKey(identity: MediaIdentity): String = "last_stream_server:${identity.cacheKey}" - private data class ProviderOutcome( - val provider: ExtensionStreamProvider, - val result: Result> - ) + private sealed interface ResolutionEvent { + data class Outcome(val provider: ExtensionStreamProvider, val result: Result>) : ResolutionEvent + + /** Direct providers have had [FALLBACK_HEAD_START_MS] on their own. */ + data object FallbackDeadline : ResolutionEvent + } private companion object { - const val PROVIDER_TIMEOUT_MS = 20_000L + const val TAG = "StreamingRepository" + + /** Long enough for a WebView source (native hoster pass, then up to 17 s in the WebView). */ + const val PROVIDER_TIMEOUT_MS = 25_000L + const val ID_LOOKUP_TIMEOUT_MS = 8_000L + const val FALLBACK_HEAD_START_MS = 8_000L const val CIRCUIT_BREAKER_THRESHOLD = 5 + const val BREAKER_COOLDOWN_MS = 2 * 60_000L } } diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeEpisodeMapper.kt b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeEpisodeMapper.kt index 70cdeed..4996138 100644 --- a/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeEpisodeMapper.kt +++ b/app/src/main/java/com/ivor/openstream/data/streaming/anime/AnimeEpisodeMapper.kt @@ -55,14 +55,18 @@ class AnimeEpisodeMapper @Inject constructor( private val tmdbApi: TmdbApi, private val json: Json ) { - private val lock = Mutex() + /** + * One lock per episode: the anime providers resolving it together share one lookup, while a + * lookup still stuck on the episode the user just left doesn't hold up the next one. + */ + private val locks = ConcurrentHashMap() private val refs = ConcurrentHashMap() private val entries = ConcurrentHashMap() private val roots = ConcurrentHashMap() suspend fun map(identity: MediaIdentity): AnimeEpisodeRef? = withContext(Dispatchers.IO) { refs[identity.cacheKey]?.let { return@withContext it.ref } - lock.withLock { + locks.getOrPut(identity.cacheKey) { Mutex() }.withLock { refs[identity.cacheKey]?.let { return@withLock it.ref } val ref = runCatching { lookup(identity) }.getOrElse { error -> // Network trouble (or cancellation) is not an answer: don't cache it. diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioAddonProvider.kt b/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioAddonProvider.kt new file mode 100644 index 0000000..8c967e5 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioAddonProvider.kt @@ -0,0 +1,283 @@ +package com.ivor.openstream.data.streaming.providers + +import android.util.Log +import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import com.ivor.openstream.data.streaming.StreamProvider +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository +import com.ivor.openstream.domain.model.MediaIdentity +import com.ivor.openstream.domain.model.StreamQuality +import com.ivor.openstream.domain.model.StreamSubtitle +import com.ivor.openstream.domain.model.VideoServer +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReferenceArray + +/** + * A Stremio add-on, spoken over its public HTTP protocol + * (https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/protocol.md): + * `{base}/manifest.json` says which content types and id schemes it serves, and + * `{base}/stream/{movie|series}/{id}.json` lists streams, where `id` is an IMDb id + * (`tt0133093`, or `tt0903747:1:1` for an episode) or, when the add-on accepts them, `tmdb:` ids. + * + * Only streams with a direct `url` can play here. Torrent (`infoHash`), YouTube and external-link + * streams are skipped: there is no torrent engine or debrid account behind them. Configured add-ons + * keep their settings in the base URL, so they work as pasted. + */ +class StremioAddonProvider( + override val id: String, + override val displayName: String, + override val priority: Int, + override val isFallback: Boolean, + private val baseUrl: String, + private val client: OkHttpClient, + private val json: Json +) : StreamProvider { + override val isEnabled: Boolean = true + + /** Read once per app run; add-ons rarely change what they serve. */ + @Volatile + private var addonInfo: AddonInfo? = null + + override suspend fun resolve(identity: MediaIdentity): Result> = withContext(Dispatchers.IO) { + try { + val info = addonInfo ?: loadAddonInfo().also { addonInfo = it } + val type = if (identity.tmdbType == "movie") "movie" else "series" + if (info.types.isNotEmpty() && type !in info.types) return@withContext Result.success(emptyList()) + + // The add-on's preferred id scheme first; the next only when the first finds nothing. + for (videoId in candidateIds(identity, info)) { + val servers = fetchStreams(type, videoId, identity.title) + if (servers.isNotEmpty()) return@withContext Result.success(servers) + } + Result.success(emptyList()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Log.w(TAG, "$displayName: ${error.message}") + Result.failure(error) + } + } + + private fun candidateIds(identity: MediaIdentity, info: AddonInfo): List { + val suffix = if (identity.tmdbType == "movie") "" else ":${identity.season}:${identity.episode}" + val prefixes = info.idPrefixes + val imdb = identity.imdbId?.takeIf { it.startsWith("tt") } + val takesImdb = prefixes == null || prefixes.any { it.startsWith("tt") || it == "imdb" } + val takesTmdb = prefixes != null && prefixes.any { it.startsWith("tmdb") } + return buildList { + if (imdb != null && takesImdb) add(imdb + suffix) + if (takesTmdb) add("tmdb:${identity.tmdbId}$suffix") + } + } + + private suspend fun fetchStreams(type: String, videoId: String, title: String): List { + val url = baseUrl.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegment("stream") + ?.addPathSegment(type) + ?.addPathSegment("$videoId.json") + ?.build() + ?: throw IOException("Bad add-on address") + val body = get(url.toString()) ?: return emptyList() + val streams = (json.parseToJsonElement(body) as? JsonObject)?.get("streams") as? JsonArray ?: return emptyList() + + // Add-ons often list one release several times, once per file host; keep one entry each. + val releases = LinkedHashMap>() + streams.forEach { element -> + val candidate = (element as? JsonObject)?.let { toCandidate(it, title) } ?: return@forEach + releases.getOrPut(candidate.releaseKey) { mutableListOf() } += candidate.server + } + val seenIds = HashSet() + val labelCounts = HashMap() + return firstPlayableMirrors(releases.values.take(MAX_STREAMS)).map { server -> + // Two releases with the same label still need different ids, and names to tell them apart. + var serverId = server.id + var suffix = 2 + while (!seenIds.add(serverId)) serverId = "${server.id}-${suffix++}" + val repeat = labelCounts.merge(server.name, 1) { old, added -> old + added } ?: 1 + server.copy(id = serverId, name = if (repeat > 1) "${server.name} ($repeat)" else server.name) + } + } + + /** + * For each release, the first mirror that answers like a video. Add-ons hand out links that are + * already dead (expired signed URLs, hosts asking for a captcha, redirects to a 404); listing + * those made the player fail over through them one by one. Releases are checked in parallel + * within [VERIFY_BUDGET_MS]; whatever hasn't answered by then is left out. + */ + private suspend fun firstPlayableMirrors(releases: List>): List = coroutineScope { + val playable = AtomicReferenceArray(releases.size) + val gate = Semaphore(VERIFY_PARALLELISM) + withTimeoutOrNull(VERIFY_BUDGET_MS) { + releases.mapIndexed { index, mirrors -> + launch(Dispatchers.IO) { + gate.withPermit { + playable.set(index, mirrors.firstOrNull { looksPlayable(it) }) + } + } + }.joinAll() + } + List(releases.size) { playable.get(it) }.filterNotNull() + } + + /** A 16-byte range request: video bytes (or an HLS playlist) rather than an error page. */ + private fun looksPlayable(server: VideoServer): Boolean = runCatching { + val request = Request.Builder() + .url(server.url) + .apply { server.headers.forEach { (name, value) -> header(name, value) } } + .header("Range", "bytes=0-15") + .build() + probeClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use false + val head = ByteArray(16) + val read = response.body?.byteStream()?.read(head) ?: -1 + if (read <= 0) return@use false + val start = String(head, 0, read, Charsets.ISO_8859_1).trimStart() + when { + server.isHls -> start.startsWith("#EXTM3U") + // HTML/XML error pages and JSON error bodies. + start.startsWith("<") || start.startsWith("{") -> false + else -> true + } + } + }.getOrDefault(false) + + private val probeClient: OkHttpClient by lazy { + client.newBuilder() + .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() + } + + /** A stream as a server, plus the key that groups its mirrors on other hosts. */ + private class Candidate(val server: VideoServer, val releaseKey: String) + + private fun toCandidate(stream: JsonObject, title: String): Candidate? { + val url = stream.text("url")?.trim() + if (url == null || !(url.startsWith("https://") || url.startsWith("http://"))) return null + + val hints = stream["behaviorHints"] as? JsonObject + val filename = hints?.text("filename") + val parts = StremioStreamLabels.Parts( + nameLines = stream.text("name").nonBlankLines(), + detailLines = (stream.text("title") ?: stream.text("description")).nonBlankLines(), + filename = filename + ) + val label = StremioStreamLabels.label(parts, title, fallback = displayName).take(MAX_LABEL_LENGTH) + val releaseKey = parts.releaseKey.takeIf { it.length > 1 } ?: url + val requestHeaders = ((hints?.get("proxyHeaders") as? JsonObject)?.get("request") as? JsonObject) + ?.mapNotNull { (name, value) -> (value as? JsonPrimitive)?.contentOrNull?.let { name to it } } + ?.toMap() + .orEmpty() + + val subtitles = (stream["subtitles"] as? JsonArray).orEmpty().mapNotNull { element -> + val subtitle = element as? JsonObject ?: return@mapNotNull null + val subtitleUrl = subtitle.text("url")?.takeIf { it.startsWith("http") } ?: return@mapNotNull null + val rawLanguage = subtitle.text("lang") + val language = SavedSubtitleRepository.normalizeLanguage(rawLanguage) + StreamSubtitle( + url = subtitleUrl, + label = language?.let { SavedSubtitleRepository.languageName(it) } ?: rawLanguage ?: "Subtitles", + language = language + ) + } + + val searchable = listOfNotNull(stream.text("name"), stream.text("title"), stream.text("description"), filename) + .joinToString(" ") + val server = VideoServer( + id = "$id-" + Integer.toHexString(releaseKey.hashCode()), + providerId = id, + providerName = displayName, + name = label, + url = url, + quality = qualityOf(searchable), + headers = mapOf("User-Agent" to BROWSER_USER_AGENT) + requestHeaders, + subtitles = subtitles + ) + return Candidate(server, releaseKey) + } + + /** + * An explicit resolution ("1080p") wins over words like "4K" that add-ons also use in their + * own names ("4KHDHub 1080p" is a 1080p stream). + */ + private fun qualityOf(text: String): StreamQuality { + val height = Regex("(?i)(?:^|\\D)(2160|1440|1080|720|480|360)p").find(text)?.groupValues?.get(1) + return when { + height != null -> StreamQuality.parse("${height}p") + Regex("(?i)\\b(4k|uhd)\\b").containsMatchIn(text) -> StreamQuality.Q2160 + else -> StreamQuality.UNKNOWN + } + } + + private fun loadAddonInfo(): AddonInfo { + val body = runCatching { get("${baseUrl.trimEnd('/')}/manifest.json") }.getOrNull() + ?: return AddonInfo(types = emptySet(), idPrefixes = null) + val manifest = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() + ?: return AddonInfo(types = emptySet(), idPrefixes = null) + // A "stream" resource given as an object may narrow types and id prefixes for itself. + val streamResource = (manifest["resources"] as? JsonArray).orEmpty() + .filterIsInstance() + .firstOrNull { it.text("name") == "stream" } + val types = (streamResource?.get("types") ?: manifest["types"]).strings() + val prefixes = (streamResource?.get("idPrefixes") ?: manifest["idPrefixes"]) + ?.let { it.strings() } + ?.takeIf { it.isNotEmpty() } + return AddonInfo(types = types.toSet(), idPrefixes = prefixes) + } + + private fun get(url: String): String? { + val request = Request.Builder() + .url(url) + .header("User-Agent", BROWSER_USER_AGENT) + .header("Accept", "application/json") + .build() + return client.newCall(request).execute().use { response -> + when { + response.isSuccessful -> response.body?.string() + // Many add-ons answer 404 for titles they don't have. + response.code == 404 -> null + else -> throw IOException("$displayName returned HTTP ${response.code}") + } + } + } + + private data class AddonInfo( + val types: Set, + /** Null when the add-on doesn't say: then IMDb ids are assumed, as in Stremio itself. */ + val idPrefixes: List? + ) + + private fun JsonObject.text(key: String): String? = (get(key) as? JsonPrimitive)?.contentOrNull + + private fun JsonElement?.strings(): List = + (this as? JsonArray).orEmpty().mapNotNull { (it as? JsonPrimitive)?.contentOrNull?.trim() }.filter { it.isNotEmpty() } + + private fun String?.nonBlankLines(): List = + this?.split('\n')?.map { it.trim() }?.filter { it.isNotEmpty() }.orEmpty() + + private companion object { + const val TAG = "StremioAddon" + const val MAX_STREAMS = 30 + const val MAX_LABEL_LENGTH = 110 + const val VERIFY_PARALLELISM = 8 + const val VERIFY_BUDGET_MS = 8_000L + const val PROBE_TIMEOUT_MS = 4_000L + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioStreamLabels.kt b/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioStreamLabels.kt new file mode 100644 index 0000000..5189344 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioStreamLabels.kt @@ -0,0 +1,159 @@ +package com.ivor.openstream.data.streaming.providers + +import java.util.Locale + +/** + * Short, consistent names for Stremio streams. Add-ons describe streams however they like: host + * tags and emoji ("[PixelDrain] [6.3 GB] …"), scene file names + * ("Breaking.Bad.S01.E01.1080p.BluRay.Hindi.DD2.0-English.5.1.ESub.x264-HDHub4u.Tv.mkv"), or blocks + * of emoji-led lines. This pulls out what helps pick a stream, in a fixed order: + * "1080p · BluRay · HEVC 10-bit · Hindi + English · 1.98 GB". When a stream says too little for + * that, a cleaned-up remainder of its own text leads ("kisskh", "Source 2 · Dubbed"). + */ +internal object StremioStreamLabels { + + /** What one stream's text says, already split into lines. */ + class Parts( + val nameLines: List, + val detailLines: List, + val filename: String? + ) { + private val firstDetail = detailLines.firstOrNull().orEmpty() + + val size: String? = SIZE.find(firstDetail)?.value?.let { raw -> + raw.replace(Regex("\\s+"), "").replace(Regex("(?i)([GM]B)$")) { " " + it.value.uppercase(Locale.ROOT) } + } + + /** + * The descriptive lines: the first without its leading host/size tags, then the rest minus + * host footers ("pixeldrain | HdHub"). Mirrors of one release on different hosts share them. + */ + val body: List = listOf(firstDetail.replace(LEADING_TAGS, "").trim()) + + detailLines.drop(1).filterNot { " | " in it } + + /** Groups mirrors: same descriptive text and size. */ + val releaseKey: String = + (filename ?: body.joinToString(" ")).lowercase(Locale.ROOT).filter { it.isLetterOrDigit() } + + "|" + size.orEmpty() + } + + fun label(parts: Parts, title: String, fallback: String): String { + val text = (parts.nameLines + parts.body + listOfNotNull(parts.filename)).joinToString(" ") + val facts = facts(text).toMutableList() + if (facts.size < 2) { + val hasLanguage = tokens(text).any { it in LANGUAGES } + leadingText(parts, title, hasLanguage)?.let { facts.add(0, it) } + } + parts.size?.let { facts += it } + return facts.joinToString(" · ").ifEmpty { fallback } + } + + private fun facts(text: String): List { + val low = text.lowercase(Locale.ROOT) + val words = tokens(text) + val facts = mutableListOf() + + val height = Regex("(?:^|\\D)(2160|1440|1080|720|480)p").find(low)?.groupValues?.get(1) + when { + height == "2160" -> facts += "4K" + height != null -> facts += "${height}p" + Regex("\\b(4k|uhd)\\b").containsMatchIn(low) -> facts += "4K" + } + when { + Regex("\\b(dolby.?vision|dovi|dv)\\b").containsMatchIn(low) -> facts += "Dolby Vision" + Regex("\\bhdr(10\\+?)?\\b").containsMatchIn(low) -> facts += "HDR" + } + if ("imax" in words) facts += "IMAX" + when { + "remux" in words -> facts += "Remux" + Regex("blu-?ray|bdrip|brrip").containsMatchIn(low) -> facts += "BluRay" + Regex("web-?dl").containsMatchIn(low) -> facts += "WEB-DL" + "webrip" in words -> facts += "WEBRip" + "hdtv" in words -> facts += "HDTV" + } + val tenBit = Regex("10.?bit").containsMatchIn(low) + when { + Regex("hevc|x265|h\\.?265|x2645").containsMatchIn(low) -> facts += if (tenBit) "HEVC 10-bit" else "HEVC" + "av1" in words -> facts += "AV1" + Regex("x264|h\\.?264|\\bavc\\b").containsMatchIn(low) -> facts += "H.264" + } + if (Regex("60\\s*fps").containsMatchIn(low)) facts += "60fps" + + val languages = words.mapNotNull { LANGUAGES[it] }.distinct() + when { + languages.isNotEmpty() -> facts += languages.take(3).joinToString(" + ") + Regex("dual.?(audio|áudio)|\\bdual\\b").containsMatchIn(low) -> facts += "Dual audio" + Regex("\\bmulti\\b").containsMatchIn(low) -> facts += "Multi audio" + } + when { + Regex("dublado|dubbed|\\bdub\\b").containsMatchIn(low) -> facts += "Dubbed" + Regex("legendado|subbed|\\bsub\\b").containsMatchIn(low) -> facts += "Subtitled" + } + return facts + } + + /** + * The add-on's own words when its facts are thin: a sub-source from the name ("kisskh"), else + * the first detail line, minus the film's title, years, episode markers, emoji and words the + * facts already say. + */ + private fun leadingText(parts: Parts, title: String, hasLanguage: Boolean): String? { + val titleWords = tokens(title).filterNot { it in STOP_WORDS }.toSet() + fun tidy(raw: String): String { + var text = clean(raw.replace(LEADING_TAGS, "")) + text = text.replace(Regex("(?i)\\b(?:fonte|source|servidor|server|link)\\b\\D{0,20}?0*(\\d{1,2})\\b"), "Source $1") + text = text.replace(Regex("(?i)\\b[ST]\\d{1,2}\\s*EP?\\s*\\d{1,3}\\b|\\bseason\\s*\\d+\\b|\\(\\d{4}\\)|\\b(19|20)\\d{2}\\b"), "") + return text.split(Regex("\\s+")) + .filter { it.isNotEmpty() } + .filterNot { word -> tokens(word).let { it.isNotEmpty() && it.all { token -> token in titleWords } } } + .filterNot { word -> hasLanguage && tokens(word).any { it in LANGUAGES } } + .filterNot { DUB_SUB_WORDS.matches(it) } + .joinToString(" ") + .trim(' ', '-', '|', '•', '·', ':') + } + val nameExtra = parts.nameLines.drop(1).joinToString(" ") + .replace(Regex("(?i)\\b(2160|1440|1080|720|480)p\\b|\\b4k\\b"), "") + return listOf(nameExtra, parts.detailLines.firstOrNull().orEmpty()) + .map(::tidy) + .firstOrNull { it.isNotEmpty() } + ?.take(MAX_LEADING_LENGTH) + } + + /** Drops emoji, flags and other symbols, then tidies spacing. */ + fun clean(text: String): String { + val kept = StringBuilder() + text.codePoints().forEach { codePoint -> + val type = Character.getType(codePoint) + val drop = type == Character.OTHER_SYMBOL.toInt() || + type == Character.MODIFIER_SYMBOL.toInt() || + type == Character.PRIVATE_USE.toInt() || + codePoint == 0xFE0F || codePoint == 0x200D + if (!drop) kept.appendCodePoint(codePoint) + } + return kept.toString().replace(Regex("\\s+"), " ").trim(' ', '-', '|', '•', '·', ':') + } + + private fun tokens(text: String): List = + text.lowercase(Locale.ROOT).split(Regex("[^\\p{L}\\p{N}]+")).filter { it.isNotEmpty() } + + private val SIZE = Regex("\\d+(?:[.,]\\d+)?\\s*[GM]B\\b", RegexOption.IGNORE_CASE) + + /** Bracketed host and size tags add-ons put before the release name. */ + val LEADING_TAGS = Regex("^(\\s*\\[[^\\]]*]\\s*)+") + + private val STOP_WORDS = setOf("the", "a", "an", "of", "and") + private val DUB_SUB_WORDS = Regex("(?i)dublado|legendado|dubbed|subbed|subtitled|dual|audio|áudio") + private const val MAX_LEADING_LENGTH = 40 + + /** Words and scene abbreviations for audio languages, as they appear in release names. */ + private val LANGUAGES = mapOf( + "hindi" to "Hindi", "hin" to "Hindi", "english" to "English", "eng" to "English", + "tamil" to "Tamil", "tam" to "Tamil", "telugu" to "Telugu", "tel" to "Telugu", + "malayalam" to "Malayalam", "kannada" to "Kannada", "bengali" to "Bengali", "punjabi" to "Punjabi", + "marathi" to "Marathi", "korean" to "Korean", "kor" to "Korean", "japanese" to "Japanese", + "jpn" to "Japanese", "jap" to "Japanese", "chinese" to "Chinese", "mandarin" to "Chinese", + "spanish" to "Spanish", "spa" to "Spanish", "french" to "French", "german" to "German", + "italian" to "Italian", "portuguese" to "Portuguese", "português" to "Portuguese", + "russian" to "Russian", "arabic" to "Arabic", "turkish" to "Turkish", "thai" to "Thai", "urdu" to "Urdu" + ) +} diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/DownloadSubtitleSaver.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/DownloadSubtitleSaver.kt new file mode 100644 index 0000000..ef64512 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/DownloadSubtitleSaver.kt @@ -0,0 +1,83 @@ +package com.ivor.openstream.data.subtitles + +import android.util.Log +import com.ivor.openstream.data.local.dao.DownloadDao +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.domain.model.DownloadTarget +import com.ivor.openstream.domain.repository.SubtitleRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Saves subtitles alongside each download, in the languages chosen in Settings: every one the + * video's own stream carries in those languages, plus (if enabled) every OpenSubtitles and SubSource + * release in them. Runs in the app's scope when a download starts, so they are there offline. + */ +@Singleton +class DownloadSubtitleSaver @Inject constructor( + private val savedSubtitles: SavedSubtitleRepository, + private val subtitleRepository: SubtitleRepository, + private val appSettings: AppSettingsStore, + private val downloadDao: DownloadDao +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val jobs = ConcurrentHashMap() + + fun saveFor(target: DownloadTarget, stream: List) { + val settings = appSettings.current + val languages = settings.subtitleDownloadLanguages.toSet() + if (languages.isEmpty()) return + jobs.remove(target.id)?.cancel() + val job = scope.launch { + val picks = mutableListOf>>() + stream.filter { it.subtitle.language in languages }.forEach { picks += it.subtitle to it.headers } + if (settings.subtitleDownloadFromSites) picks += fromSites(target, languages).map { it to emptyMap() } + + picks.distinctBy { it.first.url }.forEach { (subtitle, headers) -> + try { + savedSubtitles.save( + target.mediaType, target.tmdbId, target.season, target.episode, subtitle, headers + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Log.w(TAG, "Could not save ${subtitle.display} for ${target.id}: ${error.message}") + } + } + // Deleted while these were being fetched: don't leave them behind. + if (downloadDao.getDownloadById(target.id) == null) { + savedSubtitles.deleteAll(target.mediaType, target.tmdbId, target.season, target.episode) + } + } + jobs[target.id] = job + job.invokeOnCompletion { jobs.remove(target.id, job) } + } + + /** Stops saving for a download that is being removed. */ + fun cancel(downloadId: String) { + jobs.remove(downloadId)?.cancel() + } + + private suspend fun fromSites(target: DownloadTarget, languages: Set): List { + val identity = target.toIdentity() + val general = runCatching { subtitleRepository.search(identity) }.getOrDefault(emptyList()) + // The general search covers English and a few others; the rest have to be asked for by name. + val missing = languages.filter { language -> general.none { it.language == language } } + val asked = missing.flatMap { language -> + runCatching { subtitleRepository.searchLanguage(identity, language) }.getOrDefault(emptyList()) + } + return (general + asked).filter { it.language in languages } + } + + private companion object { + const val TAG = "DownloadSubtitles" + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt new file mode 100644 index 0000000..1015c60 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt @@ -0,0 +1,386 @@ +package com.ivor.openstream.data.subtitles + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.domain.model.DownloadTarget +import com.ivor.openstream.domain.model.StreamSubtitle +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.json.Json +import java.io.File +import java.io.IOException +import java.util.Locale +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** A subtitle file kept on the device for one movie or episode. */ +@Serializable +data class SavedSubtitle( + val id: String, + val fileName: String, + /** ISO 639-1 when known. */ + val language: String? = null, + val label: String, + val release: String? = null, + /** Where it came from: a subtitle site's name, or [SavedSubtitleRepository.IMPORTED]. */ + val origin: String? = null, + /** The [SubtitleDto.id] it was saved from, so the same file is not listed twice. */ + val originId: String? = null, + val isHearingImpaired: Boolean = false, + val savedAt: Long = System.currentTimeMillis() +) + +/** A subtitle the video's own stream offered, with the headers its host wants. */ +@Serializable +data class StreamSubtitleCandidate( + val subtitle: SubtitleDto, + val headers: Map = emptyMap() +) + +/** + * Subtitles saved for offline viewing, one folder per movie or episode under `filesDir/subtitles` + * holding the files (already unzipped and decoded to UTF-8) and an `index.json`. + * + * Folders are keyed by title and episode rather than by download, so the player finds them + * whether the episode plays from a download or a stream. + */ +@Singleton +class SavedSubtitleRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val fetcher: SubtitleFetcher, + private val json: Json +) { + private val changes = MutableStateFlow(0L) + private val writeLock = Mutex() + private val indexSerializer = ListSerializer(SavedSubtitle.serializer()) + private val streamSerializer = ListSerializer(StreamSubtitleCandidate.serializer()) + + fun observe(mediaType: String, tmdbId: Int, season: Int, episode: Int): Flow> { + val dir = directory(mediaType, tmdbId, season, episode) + return changes.map { readIndex(dir) }.distinctUntilChanged().flowOn(Dispatchers.IO) + } + + /** How many subtitles each folder holds, by [keyFor]. */ + fun observeCounts(): Flow> = changes + .map { + rootDirectory(context).listFiles().orEmpty() + .filter { it.isDirectory } + .associate { it.name to readIndex(it).size } + .filterValues { it > 0 } + } + .distinctUntilChanged() + .flowOn(Dispatchers.IO) + + /** + * Keeps the subtitles a download's stream carried (the ones the player shows under the source's + * name), so the Downloads sheet can offer them later without looking the stream up again. + */ + suspend fun rememberStreamSubtitles( + mediaType: String, + tmdbId: Int, + season: Int, + episode: Int, + candidates: List + ) = withContext(Dispatchers.IO) { + if (candidates.isEmpty()) return@withContext + val dir = directory(mediaType, tmdbId, season, episode) + writeLock.withLock { + dir.mkdirs() + val merged = (candidates + readStream(dir)).distinctBy { it.subtitle.url } + val temp = File(dir, "$STREAM_FILE.tmp") + temp.writeText(json.encodeToString(streamSerializer, merged)) + if (!temp.renameTo(File(dir, STREAM_FILE))) throw IOException("Could not record stream subtitles") + } + } + + suspend fun streamSubtitles(mediaType: String, tmdbId: Int, season: Int, episode: Int): List = + withContext(Dispatchers.IO) { readStream(directory(mediaType, tmdbId, season, episode)) } + + /** The player's entry for [saved]: a local file read back through [SubtitleFetcher]. */ + fun toSubtitleDto(mediaType: String, tmdbId: Int, season: Int, episode: Int, saved: SavedSubtitle) = SubtitleDto( + id = "saved_${saved.id}", + url = Uri.fromFile(File(directory(mediaType, tmdbId, season, episode), saved.fileName)).toString(), + display = saved.label, + language = saved.language, + isHearingImpaired = saved.isHearingImpaired, + source = SOURCE_NAME, + release = saved.release ?: saved.origin + ) + + /** Downloads [subtitle] and keeps it. Saving the same one again returns the copy already kept. */ + suspend fun save( + mediaType: String, + tmdbId: Int, + season: Int, + episode: Int, + subtitle: SubtitleDto, + headers: Map = emptyMap() + ): SavedSubtitle = withContext(Dispatchers.IO) { + val dir = directory(mediaType, tmdbId, season, episode) + readIndex(dir).firstOrNull { it.originId == subtitle.id }?.let { return@withContext it } + val text = fetcher.fetchText(subtitle.url, headers) + if (parseSubtitles(text).isEmpty()) { + throw IOException( + if (text.trimStart().startsWith("<")) { + "the source sent a web page instead of subtitles (the link may have expired)" + } else { + "that file has no readable subtitles" + } + ) + } + add( + dir, + text, + SavedSubtitle( + id = newId(), + fileName = "", + language = subtitle.language, + label = subtitle.display ?: subtitle.language?.let { languageName(it) } ?: "Subtitles", + release = subtitle.release, + origin = subtitle.source, + originId = subtitle.id, + isHearingImpaired = subtitle.isHearingImpaired + ) + ) + } + + /** Copies a subtitle file the user picked (SRT, VTT, ASS, or a zip/gz holding one). */ + suspend fun import(mediaType: String, tmdbId: Int, season: Int, episode: Int, uri: Uri): SavedSubtitle = + withContext(Dispatchers.IO) { + val resolver = context.contentResolver + val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } ?: uri.lastPathSegment.orEmpty() + val bytes = resolver.openInputStream(uri)?.use { input -> + val buffer = input.readNBytesCompat(MAX_IMPORT_BYTES + 1) + if (buffer.size > MAX_IMPORT_BYTES) throw IOException("That file is too large for a subtitle") + buffer + } ?: throw IOException("Could not open that file") + val text = fetcher.textOf(bytes) + if (parseSubtitles(text).isEmpty()) throw IOException("No subtitles found in that file") + + val baseName = name.substringBeforeLast('.').ifBlank { "Subtitles" } + val language = guessLanguage(baseName) + add( + directory(mediaType, tmdbId, season, episode), + text, + SavedSubtitle( + id = newId(), + fileName = "", + language = language, + label = language?.let { languageName(it) } ?: baseName, + release = baseName.takeIf { language != null }, + origin = IMPORTED, + isHearingImpaired = tokens(baseName).any { it in HEARING_IMPAIRED_TAGS && !(it == "hi" && language == "hi") } + ) + ) + } + + suspend fun delete(mediaType: String, tmdbId: Int, season: Int, episode: Int, id: String) = withContext(Dispatchers.IO) { + val dir = directory(mediaType, tmdbId, season, episode) + writeLock.withLock { + val index = readIndex(dir) + val removed = index.firstOrNull { it.id == id } ?: return@withLock + File(dir, removed.fileName).delete() + writeIndex(dir, index - removed) + } + changes.update { it + 1 } + } + + /** Removes every subtitle saved for this movie or episode. */ + suspend fun deleteAll(mediaType: String, tmdbId: Int, season: Int, episode: Int) = withContext(Dispatchers.IO) { + writeLock.withLock { directory(mediaType, tmdbId, season, episode).deleteRecursively() } + changes.update { it + 1 } + } + + private suspend fun add(dir: File, text: String, entry: SavedSubtitle): SavedSubtitle = withContext(Dispatchers.IO) { + val saved = entry.copy(fileName = "${entry.id}.${extensionFor(text)}") + writeLock.withLock { + dir.mkdirs() + File(dir, saved.fileName).writeText(text) + writeIndex(dir, readIndex(dir) + saved) + } + changes.update { it + 1 } + saved + } + + private fun readIndex(dir: File): List { + val file = File(dir, INDEX_FILE) + if (!file.exists()) return emptyList() + return runCatching { json.decodeFromString(indexSerializer, file.readText()) } + .getOrDefault(emptyList()) + // A file deleted from outside the app should not stay listed. + .filter { File(dir, it.fileName).exists() } + } + + private fun readStream(dir: File): List { + val file = File(dir, STREAM_FILE) + if (!file.exists()) return emptyList() + return runCatching { json.decodeFromString(streamSerializer, file.readText()) }.getOrDefault(emptyList()) + } + + private fun writeIndex(dir: File, entries: List) { + if (entries.isEmpty()) { + // The stream's subtitle list stays; the folder goes only once nothing is left in it. + File(dir, INDEX_FILE).delete() + if (dir.listFiles().isNullOrEmpty()) dir.delete() + return + } + val temp = File(dir, "$INDEX_FILE.tmp") + temp.writeText(json.encodeToString(indexSerializer, entries)) + if (!temp.renameTo(File(dir, INDEX_FILE))) throw IOException("Could not update saved subtitles") + } + + private fun directory(mediaType: String, tmdbId: Int, season: Int, episode: Int) = + File(rootDirectory(context), keyFor(mediaType, tmdbId, season, episode)) + + private fun newId() = UUID.randomUUID().toString().replace("-", "").take(12) + + private fun extensionFor(text: String): String { + val head = text.trimStart().take(200) + return when { + head.startsWith("WEBVTT") -> "vtt" + "[Script Info]" in head || "[Events]" in text -> "ass" + else -> "srt" + } + } + + private fun java.io.InputStream.readNBytesCompat(limit: Int): ByteArray { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8 * 1024) + while (out.size() < limit) { + val read = read(buffer, 0, minOf(buffer.size, limit - out.size())) + if (read < 0) break + out.write(buffer, 0, read) + } + return out.toByteArray() + } + + companion object { + /** Source name the player shows under a saved subtitle. */ + const val SOURCE_NAME = "Saved" + const val IMPORTED = "Imported" + private const val INDEX_FILE = "index.json" + private const val STREAM_FILE = "stream.json" + + /** Languages offered by name (ISO 639-1, plus OpenSubtitles' "pb" for Brazilian Portuguese). */ + val COMMON_LANGUAGES = listOf( + "en", "es", "fr", "de", "it", "pt", "pb", "nl", "sv", "no", "da", "fi", "is", "pl", "cs", "sk", + "sl", "hr", "sr", "bs", "bg", "mk", "ro", "hu", "el", "tr", "ru", "uk", "et", "lv", "lt", "sq", + "ar", "he", "fa", "ur", "hi", "bn", "ta", "te", "ml", "kn", "mr", "si", "ne", "zh", "ja", "ko", + "th", "vi", "id", "ms", "tl", "my", "km", "ka", "hy", "eu", "ca", "gl" + ) + + /** File types [SubtitleFetcher] may read back from the folder; never the JSON files. */ + val FILE_EXTENSIONS = setOf("srt", "vtt", "ass") + + /** The player-style list entries for a server's own subtitles. */ + fun streamCandidates( + providerName: String?, + subtitles: List, + serverHeaders: Map + ): List = subtitles.map { subtitle -> + val language = normalizeLanguage(subtitle.language) ?: normalizeLanguage(subtitle.label) + StreamSubtitleCandidate( + subtitle = SubtitleDto( + // Stable across lookups, so a saved one is recognised the next time. + id = "stream_" + Integer.toHexString(subtitle.url.hashCode()), + url = subtitle.url, + display = subtitle.label.ifBlank { language?.let { languageName(it) } ?: "Subtitles" }, + language = language, + isHearingImpaired = Regex("\\b(sdh|cc|hi)\\b", RegexOption.IGNORE_CASE).containsMatchIn(subtitle.label), + source = providerName, + release = providerName?.let { "From $it" } + ), + headers = serverHeaders + subtitle.headers + ) + } + + /** ISO 639-1 from a code ("en", "eng") or an English name ("English", "Spanish (Latin)"). */ + fun normalizeLanguage(raw: String?): String? { + val value = raw?.trim()?.lowercase(Locale.ROOT)?.takeIf { it.isNotEmpty() } ?: return null + val iso2 = Locale.getISOLanguages().toSet() + val code = value.substringBefore('-').substringBefore('_') + if (code.length == 2 && code in iso2) return code + if (code.length == 3) { + BIBLIOGRAPHIC_TO_ISO2[code]?.let { return it } + Locale.getISOLanguages().firstOrNull { Locale.forLanguageTag(it).isO3Language == code }?.let { return it } + } + val firstWord = value.split(Regex("[^\\p{L}]+")).firstOrNull { it.isNotEmpty() } ?: return null + return Locale.getISOLanguages().firstOrNull { + Locale.forLanguageTag(it).getDisplayLanguage(Locale.ENGLISH).equals(firstWord, ignoreCase = true) + } + } + private const val MAX_IMPORT_BYTES = 5 * 1024 * 1024 + private val HEARING_IMPAIRED_TAGS = setOf("sdh", "hi", "cc") + + fun rootDirectory(context: Context) = File(context.filesDir, "subtitles") + + /** Same as the download id, so movies use season and episode 1 as downloads do. */ + fun keyFor(mediaType: String, tmdbId: Int, season: Int, episode: Int): String = + if (mediaType == "movie") { + DownloadTarget.downloadIdFor(mediaType, tmdbId, 1, 1) + } else { + DownloadTarget.downloadIdFor(mediaType, tmdbId, season, episode) + } + + fun languageName(code: String): String { + // OpenSubtitles' own codes, not ISO 639-1. + when (code) { + "pb" -> return "Portuguese (Brazil)" + "zt" -> return "Chinese (Traditional)" + "ze" -> return "Chinese and English" + } + val name = Locale.forLanguageTag(code).getDisplayLanguage(Locale.ENGLISH) + return if (name.isBlank() || name.equals(code, ignoreCase = true)) code.uppercase(Locale.ROOT) else name + } + + private fun tokens(name: String) = name.lowercase(Locale.ROOT).split(Regex("[^\\p{L}0-9]+")).filter { it.isNotEmpty() } + + /** + * The language a file name ends with, like "Show.S01E02.1080p.es.srt" or + * "Movie (Spanish).srt". "hi" usually marks hearing-impaired subtitles rather than Hindi, + * so it only counts when nothing else matches. + */ + internal fun guessLanguage(fileName: String): String? { + val iso2 = Locale.getISOLanguages().toSet() + val byIso3 = Locale.getISOLanguages().associateBy { Locale.forLanguageTag(it).isO3Language } + val byName = Locale.getISOLanguages().associateBy { + Locale.forLanguageTag(it).getDisplayLanguage(Locale.ENGLISH).lowercase(Locale.ROOT) + } + val tokens = tokens(fileName) + val matches = tokens.mapIndexedNotNull { index, token -> + // Short codes only near the end, where they mark the language rather than a title word ("It"). + val isTail = index >= tokens.size - 3 + when { + token.length == 2 && isTail && token in iso2 -> token + token.length == 3 && isTail && token in BIBLIOGRAPHIC_TO_ISO2 -> BIBLIOGRAPHIC_TO_ISO2[token] + token.length == 3 && isTail && token in byIso3 -> byIso3[token] + token.length > 3 -> byName[token] + else -> null + } + } + return matches.lastOrNull { it != "hi" } ?: matches.lastOrNull() + } + + private val BIBLIOGRAPHIC_TO_ISO2 = mapOf( + "ger" to "de", "fre" to "fr", "chi" to "zh", "cze" to "cs", "dut" to "nl", "gre" to "el", + "per" to "fa", "rum" to "ro", "alb" to "sq", "arm" to "hy", "baq" to "eu", "bur" to "my", + "geo" to "ka", "ice" to "is", "mac" to "mk", "may" to "ms", "slo" to "sk", "wel" to "cy" + ) + } +} diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt index dc9a2a0..3242311 100644 --- a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleFetcher.kt @@ -1,17 +1,24 @@ package com.ivor.openstream.data.subtitles +import android.content.Context import com.ivor.openstream.data.repository.OpenSubtitlesRepository import com.ivor.openstream.data.repository.SubSourceRepository import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import java.io.ByteArrayInputStream +import java.io.File import java.io.IOException import java.nio.ByteBuffer import java.nio.charset.CharacterCodingException +import java.net.URI import java.nio.charset.CodingErrorAction import java.util.zip.GZIPInputStream import java.util.zip.ZipInputStream @@ -22,14 +29,17 @@ import javax.inject.Singleton /** * Downloads a subtitle file as text, whatever it came wrapped in: OpenSubtitles' gzip files, * SubSource's zip archives (behind a download token), or plain SRT/VTT/ASS from a stream's own - * host. Shared by the player and the cast proxy. + * host, or a file saved on the device ([SavedSubtitleRepository]). Shared by the player and the + * cast proxy. */ @Singleton class SubtitleFetcher @Inject constructor( + @ApplicationContext private val context: Context, @Named("StreamingClient") private val client: OkHttpClient ) { /** The subtitle's text. [headers] are the stream's (Referer...), sent only to the stream's hosts. */ suspend fun fetchText(url: String, headers: Map = emptyMap()): String = withContext(Dispatchers.IO) { + if (url.startsWith("file:")) return@withContext readSaved(url) val bytes = when { SubSourceRepository.isSubSourceUrl(url) -> download(SubSourceRepository.resolveDownloadUrl(client, url), emptyMap()) url.toHttpUrlOrNull()?.host?.endsWith("opensubtitles.org") == true -> @@ -37,9 +47,68 @@ class SubtitleFetcher @Inject constructor( download(url, mapOf("User-Agent" to OpenSubtitlesRepository.USER_AGENT)) else -> download(url, mapOf("User-Agent" to BROWSER_USER_AGENT) + headers) } + val text = textOf(bytes) + if (text.isPlaylist()) joinPlaylist(url, text, headers) else text + } + + /** + * Streams often list subtitles as an HLS playlist of WebVTT segments. ExoPlayer reads those + * itself, but a saved copy (and the cast proxy) needs one file: the segments, in order, joined. + * A master playlist is followed to its first media playlist. + */ + private suspend fun joinPlaylist( + playlistUrl: String, + playlist: String, + headers: Map, + depth: Int = 0 + ): String = coroutineScope { + val base = playlistUrl.toHttpUrlOrNull() ?: throw IOException("Bad subtitle playlist address") + val entries = playlist.lines().map(String::trim) + .filter { it.isNotEmpty() && !it.startsWith("#") } + .mapNotNull { base.resolve(it)?.toString() } + if (entries.isEmpty()) throw IOException("The subtitle playlist is empty") + val requestHeaders = mapOf("User-Agent" to BROWSER_USER_AGENT) + headers + + if (depth == 0 && Regex("#EXT-X-STREAM-INF|#EXT-X-MEDIA").containsMatchIn(playlist)) { + val media = entries.first() + val text = textOf(download(media, requestHeaders)) + if (!text.isPlaylist()) return@coroutineScope text + return@coroutineScope joinPlaylist(media, text, headers, depth + 1) + } + + val parts = entries.take(MAX_PLAYLIST_SEGMENTS).chunked(PLAYLIST_PARALLELISM).flatMap { batch -> + batch.map { segmentUrl -> + async(Dispatchers.IO) { + // An empty segment (no cues in that stretch) is normal; skip it. + runCatching { textOf(download(segmentUrl, requestHeaders)) } + .getOrElse { error -> if (error is IOException && error.message == EMPTY_FILE) null else throw error } + } + }.awaitAll() + }.filterNotNull() + if (parts.isEmpty()) throw IOException("The subtitle playlist has no cues") + parts.joinToString("\n\n") + } + + private fun String.isPlaylist() = trimStart().startsWith("#EXTM3U") + + /** Subtitle text from raw file bytes: unzips or gunzips when needed, then guesses the charset. */ + fun textOf(bytes: ByteArray): String { val text = decode(unwrap(bytes)) - if (text.isBlank()) throw IOException("Subtitle file is empty") - text + if (text.isBlank()) throw IOException(EMPTY_FILE) + return text + } + + /** + * Saved subtitles only. The cast proxy hands any URL it is given to [fetchText], so a file + * path from anywhere else on the device must never be read. + */ + private fun readSaved(url: String): String { + val file = File(URI(url)).canonicalFile + val root = SavedSubtitleRepository.rootDirectory(context).canonicalPath + File.separator + if (!file.path.startsWith(root) || file.extension.lowercase() !in SavedSubtitleRepository.FILE_EXTENSIONS) { + throw IOException("Not a saved subtitle") + } + return file.readText() } private fun download(url: String, headers: Map): ByteArray { @@ -90,5 +159,8 @@ class SubtitleFetcher @Inject constructor( private companion object { val SUBTITLE_EXTENSIONS = listOf(".srt", ".vtt", ".ass", ".ssa") + const val EMPTY_FILE = "Subtitle file is empty" + const val MAX_PLAYLIST_SEGMENTS = 3_000 + const val PLAYLIST_PARALLELISM = 6 } } diff --git a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt index 728ea61..93df13b 100644 --- a/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SubtitleParser.kt @@ -39,7 +39,8 @@ fun parseSubtitles(content: String): List { } } else { Log.i("PlayerSubtitles", "Detected SRT/VTT format") - val timestampRegex = Regex("(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2}[,.]\\d{3})") + // WebVTT may leave the hours out ("01:02.345"); SRT always has them ("00:01:02,345"). + val timestampRegex = Regex("((?:\\d{1,2}:)?\\d{1,2}:\\d{2}[,.]\\d{1,3})\\s*-->\\s*((?:\\d{1,2}:)?\\d{1,2}:\\d{2}[,.]\\d{1,3})") val blocks = cleanContent.split(Regex("\\n\\s*\\n")).filter { it.isNotBlank() } for (block in blocks) { @@ -92,15 +93,16 @@ private fun parseAssTimestamp(ts: String): Long { } } +/** "hh:mm:ss.mmm", or "mm:ss.mmm" as WebVTT allows; a comma works as the separator too. */ private fun parseTimestamp(ts: String): Long { - val clean = ts.replace(',', '.') + val clean = ts.trim().replace(',', '.') val parts = clean.split(':') - if (parts.size < 3) return 0L - - val secondsParts = parts[2].split('.') - - val h = parts[0].toLongOrNull() ?: 0L - val m = parts[1].toLongOrNull() ?: 0L + if (parts.size !in 2..3) return 0L + + val secondsParts = parts.last().split('.') + + val h = if (parts.size == 3) parts[0].toLongOrNull() ?: 0L else 0L + val m = parts[parts.size - 2].toLongOrNull() ?: 0L val s = secondsParts[0].toLongOrNull() ?: 0L val ms = if (secondsParts.size > 1) { secondsParts[1].padEnd(3, '0').take(3).toLongOrNull() ?: 0L diff --git a/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt b/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt index 6760eee..8a230fd 100644 --- a/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt +++ b/app/src/main/java/com/ivor/openstream/domain/model/ExtensionMarketplace.kt @@ -34,6 +34,8 @@ enum class ExtensionEngineType(val key: String) { ANIMEPAHE("animepahe"), FOURANIMO("fouranimo"), ANIMEGG("animegg"), + /** A Stremio add-on; `endpoint` is its base URL (the manifest URL without `/manifest.json`). */ + STREMIO("stremio"), UNSUPPORTED("unsupported"); companion object { @@ -63,6 +65,8 @@ data class ExtensionEngine( ExtensionEngineType.ANIMEPAHE, ExtensionEngineType.FOURANIMO, ExtensionEngineType.ANIMEGG -> endpoint.startsWith("https://") + ExtensionEngineType.STREMIO -> + endpoint.startsWith("https://") || endpoint.startsWith("http://") ExtensionEngineType.UNSUPPORTED -> false } diff --git a/app/src/main/java/com/ivor/openstream/domain/repository/ExtensionRepository.kt b/app/src/main/java/com/ivor/openstream/domain/repository/ExtensionRepository.kt index 4dbaa11..3188daa 100644 --- a/app/src/main/java/com/ivor/openstream/domain/repository/ExtensionRepository.kt +++ b/app/src/main/java/com/ivor/openstream/domain/repository/ExtensionRepository.kt @@ -30,9 +30,12 @@ interface ExtensionRepository { /** Updates every installed extension that has a newer version and returns how many changed. */ fun updateAll(): Int - /** Installed + enabled + runnable on this build, ordered by engine priority. */ + /** Installed + enabled + runnable on this build, in source order (the user's ranking first). */ fun activeExtensions(): List + /** Ranks installed sources: [keys] first, in this order. */ + fun setSourceOrder(keys: List) + /** Records whether an extension produced a usable stream, feeding the reliability signal. */ fun recordOutcome(key: String, success: Boolean) } diff --git a/app/src/main/java/com/ivor/openstream/domain/repository/SubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/domain/repository/SubtitleRepository.kt index adbaf1f..c2384e3 100644 --- a/app/src/main/java/com/ivor/openstream/domain/repository/SubtitleRepository.kt +++ b/app/src/main/java/com/ivor/openstream/domain/repository/SubtitleRepository.kt @@ -6,4 +6,8 @@ import com.ivor.openstream.domain.model.MediaIdentity interface SubtitleRepository { /** External subtitles for a movie or episode, best matches first. Empty when none are found. */ suspend fun search(identity: MediaIdentity): List + + /** More releases in one language ([language] is ISO 639-1), including ones [search] leaves out. */ + suspend fun searchLanguage(identity: MediaIdentity, language: String): List = + search(identity).filter { it.language == language } } diff --git a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt new file mode 100644 index 0000000..27e7462 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt @@ -0,0 +1,562 @@ +package com.ivor.openstream.presentation.downloads + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.Hearing +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LoadingIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ivor.openstream.data.local.entity.DownloadEntity +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.data.subtitles.SavedSubtitle +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository +import com.ivor.openstream.ui.theme.ExpressiveShapes + +/** + * Subtitles for one finished download: what is saved on the device (these play offline), results + * from the subtitle sites to tick and save, and importing a file the user already has. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun DownloadSubtitlesSheet( + download: DownloadEntity, + viewModel: DownloadSubtitlesViewModel, + onDismiss: () -> Unit +) { + LaunchedEffect(download.downloadId) { viewModel.open(download) } + val state by viewModel.uiState.collectAsState() + var pickingLanguage by remember { mutableStateOf(false) } + val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let(viewModel::importFile) + } + + ModalBottomSheet( + onDismissRequest = { + viewModel.close() + onDismiss() + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + LazyColumn( + modifier = Modifier.weight(1f, fill = false), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + item(key = "header") { SheetHeader(download) } + + // On this device + item(key = "saved-title") { + SectionHeader( + title = "On this device", + detail = if (state.saved.isEmpty()) null else "Works offline", + action = { + FilledTonalButton( + onClick = { importLauncher.launch(arrayOf("*/*")) }, + enabled = !state.isImporting + ) { + if (state.isImporting) { + LoadingIndicator(Modifier.size(18.dp)) + } else { + Icon(Icons.Default.FileOpen, contentDescription = null, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.width(8.dp)) + Text("Import file") + } + } + ) + } + if (state.saved.isEmpty()) { + item(key = "saved-empty") { + HintCard( + icon = { Icon(Icons.Default.ClosedCaption, contentDescription = null) }, + text = "Nothing saved yet. Pick subtitles below, or import an SRT, VTT or ASS file you already have." + ) + } + } + itemsIndexed(state.saved, key = { _, item -> "saved:${item.id}" }) { index, saved -> + SavedRow( + saved = saved, + index = index, + count = state.saved.size, + onDelete = { viewModel.delete(saved) }, + modifier = Modifier.animateItem() + ) + } + + state.message?.let { message -> + item(key = "message") { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp) + ) + } + } + + // Online + item(key = "online-title") { + SectionHeader( + title = "Find online", + detail = if (state.isCheckingSource) { + "Checking the video's source, OpenSubtitles and SubSource…" + } else { + "The video's source, OpenSubtitles and SubSource" + }, + modifier = Modifier.padding(top = 12.dp) + ) + } + item(key = "filters") { + FilterRow( + languages = state.languages, + languageFilter = state.languageFilter, + hideHearingImpaired = state.hideHearingImpaired, + onLanguage = viewModel::setLanguageFilter, + onHideHearingImpaired = viewModel::setHideHearingImpaired, + onOtherLanguage = { pickingLanguage = true } + ) + } + + val groups = state.groupedOnline + val searching = state.isSearching || state.isCheckingSource || + (state.languageFilter != null && state.languageFilter in state.searchingLanguages) + when { + searching && groups.isEmpty() -> item(key = "searching") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { LoadingIndicator() } + } + groups.isEmpty() -> item(key = "online-empty") { + val language = state.languageFilter + HintCard( + icon = { Icon(if (state.online.isEmpty()) Icons.Default.WifiOff else Icons.Default.Search, contentDescription = null) }, + text = when { + language != null -> "No ${SavedSubtitleRepository.languageName(language)} subtitles found for this one." + state.online.isEmpty() -> "Nothing found. Searching needs a connection; saved subtitles above still work offline." + else -> "Everything here is hidden by the filters." + }, + action = { + TextButton(onClick = { + if (language != null) viewModel.searchLanguage(language) else viewModel.retrySearch() + }) { Text("Search again") } + } + ) + } + } + groups.forEach { (language, subtitles) -> + item(key = "group:$language") { + LanguageHeader( + name = language?.let { SavedSubtitleRepository.languageName(it) } ?: "Other", + count = subtitles.size, + isSearching = language != null && language in state.searchingLanguages, + onSearchMore = language?.let { code -> { viewModel.searchLanguage(code) } } + ) + } + itemsIndexed(subtitles, key = { _, item -> "online:${item.id}:${item.url}" }) { index, subtitle -> + OnlineRow( + subtitle = subtitle, + index = index, + count = subtitles.size, + isSaved = subtitle.id in state.savedOriginIds, + isSaving = subtitle.id in state.saving, + isSelected = subtitle.id in state.selected, + onToggle = { viewModel.toggle(subtitle) } + ) + } + } + if (searching && groups.isNotEmpty()) { + item(key = "searching-more") { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + LoadingIndicator(Modifier.size(40.dp)) + } + } + } + } + + AnimatedVisibility( + visible = state.selected.isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + SelectionBar( + count = state.selected.size, + onClear = viewModel::clearSelection, + onSave = viewModel::saveSelected + ) + } + Spacer(Modifier.navigationBarsPadding()) + } + + if (pickingLanguage) { + LanguagePickerDialog( + onPick = { language -> + pickingLanguage = false + viewModel.searchLanguage(language) + }, + onDismiss = { pickingLanguage = false } + ) + } +} + +@Composable +private fun SheetHeader(download: DownloadEntity) { + Row( + modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 4.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = ExpressiveShapes.medium, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) { + Icon(Icons.Default.ClosedCaption, contentDescription = null, modifier = Modifier.padding(12.dp)) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + text = "Subtitles", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + modifier = Modifier.semantics { heading() } + ) + Text( + text = download.sheetTitle(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun SectionHeader( + title: String, + detail: String?, + modifier: Modifier = Modifier, + action: (@Composable () -> Unit)? = null +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 12.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { heading() } + ) + detail?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + action?.invoke() + } +} + +@Composable +private fun HintCard( + icon: @Composable () -> Unit, + text: String, + action: (@Composable () -> Unit)? = null +) { + Surface( + shape = ExpressiveShapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(24.dp), contentAlignment = Alignment.Center) { icon() } + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + action?.let { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { it() } + } + } + } +} + +@Composable +private fun FilterRow( + languages: List, + languageFilter: String?, + hideHearingImpaired: Boolean, + onLanguage: (String?) -> Unit, + onHideHearingImpaired: (Boolean) -> Unit, + onOtherLanguage: () -> Unit +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 4.dp), + modifier = Modifier.padding(bottom = 8.dp) + ) { + item(key = "all") { + FilterChip( + selected = languageFilter == null, + onClick = { onLanguage(null) }, + label = { Text("All languages") } + ) + } + // A language searched by name stays in the row even before results come back. + val shown = if (languageFilter != null && languageFilter !in languages) listOf(languageFilter) + languages else languages + items(shown, key = { it }) { language -> + FilterChip( + selected = languageFilter == language, + onClick = { onLanguage(if (languageFilter == language) null else language) }, + label = { Text(SavedSubtitleRepository.languageName(language)) } + ) + } + item(key = "other") { + AssistChip( + onClick = onOtherLanguage, + label = { Text("Another language") }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(18.dp)) } + ) + } + item(key = "sdh") { + FilterChip( + selected = hideHearingImpaired, + onClick = { onHideHearingImpaired(!hideHearingImpaired) }, + label = { Text("Hide SDH") }, + leadingIcon = { + Icon(Icons.Default.Hearing, contentDescription = null, modifier = Modifier.size(FilterChipDefaults.IconSize)) + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun LanguageHeader(name: String, count: Int, isSearching: Boolean, onSearchMore: (() -> Unit)?) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, top = 12.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() } + ) + Text( + text = " $count", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + when { + isSearching -> LoadingIndicator(Modifier.size(32.dp)) + onSearchMore != null -> TextButton(onClick = onSearchMore) { Text("More releases") } + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun SavedRow( + saved: SavedSubtitle, + index: Int, + count: Int, + onDelete: () -> Unit, + modifier: Modifier = Modifier +) { + SegmentedListItem( + shapes = ListItemDefaults.segmentedShapes(index = index, count = count), + colors = ListItemDefaults.segmentedColors(), + modifier = modifier, + leadingContent = { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + }, + supportingContent = { + val detail = listOfNotNull( + saved.release, + saved.origin, + "SDH".takeIf { saved.isHearingImpaired && "(SDH)" !in saved.label } + ).joinToString(" · ") + if (detail.isNotEmpty()) Text(detail, maxLines = 2, overflow = TextOverflow.Ellipsis) + }, + trailingContent = { + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete ${saved.label} subtitles") + } + } + ) { + Text(saved.label, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun OnlineRow( + subtitle: SubtitleDto, + index: Int, + count: Int, + isSaved: Boolean, + isSaving: Boolean, + isSelected: Boolean, + onToggle: () -> Unit +) { + val label = subtitle.display ?: subtitle.language?.let { SavedSubtitleRepository.languageName(it) } ?: "Subtitles" + SegmentedListItem( + checked = isSelected, + onCheckedChange = { onToggle() }, + enabled = !isSaved && !isSaving, + shapes = ListItemDefaults.segmentedShapes(index = index, count = count), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + when { + isSaved -> Icon(Icons.Default.CheckCircle, contentDescription = "Saved", tint = MaterialTheme.colorScheme.primary) + isSaving -> LoadingIndicator(Modifier.size(32.dp)) + // The row itself toggles; the box only shows the state. + else -> Checkbox(checked = isSelected, onCheckedChange = null) + } + } + }, + supportingContent = { + Text( + text = when { + isSaved -> "Saved · " + (subtitle.release ?: subtitle.source.orEmpty()) + isSaving -> "Saving…" + else -> listOfNotNull(subtitle.release, subtitle.source).joinToString(" · ") + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + ) { + Text(label, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +@Composable +private fun SelectionBar(count: Int, onClear: () -> Unit, onSave: () -> Unit) { + Column { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = if (count == 1) "1 selected" else "$count selected", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + TextButton(onClick = onClear) { Text("Clear") } + Spacer(Modifier.width(8.dp)) + Button(onClick = onSave, modifier = Modifier.heightIn(min = 48.dp)) { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (count == 1) "Save" else "Save $count") + } + } + } +} + +@Composable +private fun LanguagePickerDialog(onPick: (String) -> Unit, onDismiss: () -> Unit) { + val languages = remember { + SavedSubtitleRepository.COMMON_LANGUAGES.map { it to SavedSubtitleRepository.languageName(it) }.sortedBy { it.second } + } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Search a language") }, + text = { + LazyColumn(Modifier.heightIn(max = 420.dp)) { + items(languages, key = { it.first }) { (code, name) -> + ListItem( + headlineContent = { Text(name) }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable(role = Role.Button) { onPick(code) } + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } + ) +} + +private fun DownloadEntity.sheetTitle(): String = + if (mediaType == "movie") { + displayTitle + } else { + "$displayTitle · S$season E$episode" + (episodeTitle?.let { " · $it" } ?: "") + } diff --git a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt new file mode 100644 index 0000000..b599369 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt @@ -0,0 +1,283 @@ +package com.ivor.openstream.presentation.downloads + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ivor.openstream.data.local.entity.DownloadEntity +import com.ivor.openstream.data.remote.model.SubtitleDto +import com.ivor.openstream.data.subtitles.SavedSubtitle +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository +import com.ivor.openstream.data.subtitles.StreamSubtitleCandidate +import com.ivor.openstream.domain.model.MediaIdentity +import com.ivor.openstream.domain.repository.StreamingRepository +import com.ivor.openstream.domain.repository.SubtitleRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject + +data class DownloadSubtitlesUiState( + val download: DownloadEntity? = null, + val saved: List = emptyList(), + val online: List = emptyList(), + val isSearching: Boolean = false, + /** Looking the title up in the stream sources for the subtitles the video itself carries. */ + val isCheckingSource: Boolean = false, + /** Languages the user asked for by name, still being searched. */ + val searchingLanguages: Set = emptySet(), + /** Null shows every language. */ + val languageFilter: String? = null, + /** Hides hearing-impaired (SDH) releases from the online list. */ + val hideHearingImpaired: Boolean = false, + /** Ids of online subtitles ticked for saving. */ + val selected: Set = emptySet(), + /** Ids of online subtitles being downloaded right now. */ + val saving: Set = emptySet(), + val isImporting: Boolean = false, + val message: String? = null +) { + val savedOriginIds: Set get() = saved.mapNotNullTo(HashSet()) { it.originId } + + /** Languages in the results, English first, then by name. */ + val languages: List + get() = online.mapNotNull { it.language }.distinct() + .sortedWith(compareBy { it != "en" }.thenBy { SavedSubtitleRepository.languageName(it) }) + + val visibleOnline: List + get() = online + .filter { languageFilter == null || it.language == languageFilter } + .filter { !hideHearingImpaired || !it.isHearingImpaired } + + /** The visible results by language, in [languages] order; unknown languages last. */ + val groupedOnline: List>> + get() { + val byLanguage = visibleOnline.groupBy { it.language } + return (languages + listOf(null)).mapNotNull { language -> + byLanguage[language]?.let { language to it } + } + } +} + +/** + * Saving subtitles for a finished download: the ones the video's own stream carries (what the + * player lists under the source's name), the subtitle sites, or a file on the device. + */ +@HiltViewModel +class DownloadSubtitlesViewModel @Inject constructor( + private val subtitleRepository: SubtitleRepository, + private val savedSubtitles: SavedSubtitleRepository, + private val streamingRepository: StreamingRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(DownloadSubtitlesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var sessionJob: Job? = null + + /** Request headers (Referer...) the stream's hosts want, by subtitle id. */ + private val streamHeaders = ConcurrentHashMap>() + + fun open(download: DownloadEntity) { + if (_uiState.value.download?.downloadId == download.downloadId && sessionJob?.isActive == true) return + sessionJob?.cancel() + streamHeaders.clear() + _uiState.value = DownloadSubtitlesUiState(download = download, isSearching = true) + sessionJob = viewModelScope.launch { + launch { + savedSubtitles.observe(download.mediaType, download.tmdbId, download.season, download.episode) + .collect { saved -> _uiState.update { it.copy(saved = saved) } } + } + launch { loadStreamSubtitles(download, lookUpIfMissing = true) } + launch { search(download) } + } + } + + /** + * Subtitles recorded when the download started. Downloads made before that was recorded (or + * whose source had none then) look the title up in the stream sources again. + */ + private suspend fun loadStreamSubtitles(download: DownloadEntity, lookUpIfMissing: Boolean) { + val recorded = savedSubtitles.streamSubtitles(download.mediaType, download.tmdbId, download.season, download.episode) + addStreamSubtitles(download, recorded) + if (recorded.isNotEmpty() || !lookUpIfMissing) return + + _uiState.update { if (it.download?.downloadId == download.downloadId) it.copy(isCheckingSource = true) else it } + val found = mutableListOf() + try { + withTimeoutOrNull(SOURCE_LOOKUP_TIMEOUT_MS) { + streamingRepository.resolveServers(download.identity()) + .onEach { resolution -> + val candidates = resolution.servers.flatMap { server -> + SavedSubtitleRepository.streamCandidates(server.providerName, server.subtitles, server.headers) + } + found += candidates + addStreamSubtitles(download, candidates) + } + .first { it.isComplete } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + // The sites' results still show; the source lookup is a bonus. + } finally { + _uiState.update { if (it.download?.downloadId == download.downloadId) it.copy(isCheckingSource = false) else it } + } + runCatching { + savedSubtitles.rememberStreamSubtitles( + download.mediaType, download.tmdbId, download.season, download.episode, + found.distinctBy { it.subtitle.url } + ) + } + } + + private fun addStreamSubtitles(download: DownloadEntity, candidates: List) { + if (candidates.isEmpty()) return + candidates.forEach { streamHeaders[it.subtitle.id] = it.headers } + _uiState.update { state -> + if (state.download?.downloadId != download.downloadId) return@update state + // The video's own subtitles go first: they are timed for this exact file. + state.copy(online = (candidates.map { it.subtitle } + state.online).distinctBy { it.url }) + } + } + + fun close() { + sessionJob?.cancel() + sessionJob = null + _uiState.value = DownloadSubtitlesUiState() + } + + fun retrySearch() { + val download = _uiState.value.download ?: return + _uiState.update { it.copy(isSearching = true, message = null) } + viewModelScope.launch { search(download) } + if (!_uiState.value.isCheckingSource && _uiState.value.online.none { it.id.startsWith("stream_") }) { + viewModelScope.launch { loadStreamSubtitles(download, lookUpIfMissing = true) } + } + } + + fun setLanguageFilter(language: String?) = _uiState.update { it.copy(languageFilter = language) } + + fun setHideHearingImpaired(hide: Boolean) = _uiState.update { it.copy(hideHearingImpaired = hide) } + + fun toggle(subtitle: SubtitleDto) = _uiState.update { state -> + if (subtitle.id in state.savedOriginIds || subtitle.id in state.saving) return@update state + state.copy(selected = if (subtitle.id in state.selected) state.selected - subtitle.id else state.selected + subtitle.id) + } + + fun clearSelection() = _uiState.update { it.copy(selected = emptySet()) } + + /** + * Saves every ticked subtitle, two at a time so the subtitle sites are not flooded. Keeps + * going if the sheet is closed; they land in the folder all the same. + */ + fun saveSelected() { + val state = _uiState.value + val download = state.download ?: return + val picked = state.online.filter { it.id in state.selected } + if (picked.isEmpty()) return + _uiState.update { it.copy(selected = emptySet(), saving = it.saving + picked.map { s -> s.id }, message = null) } + viewModelScope.launch { + picked.chunked(2).forEach { batch -> + batch.map { subtitle -> launch { saveNow(download, subtitle) } }.joinAll() + } + } + } + + /** Looks for more releases in [language], which the general search may have left out. */ + fun searchLanguage(language: String) { + val download = _uiState.value.download ?: return + _uiState.update { it.copy(languageFilter = language, searchingLanguages = it.searchingLanguages + language, message = null) } + viewModelScope.launch { + val found = runCatching { subtitleRepository.searchLanguage(download.identity(), language) } + .getOrDefault(emptyList()) + _uiState.update { state -> + if (state.download?.downloadId != download.downloadId) return@update state + state.copy( + online = (state.online + found).distinctBy { it.url }, + searchingLanguages = state.searchingLanguages - language, + message = if (found.isEmpty() && state.online.none { it.language == language }) { + "No ${SavedSubtitleRepository.languageName(language)} subtitles found" + } else { + null + } + ) + } + } + } + + private suspend fun saveNow(download: DownloadEntity, subtitle: SubtitleDto) { + val result = runCatching { + savedSubtitles.save( + download.mediaType, download.tmdbId, download.season, download.episode, subtitle, + headers = streamHeaders[subtitle.id].orEmpty() + ) + } + if (result.exceptionOrNull() is CancellationException) return + _uiState.update { state -> + if (state.download?.downloadId != download.downloadId) return@update state + state.copy( + saving = state.saving - subtitle.id, + message = result.exceptionOrNull()?.let { error -> + "Could not save ${subtitle.display ?: "a subtitle"}: ${error.message ?: "download failed"}" + } ?: state.message + ) + } + } + + fun importFile(uri: Uri) { + val download = _uiState.value.download ?: return + _uiState.update { it.copy(isImporting = true, message = null) } + viewModelScope.launch { + val result = runCatching { + savedSubtitles.import(download.mediaType, download.tmdbId, download.season, download.episode, uri) + } + _uiState.update { + it.copy( + isImporting = false, + message = result.fold( + onSuccess = { saved -> if (saved.language == null) "Saved. Language not recognised from the file name." else null }, + onFailure = { error -> error.message ?: "Could not read that file" } + ) + ) + } + } + } + + fun delete(saved: SavedSubtitle) { + val download = _uiState.value.download ?: return + viewModelScope.launch { + savedSubtitles.delete(download.mediaType, download.tmdbId, download.season, download.episode, saved.id) + } + } + + private suspend fun search(download: DownloadEntity) { + val found = runCatching { subtitleRepository.search(download.identity()) }.getOrDefault(emptyList()) + _uiState.update { state -> + if (state.download?.downloadId != download.downloadId) return@update state + state.copy(online = (state.online + found).distinctBy { it.url }, isSearching = false) + } + } + + private fun DownloadEntity.identity() = MediaIdentity( + tmdbId = tmdbId, + tmdbType = mediaType, + title = displayTitle, + season = season, + episode = episode, + year = year + ) + + private companion object { + const val SOURCE_LOOKUP_TIMEOUT_MS = 30_000L + } +} diff --git a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadViewModel.kt index 7a1893b..291b176 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivor.openstream.data.local.entity.DownloadEntity +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository import com.ivor.openstream.domain.model.DownloadStatus import com.ivor.openstream.domain.repository.DownloadRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -36,19 +38,29 @@ data class DownloadsUiState( val completedCount: Int = 0, val storedBytes: Long = 0L, /** Free space on the volume downloads are written to. */ - val freeBytes: Long = 0L + val freeBytes: Long = 0L, + /** Saved subtitles per movie or episode, by [SavedSubtitleRepository.keyFor]. */ + val subtitleCounts: Map = emptyMap() ) { + fun subtitleCount(download: DownloadEntity): Int = subtitleCounts[ + SavedSubtitleRepository.keyFor(download.mediaType, download.tmdbId, download.season, download.episode) + ] ?: 0 + val isEmpty: Boolean get() = !isLoading && inProgress.isEmpty() && library.isEmpty() } @HiltViewModel class DownloadViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val repository: DownloadRepository + private val repository: DownloadRepository, + savedSubtitles: SavedSubtitleRepository ) : ViewModel() { - val uiState: StateFlow = repository.getAllDownloads() - .map { downloads -> + val uiState: StateFlow = combine( + repository.getAllDownloads(), + savedSubtitles.observeCounts() + ) { downloads, subtitleCounts -> downloads to subtitleCounts } + .map { (downloads, subtitleCounts) -> val completed = downloads.filter { it.status == DownloadStatus.COMPLETED } DownloadsUiState( isLoading = false, @@ -71,7 +83,8 @@ class DownloadViewModel @Inject constructor( .sortedByDescending { group -> group.items.maxOf { it.dateAdded } }, completedCount = completed.size, storedBytes = completed.sumOf { it.sizeBytes }, - freeBytes = (context.getExternalFilesDir(null) ?: context.filesDir).usableSpace + freeBytes = (context.getExternalFilesDir(null) ?: context.filesDir).usableSpace, + subtitleCounts = subtitleCounts ) } .flowOn(Dispatchers.IO) diff --git a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadsScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadsScreen.kt index 0edc288..a3f0ee8 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DownloadForOffline @@ -94,6 +95,17 @@ fun DownloadsScreen( ) { val state by viewModel.uiState.collectAsState() var confirmDeleteAll by rememberSaveable { mutableStateOf(false) } + var subtitlesForId by rememberSaveable { mutableStateOf(null) } + val subtitlesViewModel: DownloadSubtitlesViewModel = hiltViewModel() + val subtitlesFor = subtitlesForId?.let { id -> state.library.flatMap { it.items }.firstOrNull { it.downloadId == id } } + + if (subtitlesFor != null) { + DownloadSubtitlesSheet( + download = subtitlesFor, + viewModel = subtitlesViewModel, + onDismiss = { subtitlesForId = null } + ) + } if (confirmDeleteAll) { AlertDialog( @@ -123,7 +135,8 @@ fun DownloadsScreen( viewModel = viewModel, onBackClick = onBackClick, onDownloadClick = onDownloadClick, - onDeleteAll = { confirmDeleteAll = true } + onDeleteAll = { confirmDeleteAll = true }, + onSubtitles = { subtitlesForId = it.downloadId } ) } else { CenteredListBox(Modifier.fillMaxSize(), minGutter = 16.dp) { gutter -> @@ -176,7 +189,9 @@ fun DownloadsScreen( items(state.library, key = { "group:${it.key}" }) { group -> LibraryGroup( group = group, + subtitleCount = state::subtitleCount, onPlay = onDownloadClick, + onSubtitles = { subtitlesForId = it.downloadId }, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier @@ -227,7 +242,8 @@ private fun WideDownloads( viewModel: DownloadViewModel, onBackClick: () -> Unit, onDownloadClick: (DownloadEntity) -> Unit, - onDeleteAll: () -> Unit + onDeleteAll: () -> Unit, + onSubtitles: (DownloadEntity) -> Unit ) { Row(modifier = Modifier.fillMaxSize()) { Column( @@ -281,7 +297,9 @@ private fun WideDownloads( items(state.library, key = { "group:${it.key}" }) { group -> LibraryGroup( group = group, + subtitleCount = state::subtitleCount, onPlay = onDownloadClick, + onSubtitles = onSubtitles, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier.animateItem() @@ -438,7 +456,9 @@ private fun InProgressRow( @Composable private fun LibraryGroup( group: DownloadGroup, + subtitleCount: (DownloadEntity) -> Int, onPlay: (DownloadEntity) -> Unit, + onSubtitles: (DownloadEntity) -> Unit, onDelete: (DownloadEntity) -> Unit, onDeleteAll: () -> Unit, modifier: Modifier = Modifier @@ -484,7 +504,7 @@ private fun LibraryGroup( ) Text( text = if (group.isMovie) { - "Movie · ${formatBytes(group.totalBytes)}" + "Movie · ${formatBytes(group.totalBytes)}" + subtitleSuffix(subtitleCount(group.items.first())) } else { "${pluralize(group.items.size, "episode")} · ${formatBytes(group.totalBytes)}" }, @@ -493,9 +513,13 @@ private fun LibraryGroup( ) } if (group.isMovie) { - FilledTonalIconButton(onClick = { onPlay(group.items.first()) }) { + val movie = group.items.first() + FilledTonalIconButton(onClick = { onPlay(movie) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Play") } + IconButton(onClick = { onSubtitles(movie) }) { + Icon(Icons.Default.ClosedCaption, contentDescription = "Subtitles for ${group.title}") + } IconButton(onClick = onDeleteAll) { Icon(Icons.Default.Delete, contentDescription = "Delete download") } @@ -524,10 +548,15 @@ private fun LibraryGroup( shapes = ListItemDefaults.segmentedShapes(index = index, count = group.items.size), colors = ListItemDefaults.segmentedColors(), leadingContent = { Thumbnail(item.stillPath ?: item.posterPath) }, - supportingContent = { Text(formatBytes(item.sizeBytes)) }, + supportingContent = { Text(formatBytes(item.sizeBytes) + subtitleSuffix(subtitleCount(item))) }, trailingContent = { - IconButton(onClick = { onDelete(item) }) { - Icon(Icons.Default.Delete, contentDescription = "Delete episode") + Row { + IconButton(onClick = { onSubtitles(item) }) { + Icon(Icons.Default.ClosedCaption, contentDescription = "Subtitles for episode ${item.episode}") + } + IconButton(onClick = { onDelete(item) }) { + Icon(Icons.Default.Delete, contentDescription = "Delete episode") + } } } ) { @@ -540,7 +569,7 @@ private fun LibraryGroup( } } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - androidx.compose.material3.TextButton(onClick = onDeleteAll) { + TextButton(onClick = onDeleteAll) { Text("Delete all", color = MaterialTheme.colorScheme.error) } } @@ -619,6 +648,8 @@ private fun statusLine(download: DownloadEntity): String = when (download.status else -> download.episodeTitle.orEmpty() } +private fun subtitleSuffix(count: Int) = if (count > 0) " · ${pluralize(count, "subtitle")}" else "" + private fun pluralize(count: Int, noun: String) = if (count == 1) "1 $noun" else "$count ${noun}s" internal fun formatBytes(bytes: Long): String { diff --git a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceComponents.kt b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceComponents.kt index 03ec627..b50babd 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceComponents.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceComponents.kt @@ -17,6 +17,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material3.IconButton +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Download @@ -121,7 +124,11 @@ fun ExtensionRow( onUninstall: () -> Unit, onUpdate: () -> Unit, onEnabledChange: (Boolean) -> Unit, - rank: Int? = null + rank: Int? = null, + /** Installed tab only: move this source up or down the search order (null at either end). */ + onMoveUp: (() -> Unit)? = null, + onMoveDown: (() -> Unit)? = null, + showReorder: Boolean = false ) { val manifest = extension.manifest Surface( @@ -220,12 +227,22 @@ fun ExtensionRow( }, modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, color = if (extension.isEnabled) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurfaceVariant } ) + if (showReorder) { + IconButton(onClick = { onMoveUp?.invoke() }, enabled = onMoveUp != null) { + Icon(Icons.Default.KeyboardArrowUp, contentDescription = "Move ${manifest.name} up") + } + IconButton(onClick = { onMoveDown?.invoke() }, enabled = onMoveDown != null) { + Icon(Icons.Default.KeyboardArrowDown, contentDescription = "Move ${manifest.name} down") + } + } Switch( checked = extension.isEnabled, onCheckedChange = onEnabledChange, diff --git a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceScreen.kt index 24c5216..beabf57 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceScreen.kt @@ -59,6 +59,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import com.ivor.openstream.presentation.components.ConnectedChoiceGroup +import com.ivor.openstream.data.settings.SourceSearchMode +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.heading import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -327,6 +331,7 @@ private fun InstalledTab( onOpenDetails: (MarketplaceExtension) -> Unit, onBrowse: () -> Unit ) { + val searchMode by viewModel.searchMode.collectAsState() CenteredListBox(Modifier.fillMaxSize(), minGutter = 16.dp) { gutter -> LazyColumn( modifier = Modifier.fillMaxSize(), @@ -340,6 +345,12 @@ private fun InstalledTab( ) } + if (state.installed.size > 1) { + item(key = "search-order") { + SearchOrderCard(mode = searchMode, onModeChange = viewModel::setSearchMode) + } + } + if (state.updatable.isNotEmpty()) { item { Surface( @@ -390,14 +401,18 @@ private fun InstalledTab( } } } else { - items(state.installed, key = { it.key }) { extension -> + itemsIndexed(state.installed, key = { _, extension -> extension.key }) { index, extension -> ExtensionRow( extension = extension, onClick = { onOpenDetails(extension) }, onInstall = { viewModel.install(extension) }, onUninstall = { viewModel.uninstall(extension) }, onUpdate = { viewModel.update(extension) }, - onEnabledChange = { viewModel.setEnabled(extension, it) } + onEnabledChange = { viewModel.setEnabled(extension, it) }, + rank = index + 1, + onMoveUp = { viewModel.moveSource(extension, -1) }.takeIf { index > 0 }, + onMoveDown = { viewModel.moveSource(extension, 1) }.takeIf { index < state.installed.lastIndex }, + showReorder = state.installed.size > 1 ) } } @@ -405,6 +420,51 @@ private fun InstalledTab( } } +/** How the player searches: everything at once, or one source at a time down the list below. */ +@Composable +private fun SearchOrderCard(mode: SourceSearchMode, onModeChange: (SourceSearchMode) -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = ExpressiveShapes.large, + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(18.dp)) { + Text( + text = "Search order", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.semantics { heading() } + ) + Spacer(Modifier.height(10.dp)) + ConnectedChoiceGroup( + options = SourceSearchMode.entries, + selected = mode, + label = { it.label }, + onSelect = onModeChange + ) + Spacer(Modifier.height(10.dp)) + Text( + text = when (mode) { + SourceSearchMode.ALL_AT_ONCE -> + "Every source is searched together, fastest overall. Streams are sorted by quality; " + + "the order below breaks ties." + SourceSearchMode.IN_ORDER -> + "Sources are searched one at a time from the top, and the first one that finds " + + "something plays. The rest are searched only if its streams fail or you tap Find more." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "Use the arrows on each source to change the order.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } +} + @Composable private fun RepositoriesTab( state: MarketplaceUiState, @@ -426,13 +486,15 @@ private fun RepositoriesTab( ) { Column(modifier = Modifier.padding(18.dp)) { Text( - text = "Add a repository", + text = "Add a repository or Stremio add-on", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Spacer(Modifier.height(6.dp)) Text( - text = "Paste a link to an index.json. GitHub page links are converted automatically.", + text = "Paste a link to an index.json, or a Stremio add-on's manifest.json (stremio:// links " + + "and configured add-on links work too). Add-ons that stream direct links are installed " + + "right away; torrent-only and debrid add-ons can't play here.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -441,7 +503,7 @@ private fun RepositoriesTab( value = repoUrl, onValueChange = { repoUrl = it }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("https://…/index.json") }, + placeholder = { Text("https://…/index.json or …/manifest.json") }, leadingIcon = { Icon(Icons.Default.Public, contentDescription = null) }, singleLine = true, shape = ExpressiveShapes.medium, diff --git a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceViewModel.kt index 3ba29bd..07e92e6 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceViewModel.kt @@ -3,6 +3,8 @@ package com.ivor.openstream.presentation.marketplace import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivor.openstream.data.extensions.MarketplaceRanker +import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.data.settings.SourceSearchMode import com.ivor.openstream.domain.model.ExtensionCatalog import com.ivor.openstream.domain.model.MarketplaceExtension import com.ivor.openstream.domain.model.MarketplaceSort @@ -12,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -33,9 +36,27 @@ data class MarketplaceUiState( @HiltViewModel class MarketplaceViewModel @Inject constructor( - private val repository: ExtensionRepository + private val repository: ExtensionRepository, + private val appSettings: AppSettingsStore ) : ViewModel() { + /** How the player searches the installed sources; set on the Installed tab. */ + val searchMode: StateFlow = appSettings.settings + .map { it.sourceSearchMode } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), appSettings.current.sourceSearchMode) + + fun setSearchMode(mode: SourceSearchMode) = appSettings.update { it.copy(sourceSearchMode = mode) } + + /** Moves an installed source [offset] places up (negative) or down in the search order. */ + fun moveSource(extension: MarketplaceExtension, offset: Int) { + val keys = state.value.installed.map { it.key }.toMutableList() + val from = keys.indexOf(extension.key) + val to = (from + offset).coerceIn(0, keys.lastIndex) + if (from < 0 || from == to) return + keys.add(to, keys.removeAt(from)) + repository.setSourceOrder(keys) + } + private val query = MutableStateFlow("") private val sort = MutableStateFlow(MarketplaceSort.POPULAR) private val tag = MutableStateFlow(null) diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt index 7ba0a76..41902f5 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt @@ -50,6 +50,7 @@ import com.ivor.openstream.data.settings.AppSettings import com.ivor.openstream.domain.model.SkipSegment import com.ivor.openstream.data.repository.SkipTimesRepository import com.ivor.openstream.data.settings.AppSettingsStore +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository import com.ivor.openstream.data.subtitles.SubtitleFetcher import com.ivor.openstream.presentation.player.session.SleepTimer import com.ivor.openstream.presentation.player.session.CastError @@ -110,7 +111,8 @@ class PlayerViewModel @Inject constructor( private val playbackSession: PlaybackSession, appSettingsStore: AppSettingsStore, private val skipTimesRepository: SkipTimesRepository, - private val subtitleFetcher: SubtitleFetcher + private val subtitleFetcher: SubtitleFetcher, + private val savedSubtitleRepository: SavedSubtitleRepository ) : ViewModel() { /** A sideloaded subtitle's text, downloaded and unwrapped by the data layer. */ @@ -280,8 +282,15 @@ class PlayerViewModel @Inject constructor( private val _isLoadingEpisodes = MutableStateFlow(false) val isLoadingEpisodes = _isLoadingEpisodes.asStateFlow() - private val _remoteSubtitles = MutableStateFlow>(emptyList()) - val remoteSubtitles = _remoteSubtitles.asStateFlow() + private val _onlineSubtitles = MutableStateFlow>(emptyList()) + + /** Saved on the device for this episode, and the ids of the online entries they were saved from. */ + private val _savedSubtitles = MutableStateFlow, Set>>(emptyList() to emptySet()) + + /** Saved subtitles first (they work offline), then what the subtitle sites have. */ + val remoteSubtitles: StateFlow> = combine(_savedSubtitles, _onlineSubtitles) { (saved, savedFrom), online -> + saved + online.filterNot { it.id in savedFrom } + }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) private val _mediaDetails = MutableStateFlow(null) val mediaDetails = _mediaDetails.asStateFlow() @@ -435,7 +444,8 @@ class PlayerViewModel @Inject constructor( _nextEpisode.value = null _mediaDetails.value = null _currentEpisode.value = null - _remoteSubtitles.value = emptyList() + _onlineSubtitles.value = emptyList() + _savedSubtitles.value = emptyList() to emptySet() _skipSegments.value = emptyList() _nextEpisodes.value = emptyList() _seasonEpisodes.value = emptyList() @@ -510,7 +520,15 @@ class PlayerViewModel @Inject constructor( } launch { - _remoteSubtitles.value = subtitleRepository.search( + savedSubtitleRepository.observe(mediaType, tmdbId, seasonNumber, currentEpisodeNumber).collect { saved -> + _savedSubtitles.value = saved.map { + savedSubtitleRepository.toSubtitleDto(mediaType, tmdbId, seasonNumber, currentEpisodeNumber, it) + } to saved.mapNotNullTo(HashSet()) { it.originId } + } + } + + launch { + _onlineSubtitles.value = subtitleRepository.search( MediaIdentity( tmdbId = tmdbId, tmdbType = mediaType, @@ -605,13 +623,13 @@ class PlayerViewModel @Inject constructor( automaticFailovers++ _activeServer.value = next setActiveId(next.id) - _playerEvents.tryEmit("${failed.name} stopped responding. Switched to ${next.name}.") + _playerEvents.tryEmit("${failed.name} didn't play. Trying ${next.name}.") } else if (!backupSourcesSearched && currentIdentity != null) { // Every direct link failed to play: widen the search to the backup sources once. backupSourcesSearched = true automaticFailovers = 0 _activeServer.value = null - _playerEvents.tryEmit("${failed.name} stopped responding. Searching backup sources…") + _playerEvents.tryEmit("${failed.name} didn't play. Searching backup sources…") startResolution(currentIdentity!!, includeFallbacks = true) } else { _activeServer.value = null diff --git a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt index 366a0a9..98ab346 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/player/components/ExoPlayerView.kt @@ -114,6 +114,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.runtime.mutableIntStateOf import com.ivor.openstream.data.streaming.BROWSER_USER_AGENT import com.ivor.openstream.domain.model.forDuration +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository import com.ivor.openstream.data.subtitles.SubtitleCue import com.ivor.openstream.data.repository.SubSourceRepository import com.ivor.openstream.data.subtitles.isSubtitleAd @@ -1599,6 +1600,7 @@ private fun displayLanguageOrNull(code: String): String? { /** Files the player can't read itself (compressed, or behind a download token); loaded on demand. */ private fun SubtitleDto.isSideloadOnly(): Boolean = source == OpenSubtitlesRepository.SOURCE_NAME || source == SubSourceRepository.SOURCE_NAME || + source == SavedSubtitleRepository.SOURCE_NAME || url.substringBefore('?').substringAfterLast('.').lowercase() in setOf("gz", "zip") /** Caption size in picture-in-picture relative to the user's chosen size. */ diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt index 65588c4..e1cce86 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.DeleteSweep import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.ClosedCaption import androidx.compose.material.icons.filled.HighQuality import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.People @@ -107,6 +108,17 @@ fun SettingsScreen( val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val snackbarHostState = remember { SnackbarHostState() } var confirmClearHistory by rememberSaveable { mutableStateOf(false) } + var pickingSubtitleLanguages by rememberSaveable { mutableStateOf(false) } + + if (pickingSubtitleLanguages) { + SubtitleLanguagesSheet( + selected = appSettings.subtitleDownloadLanguages, + fromSites = appSettings.subtitleDownloadFromSites, + onLanguagesChange = viewModel::setSubtitleDownloadLanguages, + onFromSitesChange = viewModel::setSubtitleDownloadFromSites, + onDismiss = { pickingSubtitleLanguages = false } + ) + } val exportBackup = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(LibraryBackup.MIME_TYPE)) { uri -> uri?.let(viewModel::exportBackup) @@ -292,7 +304,7 @@ fun SettingsScreen( item(key = "downloads") { SettingsGroup(title = "Downloads") { ChoiceRow( - index = 0, count = 2, + index = 0, count = 3, icon = Icons.Default.HighQuality, title = "Quality", supporting = "Highest quality a download picks", @@ -301,8 +313,16 @@ fun SettingsScreen( label = { "${it}p" }, onSelect = viewModel::setDownloadMaxHeight ) + NavigationRow( + index = 1, count = 3, + icon = Icons.Default.ClosedCaption, + title = "Subtitles", + supporting = subtitleLanguagesSummary(appSettings.subtitleDownloadLanguages) + + " · saved with every download", + onClick = { pickingSubtitleLanguages = true } + ) SwitchRow( - index = 1, count = 2, + index = 2, count = 3, icon = Icons.Default.Wifi, title = "Wi-Fi only", supporting = "Wait for an unmetered network", diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt index 58bb9d5..8a31c86 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SettingsViewModel.kt @@ -170,6 +170,12 @@ class SettingsViewModel @Inject constructor( fun setPipRightAction(action: PipAction) = appSettingsStore.update { it.copy(pipRightAction = action) } + fun setSubtitleDownloadLanguages(languages: List) = + appSettingsStore.update { it.copy(subtitleDownloadLanguages = languages.distinct()) } + + fun setSubtitleDownloadFromSites(enabled: Boolean) = + appSettingsStore.update { it.copy(subtitleDownloadFromSites = enabled) } + fun setWifiOnlyDownloads(wifiOnly: Boolean) { appSettingsStore.update { it.copy(wifiOnlyDownloads = wifiOnly) } downloadManager.requirements = downloadRequirements(wifiOnly) diff --git a/app/src/main/java/com/ivor/openstream/presentation/settings/SubtitleLanguagesSheet.kt b/app/src/main/java/com/ivor/openstream/presentation/settings/SubtitleLanguagesSheet.kt new file mode 100644 index 0000000..78e94e2 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/settings/SubtitleLanguagesSheet.kt @@ -0,0 +1,139 @@ +package com.ivor.openstream.presentation.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Public +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ivor.openstream.data.subtitles.SavedSubtitleRepository + +/** "English, Spanish", or "Off" when no language is picked. */ +internal fun subtitleLanguagesSummary(languages: List): String = + if (languages.isEmpty()) "Off" else languages.joinToString(", ") { SavedSubtitleRepository.languageName(it) } + +/** + * Which subtitle languages every new download saves for offline use. Changes apply as they are + * made; downloads already finished keep what they have (the Downloads sheet adds more). + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun SubtitleLanguagesSheet( + selected: List, + fromSites: Boolean, + onLanguagesChange: (List) -> Unit, + onFromSitesChange: (Boolean) -> Unit, + onDismiss: () -> Unit +) { + // Picked languages first, in the order they were when the sheet opened, so rows don't jump. + val ordered = remember { + val all = SavedSubtitleRepository.COMMON_LANGUAGES + selected.filter { it !in SavedSubtitleRepository.COMMON_LANGUAGES } + all.distinct().sortedWith( + compareBy { it !in selected }.thenBy { SavedSubtitleRepository.languageName(it) } + ) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + LazyColumn( + modifier = Modifier.navigationBarsPadding(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap) + ) { + item(key = "header") { + Column(Modifier.padding(start = 8.dp, end = 8.dp, bottom = 12.dp)) { + Text( + text = "Subtitles with downloads", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Black, + modifier = Modifier.semantics { heading() } + ) + Text( + text = if (selected.isEmpty()) { + "No language picked: downloads won't save subtitles. You can still add them from Downloads." + } else { + "Every new download also saves all subtitles in these languages, so they work offline." + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + + item(key = "sites") { + SegmentedListItem( + checked = fromSites, + onCheckedChange = onFromSitesChange, + shapes = ListItemDefaults.segmentedShapes(index = 0, count = 1), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { Icon(Icons.Default.Public, contentDescription = null) }, + supportingContent = { + Text( + if (fromSites) { + "The video's own subtitles plus OpenSubtitles and SubSource" + } else { + "Only the subtitles the video's source provides" + } + ) + }, + trailingContent = { Switch(checked = fromSites, onCheckedChange = null) } + ) { + Text("Also from subtitle sites", fontWeight = FontWeight.SemiBold) + } + } + + item(key = "languages-title") { + Text( + text = "Languages", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .padding(start = 8.dp, top = 16.dp, bottom = 8.dp) + .semantics { heading() } + ) + } + itemsIndexed(ordered, key = { _, code -> code }) { index, code -> + val checked = code in selected + SegmentedListItem( + checked = checked, + onCheckedChange = { on -> + onLanguagesChange(if (on) selected + code else selected - code) + }, + shapes = ListItemDefaults.segmentedShapes(index = index, count = ordered.size), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { Checkbox(checked = checked, onCheckedChange = null) } + ) { + Text( + SavedSubtitleRepository.languageName(code), + fontWeight = if (checked) FontWeight.Bold else FontWeight.Normal + ) + } + } + } + } +} diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 45e1069..7d782a0 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -100,6 +100,7 @@ falls back to `main/extensions/index.json`. | `anikoto` | `AnikotoProvider` (site search, MAL-id check, megaplay embeds via `MegaplayExtractor`) | `endpoint`: the site origin (`https://…`) | | `reanime` | `ReAnimeProvider` (megaplay by AniList id; the site is the Referer) | `endpoint`: the site origin | | `animepahe` | `AnimePaheProvider` (Cloudflare cleared in a hidden WebView by `CloudflareClearance`, kwik packed-script unpacking) | `endpoint`: the site origin | +| `stremio` | `StremioAddonProvider` (the Stremio add-on protocol over HTTPS) | `endpoint`: the add-on's base URL, i.e. its manifest URL without `/manifest.json` (a configured add-on's URL includes its settings) | The three anime engines resolve only titles that `AnimeEpisodeMapper` can map to an AniList episode (ani.zip by TMDB id, then AniList sequels, matched by TVDB season/episode or air date); @@ -127,8 +128,45 @@ catalog stays forward-compatible: publishing an entry for a future engine does n Trending additionally weights `installsLast7Days` and how recently the entry was updated. +### Stremio add-ons + +The `stremio` engine speaks the public +[Stremio add-on protocol](https://github.com/Stremio/stremio-addon-sdk/blob/master/docs/protocol.md): +it reads `{base}/manifest.json` once for the content types and id prefixes, then asks +`{base}/stream/movie/{imdbId}.json` or `{base}/stream/series/{imdbId}:{season}:{episode}.json` +(or `tmdb:` ids when the add-on lists that prefix). Only streams with a direct `url` are used, with +the add-on's `behaviorHints.proxyHeaders.request` headers and any `subtitles`. Torrent (`infoHash`), +YouTube and external-link streams are skipped: there is no torrent engine, and debrid services need +user API keys, which the app doesn't ask for. + +Add-ons often list one release several times, once per file host, and some of those links are dead +when they are handed out (expired signed URLs, hosts asking for a captcha, redirects to a 404). The +engine groups mirrors by release name and size, then keeps the first mirror per release that answers +a 16-byte range request with video bytes (or `#EXTM3U` for HLS), checking releases in parallel within +8 s. Without this the player fails over through dead links one by one. + +Add-ons name streams however they like (host tags, emoji, scene file names), so +`StremioStreamLabels` rebuilds each name from what it says, in a fixed order: +`1080p · BluRay · HEVC 10-bit · Hindi + English · 1.98 GB`. Streams that say too little get a +cleaned-up remainder of their own text first (`kisskh`, `Source 2 · Dubbed`). + +A Stremio `manifest.json` link (or a `stremio://` link, or an add-on's `/configure` page link) pasted +into **Add repository** becomes a one-entry repository: `ExtensionIndexParser` maps the manifest to +an entry, installed right away when it serves streams and doesn't require configuring first. Its +`versionCode` comes from the add-on's semver, so a new add-on version shows as an update. + +Add-ons listed in the official catalog were checked to return direct streams without an account +or key for a movie and an episode when they were added (status `3`, Beta). Public add-on hosts move +often; when one stops answering, mark it `0` (Down) or retire the entry. + ## Runtime +Installed sources are ranked by the user (Marketplace > Installed, arrows on each source), falling +back to `engine.priority` for anything not ranked yet; a provider's priority at resolve time is its +position in that order. In the default "All at once" search mode every source runs together and +streams are sorted by quality with the order breaking ties; in "In my order" sources run one at a +time from the top and the search stops at the first that finds streams. + `ExtensionProviderRegistry` turns installed + enabled manifests into `StreamProvider`s at resolve time, so adding a source is a data change in an index — no Dagger module edit, no app release. Provider ids are unchanged (`vidking-`), so saved server preferences and existing diff --git a/extensions/README.md b/extensions/README.md index 21fd2d6..d5db24c 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -31,6 +31,7 @@ as "Needs a newer app version". | `web-embed` | Any web player page the app can load in a hidden browser and record the video requests of | `movieUrl` and/or `tvUrl` | | `vidking-webview` | The built-in Vidking page fallback; there is already one, don't add another | none | | `anikoto` / `reanime` / `animepahe` | Anime sites with a built-in scraper; point `endpoint` at the site's current domain when it moves | `endpoint` (e.g. `https://anikototv.to`) | +| `stremio` | A Stremio add-on that returns direct (non-torrent) stream links without an account or debrid key | `endpoint`: the manifest URL without `/manifest.json` | `web-embed` URL templates must be `https://` and can use these placeholders: @@ -77,6 +78,7 @@ Rules the catalog follows: - **`id`** is permanent and unique. Installs, stats and saved preferences hang off it, so never rename it; to replace a source, add a new id and retire the old one. - **`name`**: official routes are named after Valorant agents. Keep to that for official entries. + Stremio add-ons keep the add-on's own name, so people recognise them. - **`description`**: one short sentence on what the source is good for. No hostnames or ads. - **`language`**: `Multi`, or the language of the audio when the route is a dub (`English`, `Hindi`…). The Audio page uses this to offer the route as a language choice. diff --git a/extensions/index.json b/extensions/index.json index 9ed8de0..e80bb18 100644 --- a/extensions/index.json +++ b/extensions/index.json @@ -266,6 +266,111 @@ "priority": 13 } }, + { + "id": "hdhub", + "name": "HdHub", + "description": "Stremio add-on with many direct releases per title, up to 4K, including Hindi and dual-audio files. Large files: best on a fast connection.", + "version": "1.0.7", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Multi", + "iconUrl": "https://hdhub.thevolecitor.qzz.io/logo.png", + "tags": ["movies", "series", "stremio", "4k"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://hdhub.thevolecitor.qzz.io", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://hdhub.thevolecitor.qzz.io/eyJ0b3Jib3giOiJ1bnNldCIsInF1YWxpdGllcyI6IjIxNjBwLDEwODBwLDcyMHAiLCJzb3J0IjoiZGVzYyJ9", + "priority": 14 + } + }, + { + "id": "yastream", + "name": "yastream", + "description": "Stremio add-on for Asian dramas, series and movies from several providers.", + "version": "0.11.1", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Multi", + "iconUrl": "https://yastream.tamthai.de/img/yas.png", + "tags": ["series", "movies", "stremio", "asian-drama"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://yastream.tamthai.de", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://yastream.tamthai.de", + "priority": 15 + } + }, + { + "id": "zeus", + "name": "Zeus", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies, series and anime.", + "version": "0.39.4", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://i.imgur.com/7Jr9ng6.jpg", + "tags": ["movies", "series", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://398fe185fed6-zeus.baby-beamup.club", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://398fe185fed6-zeus.baby-beamup.club/v1-p1kv-q3j", + "priority": 16 + } + }, + { + "id": "froststream", + "name": "FrostStream", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled movies and series.", + "version": "2.2.7", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://i.imgur.com/yuG0ZIU.jpeg", + "tags": ["movies", "series", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://froststream.cloutteam.com", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://froststream.cloutteam.com", + "priority": 17 + } + }, + { + "id": "fenixflix", + "name": "FenixFlix", + "description": "Stremio add-on with Portuguese (Brazil) dubbed and subtitled series, novelas and movies.", + "version": "1.1.9", + "versionCode": 1, + "apiVersion": 1, + "authors": ["Stremio add-on"], + "language": "Portuguese", + "iconUrl": "https://fenixflix.fenixhub.online/logo.png", + "tags": ["series", "movies", "stremio", "portuguese"], + "status": 3, + "updatedAt": "2026-10-02", + "homepage": "https://fenixflix.fenixhub.online", + "installedByDefault": false, + "engine": { + "type": "stremio", + "endpoint": "https://fenixflix.fenixhub.online", + "priority": 18 + } + }, { "id": "jett", "name": "Jett",