From 16ecfb0c1c94d82c8bf07fac7bd71124d9756fa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:05:02 +0000 Subject: [PATCH 01/11] Offline subtitles for downloads, save downloads to the gallery Downloads get a Subtitles sheet: tick any number of subtitles from OpenSubtitles and SubSource (grouped by language, any language searchable, SDH filter) or import an SRT/VTT/ASS file. They are kept per title and episode in filesDir/subtitles and the player lists them first, online or offline. SubtitleFetcher reads file: URLs only from that folder. "Save to gallery" repackages a finished download into an MP4 in Movies/OpenStream with Media3 Transformer, reading the download cache like the offline player, without re-encoding. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 11 +- app/build.gradle.kts | 2 + .../data/gallery/GalleryExporter.kt | 288 +++++++++ .../repository/CombinedSubtitleRepository.kt | 8 + .../data/repository/DownloadRepositoryImpl.kt | 16 +- .../repository/OpenSubtitlesRepository.kt | 53 +- .../data/repository/SubSourceRepository.kt | 28 +- .../data/subtitles/SavedSubtitleRepository.kt | 286 +++++++++ .../data/subtitles/SubtitleFetcher.kt | 27 +- .../domain/repository/SubtitleRepository.kt | 4 + .../downloads/DownloadSubtitlesSheet.kt | 565 ++++++++++++++++++ .../downloads/DownloadSubtitlesViewModel.kt | 208 +++++++ .../downloads/DownloadViewModel.kt | 36 +- .../presentation/downloads/DownloadsScreen.kt | 193 +++++- .../presentation/player/PlayerViewModel.kt | 28 +- .../player/components/ExoPlayerView.kt | 2 + gradle/libs.versions.toml | 1 + 17 files changed, 1712 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt create mode 100644 app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt diff --git a/CLAUDE.md b/CLAUDE.md index 7813bc1..0dc5f6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Versions live only in `gradle/libs.versions.toml`; never hard-code them in Gradl only to pin its version (2.3.21). - compileSdk 37, targetSdk 36, minSdk 26. - Compose BOM 2026.09.00 with **Material 3 `1.5.0-alpha29`** (Expressive). KSP, Hilt, Room 2.8, - kotlinx-serialization, Retrofit/OkHttp, Coil 3, Media3 1.3. + kotlinx-serialization, Retrofit/OkHttp, Coil 3, Media3 1.3 (including Transformer). - Alpha APIs change between releases. Before using a Material 3 API, confirm it exists in alpha29 (for example, `javap` on the cached AAR under `~/.gradle/caches/modules-2`). Known change: `Slider` with custom `thumb`/`track` is now state-based (`rememberSliderState`). @@ -94,6 +94,11 @@ 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). 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. @@ -119,6 +124,10 @@ Rules: - **Downloads.** Everything goes through Media3's `DownloadManager` (`DownloadRepositoryImpl`): queue resolves sources in the app scope, live progress from the manager, pause/resume/retry, one rendition up to 1080p from master playlists. Offline playback reads the same cache. + "Save to gallery" (`GalleryExporter`) repackages a finished download into an MP4 in + `Movies/OpenStream` with Media3 Transformer, reading the cache through + `ImagePrefixStrippingDataSource` like the player; no re-encoding when the codecs fit MP4. The + copy is independent of the download (about twice the size is needed while it is written). ## Known external constraints diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcdcf1e..8f10fd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,8 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.cast) + // Repackages downloads as MP4 for the gallery (no re-encoding). + implementation(libs.androidx.media3.transformer) // Graphics Shapes implementation(libs.androidx.graphics.shapes) diff --git a/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt b/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt new file mode 100644 index 0000000..dbd5132 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt @@ -0,0 +1,288 @@ +package com.ivor.openstream.data.gallery + +import android.Manifest +import android.content.ContentValues +import android.content.Context +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.util.Log +import androidx.annotation.OptIn +import androidx.core.content.ContextCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.util.Clock +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSourceBitmapLoader +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.transformer.Composition +import androidx.media3.transformer.DefaultAssetLoaderFactory +import androidx.media3.transformer.DefaultDecoderFactory +import androidx.media3.transformer.ExportException +import androidx.media3.transformer.ExportResult +import androidx.media3.transformer.ProgressHolder +import androidx.media3.transformer.Transformer +import com.ivor.openstream.data.local.entity.DownloadEntity +import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Where a download stands with the gallery. */ +sealed interface GalleryExport { + data object Queued : GalleryExport + + /** [percent] is null while Media3 cannot tell yet. */ + data class Running(val percent: Int?) : GalleryExport + + data class Done(val uri: String) : GalleryExport + + data class Failed(val message: String) : GalleryExport +} + +/** + * Copies finished downloads into the gallery as MP4 (`Movies/OpenStream`). Downloads are kept as + * Media3 cache pieces (HLS segments), which no other app can read, so each one is repackaged with + * Media3 Transformer reading from the same cache the offline player uses. Nothing is re-encoded + * when the codecs fit MP4 (H.264/H.265 with AAC), so it is fast and keeps the quality. + * + * The download stays in the app; the gallery copy is a separate file that survives deleting it. + * Exports run one at a time while the app process is alive. + */ +@OptIn(UnstableApi::class) +@Singleton +class GalleryExporter @Inject constructor( + @ApplicationContext private val context: Context, + private val media3: DownloadManager, + private val cacheDataSourceFactory: CacheDataSource.Factory, + private val preferences: SharedPreferences +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val queue = Channel(Channel.UNLIMITED) + private val _exports = MutableStateFlow>(emptyMap()) + + /** By download id. */ + val exports: StateFlow> = _exports.asStateFlow() + + /** Android 9 and older need the storage permission to write to the shared Movies folder. */ + val needsStoragePermission: Boolean + get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && + ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != + PackageManager.PERMISSION_GRANTED + + init { + scope.launch { restoreFinished() } + scope.launch { + for (download in queue) runExport(download) + } + } + + fun export(download: DownloadEntity) { + when (_exports.value[download.downloadId]) { + GalleryExport.Queued, is GalleryExport.Running -> return + else -> Unit + } + _exports.update { it + (download.downloadId to GalleryExport.Queued) } + queue.trySend(download) + } + + /** Forgets a failed attempt so the row stops showing the error. */ + fun dismiss(downloadId: String) { + if (_exports.value[downloadId] is GalleryExport.Failed) _exports.update { it - downloadId } + } + + private suspend fun runExport(download: DownloadEntity) { + val id = download.downloadId + val temp = File(context.cacheDir, "gallery-export/$id.mp4") + try { + val mediaItem = withContext(Dispatchers.IO) { media3.downloadIndex.getDownload(id) } + ?.request?.toMediaItem() + ?: throw IOException("Only downloads made by this version of the app can be saved to the gallery") + withContext(Dispatchers.IO) { + temp.parentFile?.mkdirs() + temp.delete() + // The MP4 is written next to the app first, then copied out: about twice its size. + val needed = download.sizeBytes * 2 + SPACE_MARGIN_BYTES + if (download.sizeBytes > 0 && temp.parentFile!!.usableSpace < needed) { + throw IOException("Not enough free space") + } + } + _exports.update { it + (id to GalleryExport.Running(null)) } + repackage(mediaItem, temp, id) + val uri = withContext(Dispatchers.IO) { publish(temp, download) } + preferences.edit().putString(KEY_PREFIX + id, uri).apply() + _exports.update { it + (id to GalleryExport.Done(uri)) } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + Log.w(TAG, "Gallery export of $id failed", error) + _exports.update { it + (id to GalleryExport.Failed(error.userMessage())) } + } finally { + withContext(Dispatchers.IO) { temp.delete() } + } + } + + /** Runs on the main thread: Transformer must be started, polled and cancelled from its looper. */ + private suspend fun repackage(mediaItem: MediaItem, output: File, id: String) = coroutineScope { + val result = CompletableDeferred() + val mediaSourceFactory = DefaultMediaSourceFactory(context) + .setDataSourceFactory(ImagePrefixStrippingDataSource.Factory(cacheDataSourceFactory)) + val transformer = Transformer.Builder(context) + .setAssetLoaderFactory( + DefaultAssetLoaderFactory( + context, + DefaultDecoderFactory(context), + /* forceInterpretHdrVideoAsSdr = */ false, + Clock.DEFAULT, + mediaSourceFactory, + DataSourceBitmapLoader(context) + ) + ) + .addListener(object : Transformer.Listener { + override fun onCompleted(composition: Composition, exportResult: ExportResult) { + result.complete(Unit) + } + + override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) { + result.completeExceptionally(exportException) + } + }) + .build() + transformer.start(mediaItem, output.absolutePath) + val progress = launch { + val holder = ProgressHolder() + while (true) { + if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) { + _exports.update { it + (id to GalleryExport.Running(holder.progress.coerceIn(0, 100))) } + } + delay(PROGRESS_INTERVAL_MS) + } + } + try { + result.await() + } catch (cancelled: CancellationException) { + transformer.cancel() + throw cancelled + } finally { + progress.cancel() + } + } + + /** Copies the MP4 into Movies/OpenStream and returns its content URI (a file URI before Android 10). */ + private fun publish(file: File, download: DownloadEntity): String { + val name = fileName(download) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, "$name.mp4") + put(MediaStore.Video.Media.TITLE, name) + put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") + put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/$FOLDER") + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val uri = resolver.insert(collection, values) ?: throw IOException("The gallery refused the file") + try { + resolver.openOutputStream(uri)?.use { out -> file.inputStream().use { it.copyTo(out) } } + ?: throw IOException("Could not write to the gallery") + resolver.update(uri, ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }, null, null) + } catch (error: Exception) { + resolver.delete(uri, null, null) + throw error + } + return uri.toString() + } + @Suppress("DEPRECATION") + val folder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), FOLDER) + if (!folder.isDirectory && !folder.mkdirs()) throw IOException("Could not create the Movies folder") + var target = File(folder, "$name.mp4") + var copy = 1 + while (target.exists()) target = File(folder, "$name (${copy++}).mp4") + file.copyTo(target) + MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf("video/mp4"), null) + return Uri.fromFile(target).toString() + } + + /** Marks earlier exports that are still in the gallery; the user may have deleted them since. */ + private suspend fun restoreFinished() { + val saved = withContext(Dispatchers.IO) { + preferences.all + .filterKeys { it.startsWith(KEY_PREFIX) } + .mapNotNull { (key, value) -> + val uri = value as? String ?: return@mapNotNull null + val id = key.removePrefix(KEY_PREFIX) + if (stillExists(uri)) { + id to GalleryExport.Done(uri) + } else { + preferences.edit().remove(key).apply() + null + } + } + .toMap() + } + // Anything queued meanwhile wins over the stored state. + _exports.update { saved + it } + } + + private fun stillExists(uri: String): Boolean = runCatching { + val parsed = Uri.parse(uri) + if (parsed.scheme == "file") return@runCatching File(parsed.path.orEmpty()).exists() + context.contentResolver.query(parsed, arrayOf(MediaStore.Video.Media._ID), null, null, null) + ?.use { it.count > 0 } ?: false + }.getOrDefault(false) + + private fun fileName(download: DownloadEntity): String { + val title = if (download.mediaType == "movie") { + download.displayTitle + (download.year?.let { " ($it)" } ?: "") + } else { + String.format(Locale.US, "%s S%02dE%02d", download.displayTitle, download.season, download.episode) + + (download.episodeTitle?.let { " - $it" } ?: "") + } + return title.replace(Regex("[\\\\/:*?\"<>|]"), " ").replace(Regex("\\s+"), " ").trim().take(120) + } + + private fun Exception.userMessage(): String = when (this) { + is ExportException -> when (errorCode) { + ExportException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + ExportException.ERROR_CODE_IO_FILE_NOT_FOUND, + ExportException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "Some downloaded parts are missing" + ExportException.ERROR_CODE_MUXING_FAILED, + ExportException.ERROR_CODE_DECODER_INIT_FAILED, + ExportException.ERROR_CODE_ENCODER_INIT_FAILED, + ExportException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + ExportException.ERROR_CODE_ENCODING_FORMAT_UNSUPPORTED -> "This video's format can't be saved as MP4" + else -> "Could not save to the gallery" + } + else -> message ?: "Could not save to the gallery" + } + + private companion object { + const val TAG = "GalleryExport" + const val FOLDER = "OpenStream" + const val KEY_PREFIX = "gallery_export_" + const val PROGRESS_INTERVAL_MS = 500L + const val SPACE_MARGIN_BYTES = 50L * 1024 * 1024 + } +} 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..f9d8354 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,7 @@ 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.SavedSubtitleRepository import com.ivor.openstream.domain.model.DownloadStatus import com.ivor.openstream.domain.model.DownloadTarget import com.ivor.openstream.domain.model.VideoServer @@ -67,7 +68,8 @@ 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 ) : DownloadRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -137,14 +139,24 @@ 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) + 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) 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/subtitles/SavedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt new file mode 100644 index 0000000..0b75f0a --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt @@ -0,0 +1,286 @@ +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 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() +) + +/** + * 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()) + + 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) + + /** 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("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 writeIndex(dir: File, entries: List) { + if (entries.isEmpty()) { + dir.deleteRecursively() + 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 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..cafefd3 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,21 @@ 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.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 +26,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 +44,25 @@ class SubtitleFetcher @Inject constructor( download(url, mapOf("User-Agent" to OpenSubtitlesRepository.USER_AGENT)) else -> download(url, mapOf("User-Agent" to BROWSER_USER_AGENT) + headers) } + textOf(bytes) + } + + /** 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 + 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)) throw IOException("Not a saved subtitle") + return file.readText() } private fun download(url: String, headers: Map): ByteArray { 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..3abd698 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt @@ -0,0 +1,565 @@ +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 = "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.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)) + } + ) + } + } +} + +@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 { + PICKER_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" } ?: "") + } + +/** ISO 639-1, plus OpenSubtitles' "pb" for Brazilian Portuguese. */ +private val PICKER_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" +) 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..04ef928 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt @@ -0,0 +1,208 @@ +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.domain.model.MediaIdentity +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.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class DownloadSubtitlesUiState( + val download: DownloadEntity? = null, + val saved: List = emptyList(), + val online: List = emptyList(), + val isSearching: 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, from the subtitle sites or from a file on the device. */ +@HiltViewModel +class DownloadSubtitlesViewModel @Inject constructor( + private val subtitleRepository: SubtitleRepository, + private val savedSubtitles: SavedSubtitleRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(DownloadSubtitlesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var sessionJob: Job? = null + + fun open(download: DownloadEntity) { + if (_uiState.value.download?.downloadId == download.downloadId && sessionJob?.isActive == true) return + sessionJob?.cancel() + _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 { search(download) } + } + } + + 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) } + } + + 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) + } + 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 + ) +} 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..66a2cf1 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 @@ -3,7 +3,10 @@ package com.ivor.openstream.presentation.downloads import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.ivor.openstream.data.gallery.GalleryExport +import com.ivor.openstream.data.gallery.GalleryExporter 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 +16,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 +40,33 @@ 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(), + /** Gallery copies by download id. */ + val galleryExports: 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, + private val galleryExporter: GalleryExporter ) : ViewModel() { - val uiState: StateFlow = repository.getAllDownloads() - .map { downloads -> + val uiState: StateFlow = combine( + repository.getAllDownloads(), + savedSubtitles.observeCounts(), + galleryExporter.exports + ) { downloads, subtitleCounts, galleryExports -> Triple(downloads, subtitleCounts, galleryExports) } + .map { (downloads, subtitleCounts, galleryExports) -> val completed = downloads.filter { it.status == DownloadStatus.COMPLETED } DownloadsUiState( isLoading = false, @@ -71,12 +89,20 @@ 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, + galleryExports = galleryExports ) } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) + /** True on Android 9 and older until the storage permission is granted. */ + val needsStoragePermission: Boolean get() = galleryExporter.needsStoragePermission + + /** Copies a finished download into the gallery as an MP4; the download itself stays. */ + fun saveToGallery(download: DownloadEntity) = galleryExporter.export(download) + fun pause(download: DownloadEntity) = repository.pause(download.downloadId) fun resume(download: DownloadEntity) = repository.resume(download.downloadId) 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..53487bf 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,16 @@ 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 android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.runtime.remember +import com.ivor.openstream.data.gallery.GalleryExport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DownloadForOffline @@ -94,6 +104,33 @@ 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 } } + + // Android 9 and older need the storage permission before writing to the shared Movies folder. + var pendingGalleryId by rememberSaveable { mutableStateOf(null) } + val storagePermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val pending = pendingGalleryId?.let { id -> state.library.flatMap { it.items }.firstOrNull { it.downloadId == id } } + if (granted && pending != null) viewModel.saveToGallery(pending) + pendingGalleryId = null + } + val saveToGallery: (DownloadEntity) -> Unit = { download -> + if (viewModel.needsStoragePermission) { + pendingGalleryId = download.downloadId + storagePermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } else { + viewModel.saveToGallery(download) + } + } + + if (subtitlesFor != null) { + DownloadSubtitlesSheet( + download = subtitlesFor, + viewModel = subtitlesViewModel, + onDismiss = { subtitlesForId = null } + ) + } if (confirmDeleteAll) { AlertDialog( @@ -123,7 +160,9 @@ fun DownloadsScreen( viewModel = viewModel, onBackClick = onBackClick, onDownloadClick = onDownloadClick, - onDeleteAll = { confirmDeleteAll = true } + onDeleteAll = { confirmDeleteAll = true }, + onSubtitles = { subtitlesForId = it.downloadId }, + onSaveToGallery = saveToGallery ) } else { CenteredListBox(Modifier.fillMaxSize(), minGutter = 16.dp) { gutter -> @@ -176,7 +215,11 @@ fun DownloadsScreen( items(state.library, key = { "group:${it.key}" }) { group -> LibraryGroup( group = group, + subtitleCount = state::subtitleCount, + galleryExport = { state.galleryExports[it.downloadId] }, onPlay = onDownloadClick, + onSubtitles = { subtitlesForId = it.downloadId }, + onSaveToGallery = saveToGallery, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier @@ -227,7 +270,9 @@ private fun WideDownloads( viewModel: DownloadViewModel, onBackClick: () -> Unit, onDownloadClick: (DownloadEntity) -> Unit, - onDeleteAll: () -> Unit + onDeleteAll: () -> Unit, + onSubtitles: (DownloadEntity) -> Unit, + onSaveToGallery: (DownloadEntity) -> Unit ) { Row(modifier = Modifier.fillMaxSize()) { Column( @@ -281,7 +326,11 @@ private fun WideDownloads( items(state.library, key = { "group:${it.key}" }) { group -> LibraryGroup( group = group, + subtitleCount = state::subtitleCount, + galleryExport = { state.galleryExports[it.downloadId] }, onPlay = onDownloadClick, + onSubtitles = onSubtitles, + onSaveToGallery = onSaveToGallery, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier.animateItem() @@ -438,7 +487,11 @@ private fun InProgressRow( @Composable private fun LibraryGroup( group: DownloadGroup, + subtitleCount: (DownloadEntity) -> Int, + galleryExport: (DownloadEntity) -> GalleryExport?, onPlay: (DownloadEntity) -> Unit, + onSubtitles: (DownloadEntity) -> Unit, + onSaveToGallery: (DownloadEntity) -> Unit, onDelete: (DownloadEntity) -> Unit, onDeleteAll: () -> Unit, modifier: Modifier = Modifier @@ -484,21 +537,28 @@ 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)}" }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) + if (group.isMovie) GalleryStatus(galleryExport(group.items.first())) } 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 = onDeleteAll) { - Icon(Icons.Default.Delete, contentDescription = "Delete download") - } + ItemActions( + itemName = group.title, + galleryExport = galleryExport(movie), + onSubtitles = { onSubtitles(movie) }, + onSaveToGallery = { onSaveToGallery(movie) }, + onDelete = onDeleteAll, + deleteLabel = "Delete download" + ) } else { Icon( Icons.Default.ExpandMore, @@ -524,11 +584,21 @@ 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)) }, - trailingContent = { - IconButton(onClick = { onDelete(item) }) { - Icon(Icons.Default.Delete, contentDescription = "Delete episode") + supportingContent = { + Column { + Text(formatBytes(item.sizeBytes) + subtitleSuffix(subtitleCount(item))) + GalleryStatus(galleryExport(item)) } + }, + trailingContent = { + ItemActions( + itemName = "episode ${item.episode}", + galleryExport = galleryExport(item), + onSubtitles = { onSubtitles(item) }, + onSaveToGallery = { onSaveToGallery(item) }, + onDelete = { onDelete(item) }, + deleteLabel = "Delete episode" + ) } ) { Text( @@ -540,7 +610,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) } } @@ -550,6 +620,103 @@ private fun LibraryGroup( } } +/** Subtitles stay one tap away; gallery and delete sit in the overflow menu. */ +@Composable +private fun ItemActions( + itemName: String, + galleryExport: GalleryExport?, + onSubtitles: () -> Unit, + onSaveToGallery: () -> Unit, + onDelete: () -> Unit, + deleteLabel: String +) { + var menuOpen by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onSubtitles) { + Icon(Icons.Default.ClosedCaption, contentDescription = "Subtitles for $itemName") + } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options for $itemName") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + val busy = galleryExport is GalleryExport.Queued || galleryExport is GalleryExport.Running + DropdownMenuItem( + text = { + Text( + when { + busy -> "Saving to gallery…" + galleryExport is GalleryExport.Done -> "Save to gallery again" + else -> "Save to gallery" + } + ) + }, + leadingIcon = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) }, + enabled = !busy, + onClick = { + menuOpen = false + onSaveToGallery() + } + ) + DropdownMenuItem( + text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onDelete() + } + ) + } + } + } +} + +/** One line about the gallery copy, with progress while it is being written. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun GalleryStatus(export: GalleryExport?) { + if (export == null) return + val (text, isError) = when (export) { + GalleryExport.Queued -> "Waiting to save to gallery" to false + is GalleryExport.Running -> ("Saving to gallery" + (export.percent?.let { " · $it%" } ?: "…")) to false + is GalleryExport.Done -> "In your gallery" to false + is GalleryExport.Failed -> "Gallery: ${export.message}" to true + } + Column(Modifier.padding(top = 2.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (export is GalleryExport.Done) { + Icon( + Icons.Default.PhotoLibrary, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(4.dp)) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = when { + isError -> MaterialTheme.colorScheme.error + export is GalleryExport.Done -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + if (export is GalleryExport.Running) { + val percent = export.percent + if (percent != null) { + val progress by animateFloatAsState(percent / 100f, label = "galleryProgress") + LinearWavyProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } else { + LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) + } + } + } +} + @Composable private fun Thumbnail(path: String?) { AsyncImage( @@ -619,6 +786,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/player/PlayerViewModel.kt b/app/src/main/java/com/ivor/openstream/presentation/player/PlayerViewModel.kt index 7ba0a76..b1b2992 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, 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f41d88..769afed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,7 @@ androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exop androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" } +androidx-media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" } # Graphics Shapes (Expressive Shapes) androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" } From dc4dae34fbbc517639ae79d521b66ac168d9ffe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:06:22 +0000 Subject: [PATCH 02/11] Gallery export: read the download cache through its own read-only reader The export never writes to or evicts the download cache, whatever the shared CacheDataSource factory is set to; a failed or cancelled export only removes its temp MP4 and the unfinished gallery entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- .../data/gallery/GalleryExporter.kt | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt b/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt index dbd5132..54dda09 100644 --- a/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt +++ b/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt @@ -16,7 +16,9 @@ import androidx.core.content.ContextCompat import androidx.media3.common.MediaItem import androidx.media3.common.util.Clock import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSourceBitmapLoader +import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.source.DefaultMediaSourceFactory @@ -68,17 +70,34 @@ sealed interface GalleryExport { * Media3 Transformer reading from the same cache the offline player uses. Nothing is re-encoded * when the codecs fit MP4 (H.264/H.265 with AAC), so it is fast and keeps the quality. * - * The download stays in the app; the gallery copy is a separate file that survives deleting it. - * Exports run one at a time while the app process is alive. + * The download is never touched: the cache is opened read-only (no write sink, no eviction), the + * MP4 is built in a temp file under `cacheDir`, and only that temp file and an unfinished gallery + * entry are deleted when an export fails or is cancelled. The gallery copy is a separate file that + * survives deleting the download. Exports run one at a time while the app process is alive. */ @OptIn(UnstableApi::class) @Singleton class GalleryExporter @Inject constructor( @ApplicationContext private val context: Context, private val media3: DownloadManager, - private val cacheDataSourceFactory: CacheDataSource.Factory, + private val cache: Cache, + /** Network with the download's headers; only used for a piece the cache is missing. */ + private val upstreamFactory: DataSource.Factory, private val preferences: SharedPreferences ) { + /** + * Reads the download cache without ever writing to it. Built here rather than reusing a shared + * factory so a change elsewhere can never make an export modify or evict a download. + */ + private val readOnlyCache: DataSource.Factory by lazy { + ImagePrefixStrippingDataSource.Factory( + CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setCacheWriteDataSinkFactory(null) + ) + } + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val queue = Channel(Channel.UNLIMITED) private val _exports = MutableStateFlow>(emptyMap()) @@ -147,8 +166,7 @@ class GalleryExporter @Inject constructor( /** Runs on the main thread: Transformer must be started, polled and cancelled from its looper. */ private suspend fun repackage(mediaItem: MediaItem, output: File, id: String) = coroutineScope { val result = CompletableDeferred() - val mediaSourceFactory = DefaultMediaSourceFactory(context) - .setDataSourceFactory(ImagePrefixStrippingDataSource.Factory(cacheDataSourceFactory)) + val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(readOnlyCache) val transformer = Transformer.Builder(context) .setAssetLoaderFactory( DefaultAssetLoaderFactory( From c128f8192ee67e48f39deff243c3b9336a234b06 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:08:46 +0000 Subject: [PATCH 03/11] Opt in to the Expressive API for the subtitle language header Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- .../openstream/presentation/downloads/DownloadSubtitlesSheet.kt | 1 + 1 file changed, 1 insertion(+) 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 index 3abd698..e9f319f 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt @@ -393,6 +393,7 @@ private fun FilterRow( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun LanguageHeader(name: String, count: Int, isSearching: Boolean, onSearchMore: (() -> Unit)?) { Row( From 78de4c9e3517112b201b0318198a2403bacfc725 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:29:53 +0000 Subject: [PATCH 04/11] Drop Save to gallery; offer the stream's own subtitles in Downloads Removes GalleryExporter and the media3-transformer dependency. The Downloads subtitle sheet only searched OpenSubtitles and SubSource, so subtitles the player showed under the source's name never appeared there. Downloads now record the stream's subtitles (stream.json next to the saved ones); older downloads look the title up in the stream sources again. They are listed first and saved with the stream's headers. SubtitleFetcher only reads .srt/.vtt/.ass from the saved folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 10 +- app/build.gradle.kts | 2 - .../data/gallery/GalleryExporter.kt | 306 ------------------ .../data/repository/DownloadRepositoryImpl.kt | 7 + .../data/subtitles/SavedSubtitleRepository.kt | 86 ++++- .../data/subtitles/SubtitleFetcher.kt | 4 +- .../downloads/DownloadSubtitlesSheet.kt | 8 +- .../downloads/DownloadSubtitlesViewModel.kt | 81 ++++- .../downloads/DownloadViewModel.kt | 25 +- .../presentation/downloads/DownloadsScreen.kt | 172 +--------- gradle/libs.versions.toml | 1 - 11 files changed, 206 insertions(+), 496 deletions(-) delete mode 100644 app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt diff --git a/CLAUDE.md b/CLAUDE.md index 0dc5f6d..3840145 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ Versions live only in `gradle/libs.versions.toml`; never hard-code them in Gradl only to pin its version (2.3.21). - compileSdk 37, targetSdk 36, minSdk 26. - Compose BOM 2026.09.00 with **Material 3 `1.5.0-alpha29`** (Expressive). KSP, Hilt, Room 2.8, - kotlinx-serialization, Retrofit/OkHttp, Coil 3, Media3 1.3 (including Transformer). + kotlinx-serialization, Retrofit/OkHttp, Coil 3, Media3 1.3. - Alpha APIs change between releases. Before using a Material 3 API, confirm it exists in alpha29 (for example, `javap` on the cached AAR under `~/.gradle/caches/modules-2`). Known change: `Slider` with custom `thumb`/`track` is now state-based (`rememberSliderState`). @@ -95,7 +95,9 @@ Rules: 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). The player lists + 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. 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. @@ -124,10 +126,6 @@ Rules: - **Downloads.** Everything goes through Media3's `DownloadManager` (`DownloadRepositoryImpl`): queue resolves sources in the app scope, live progress from the manager, pause/resume/retry, one rendition up to 1080p from master playlists. Offline playback reads the same cache. - "Save to gallery" (`GalleryExporter`) repackages a finished download into an MP4 in - `Movies/OpenStream` with Media3 Transformer, reading the cache through - `ImagePrefixStrippingDataSource` like the player; no re-encoding when the codecs fit MP4. The - copy is independent of the download (about twice the size is needed while it is written). ## Known external constraints diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f10fd1..bcdcf1e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,8 +153,6 @@ dependencies { implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.cast) - // Repackages downloads as MP4 for the gallery (no re-encoding). - implementation(libs.androidx.media3.transformer) // Graphics Shapes implementation(libs.androidx.graphics.shapes) diff --git a/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt b/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt deleted file mode 100644 index 54dda09..0000000 --- a/app/src/main/java/com/ivor/openstream/data/gallery/GalleryExporter.kt +++ /dev/null @@ -1,306 +0,0 @@ -package com.ivor.openstream.data.gallery - -import android.Manifest -import android.content.ContentValues -import android.content.Context -import android.content.SharedPreferences -import android.content.pm.PackageManager -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.util.Log -import androidx.annotation.OptIn -import androidx.core.content.ContextCompat -import androidx.media3.common.MediaItem -import androidx.media3.common.util.Clock -import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.DataSourceBitmapLoader -import androidx.media3.datasource.cache.Cache -import androidx.media3.datasource.cache.CacheDataSource -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.transformer.Composition -import androidx.media3.transformer.DefaultAssetLoaderFactory -import androidx.media3.transformer.DefaultDecoderFactory -import androidx.media3.transformer.ExportException -import androidx.media3.transformer.ExportResult -import androidx.media3.transformer.ProgressHolder -import androidx.media3.transformer.Transformer -import com.ivor.openstream.data.local.entity.DownloadEntity -import com.ivor.openstream.data.streaming.ImagePrefixStrippingDataSource -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.IOException -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton - -/** Where a download stands with the gallery. */ -sealed interface GalleryExport { - data object Queued : GalleryExport - - /** [percent] is null while Media3 cannot tell yet. */ - data class Running(val percent: Int?) : GalleryExport - - data class Done(val uri: String) : GalleryExport - - data class Failed(val message: String) : GalleryExport -} - -/** - * Copies finished downloads into the gallery as MP4 (`Movies/OpenStream`). Downloads are kept as - * Media3 cache pieces (HLS segments), which no other app can read, so each one is repackaged with - * Media3 Transformer reading from the same cache the offline player uses. Nothing is re-encoded - * when the codecs fit MP4 (H.264/H.265 with AAC), so it is fast and keeps the quality. - * - * The download is never touched: the cache is opened read-only (no write sink, no eviction), the - * MP4 is built in a temp file under `cacheDir`, and only that temp file and an unfinished gallery - * entry are deleted when an export fails or is cancelled. The gallery copy is a separate file that - * survives deleting the download. Exports run one at a time while the app process is alive. - */ -@OptIn(UnstableApi::class) -@Singleton -class GalleryExporter @Inject constructor( - @ApplicationContext private val context: Context, - private val media3: DownloadManager, - private val cache: Cache, - /** Network with the download's headers; only used for a piece the cache is missing. */ - private val upstreamFactory: DataSource.Factory, - private val preferences: SharedPreferences -) { - /** - * Reads the download cache without ever writing to it. Built here rather than reusing a shared - * factory so a change elsewhere can never make an export modify or evict a download. - */ - private val readOnlyCache: DataSource.Factory by lazy { - ImagePrefixStrippingDataSource.Factory( - CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(upstreamFactory) - .setCacheWriteDataSinkFactory(null) - ) - } - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private val queue = Channel(Channel.UNLIMITED) - private val _exports = MutableStateFlow>(emptyMap()) - - /** By download id. */ - val exports: StateFlow> = _exports.asStateFlow() - - /** Android 9 and older need the storage permission to write to the shared Movies folder. */ - val needsStoragePermission: Boolean - get() = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && - ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) != - PackageManager.PERMISSION_GRANTED - - init { - scope.launch { restoreFinished() } - scope.launch { - for (download in queue) runExport(download) - } - } - - fun export(download: DownloadEntity) { - when (_exports.value[download.downloadId]) { - GalleryExport.Queued, is GalleryExport.Running -> return - else -> Unit - } - _exports.update { it + (download.downloadId to GalleryExport.Queued) } - queue.trySend(download) - } - - /** Forgets a failed attempt so the row stops showing the error. */ - fun dismiss(downloadId: String) { - if (_exports.value[downloadId] is GalleryExport.Failed) _exports.update { it - downloadId } - } - - private suspend fun runExport(download: DownloadEntity) { - val id = download.downloadId - val temp = File(context.cacheDir, "gallery-export/$id.mp4") - try { - val mediaItem = withContext(Dispatchers.IO) { media3.downloadIndex.getDownload(id) } - ?.request?.toMediaItem() - ?: throw IOException("Only downloads made by this version of the app can be saved to the gallery") - withContext(Dispatchers.IO) { - temp.parentFile?.mkdirs() - temp.delete() - // The MP4 is written next to the app first, then copied out: about twice its size. - val needed = download.sizeBytes * 2 + SPACE_MARGIN_BYTES - if (download.sizeBytes > 0 && temp.parentFile!!.usableSpace < needed) { - throw IOException("Not enough free space") - } - } - _exports.update { it + (id to GalleryExport.Running(null)) } - repackage(mediaItem, temp, id) - val uri = withContext(Dispatchers.IO) { publish(temp, download) } - preferences.edit().putString(KEY_PREFIX + id, uri).apply() - _exports.update { it + (id to GalleryExport.Done(uri)) } - } catch (cancelled: CancellationException) { - throw cancelled - } catch (error: Exception) { - Log.w(TAG, "Gallery export of $id failed", error) - _exports.update { it + (id to GalleryExport.Failed(error.userMessage())) } - } finally { - withContext(Dispatchers.IO) { temp.delete() } - } - } - - /** Runs on the main thread: Transformer must be started, polled and cancelled from its looper. */ - private suspend fun repackage(mediaItem: MediaItem, output: File, id: String) = coroutineScope { - val result = CompletableDeferred() - val mediaSourceFactory = DefaultMediaSourceFactory(context).setDataSourceFactory(readOnlyCache) - val transformer = Transformer.Builder(context) - .setAssetLoaderFactory( - DefaultAssetLoaderFactory( - context, - DefaultDecoderFactory(context), - /* forceInterpretHdrVideoAsSdr = */ false, - Clock.DEFAULT, - mediaSourceFactory, - DataSourceBitmapLoader(context) - ) - ) - .addListener(object : Transformer.Listener { - override fun onCompleted(composition: Composition, exportResult: ExportResult) { - result.complete(Unit) - } - - override fun onError(composition: Composition, exportResult: ExportResult, exportException: ExportException) { - result.completeExceptionally(exportException) - } - }) - .build() - transformer.start(mediaItem, output.absolutePath) - val progress = launch { - val holder = ProgressHolder() - while (true) { - if (transformer.getProgress(holder) == Transformer.PROGRESS_STATE_AVAILABLE) { - _exports.update { it + (id to GalleryExport.Running(holder.progress.coerceIn(0, 100))) } - } - delay(PROGRESS_INTERVAL_MS) - } - } - try { - result.await() - } catch (cancelled: CancellationException) { - transformer.cancel() - throw cancelled - } finally { - progress.cancel() - } - } - - /** Copies the MP4 into Movies/OpenStream and returns its content URI (a file URI before Android 10). */ - private fun publish(file: File, download: DownloadEntity): String { - val name = fileName(download) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val resolver = context.contentResolver - val values = ContentValues().apply { - put(MediaStore.Video.Media.DISPLAY_NAME, "$name.mp4") - put(MediaStore.Video.Media.TITLE, name) - put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") - put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/$FOLDER") - put(MediaStore.Video.Media.IS_PENDING, 1) - } - val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - val uri = resolver.insert(collection, values) ?: throw IOException("The gallery refused the file") - try { - resolver.openOutputStream(uri)?.use { out -> file.inputStream().use { it.copyTo(out) } } - ?: throw IOException("Could not write to the gallery") - resolver.update(uri, ContentValues().apply { put(MediaStore.Video.Media.IS_PENDING, 0) }, null, null) - } catch (error: Exception) { - resolver.delete(uri, null, null) - throw error - } - return uri.toString() - } - @Suppress("DEPRECATION") - val folder = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES), FOLDER) - if (!folder.isDirectory && !folder.mkdirs()) throw IOException("Could not create the Movies folder") - var target = File(folder, "$name.mp4") - var copy = 1 - while (target.exists()) target = File(folder, "$name (${copy++}).mp4") - file.copyTo(target) - MediaScannerConnection.scanFile(context, arrayOf(target.absolutePath), arrayOf("video/mp4"), null) - return Uri.fromFile(target).toString() - } - - /** Marks earlier exports that are still in the gallery; the user may have deleted them since. */ - private suspend fun restoreFinished() { - val saved = withContext(Dispatchers.IO) { - preferences.all - .filterKeys { it.startsWith(KEY_PREFIX) } - .mapNotNull { (key, value) -> - val uri = value as? String ?: return@mapNotNull null - val id = key.removePrefix(KEY_PREFIX) - if (stillExists(uri)) { - id to GalleryExport.Done(uri) - } else { - preferences.edit().remove(key).apply() - null - } - } - .toMap() - } - // Anything queued meanwhile wins over the stored state. - _exports.update { saved + it } - } - - private fun stillExists(uri: String): Boolean = runCatching { - val parsed = Uri.parse(uri) - if (parsed.scheme == "file") return@runCatching File(parsed.path.orEmpty()).exists() - context.contentResolver.query(parsed, arrayOf(MediaStore.Video.Media._ID), null, null, null) - ?.use { it.count > 0 } ?: false - }.getOrDefault(false) - - private fun fileName(download: DownloadEntity): String { - val title = if (download.mediaType == "movie") { - download.displayTitle + (download.year?.let { " ($it)" } ?: "") - } else { - String.format(Locale.US, "%s S%02dE%02d", download.displayTitle, download.season, download.episode) + - (download.episodeTitle?.let { " - $it" } ?: "") - } - return title.replace(Regex("[\\\\/:*?\"<>|]"), " ").replace(Regex("\\s+"), " ").trim().take(120) - } - - private fun Exception.userMessage(): String = when (this) { - is ExportException -> when (errorCode) { - ExportException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, - ExportException.ERROR_CODE_IO_FILE_NOT_FOUND, - ExportException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "Some downloaded parts are missing" - ExportException.ERROR_CODE_MUXING_FAILED, - ExportException.ERROR_CODE_DECODER_INIT_FAILED, - ExportException.ERROR_CODE_ENCODER_INIT_FAILED, - ExportException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, - ExportException.ERROR_CODE_ENCODING_FORMAT_UNSUPPORTED -> "This video's format can't be saved as MP4" - else -> "Could not save to the gallery" - } - else -> message ?: "Could not save to the gallery" - } - - private companion object { - const val TAG = "GalleryExport" - const val FOLDER = "OpenStream" - const val KEY_PREFIX = "gallery_export_" - const val PROGRESS_INTERVAL_MS = 500L - const val SPACE_MARGIN_BYTES = 50L * 1024 * 1024 - } -} 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 f9d8354..a32b20e 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 @@ -230,6 +230,13 @@ class DownloadRepositoryImpl @Inject constructor( resolvedAt = server.resolvedAt ) ) + // The stream's own subtitles, so Downloads can offer them for saving later. + runCatching { + savedSubtitles.rememberStreamSubtitles( + target.mediaType, target.tmdbId, target.season, target.episode, + SavedSubtitleRepository.streamCandidates(server.providerName, server.subtitles, server.headers) + ) + }.onFailure { Log.w(TAG, "Could not record stream subtitles for ${target.id}", it) } 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/subtitles/SavedSubtitleRepository.kt b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt index 0b75f0a..8e4483d 100644 --- a/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt @@ -5,6 +5,7 @@ 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 @@ -43,6 +44,13 @@ data class SavedSubtitle( 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`. @@ -59,6 +67,7 @@ class SavedSubtitleRepository @Inject constructor( 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) @@ -76,6 +85,31 @@ class SavedSubtitleRepository @Inject constructor( .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}", @@ -185,9 +219,17 @@ class SavedSubtitleRepository @Inject constructor( .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()) { - dir.deleteRecursively() + // 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") @@ -225,6 +267,48 @@ class SavedSubtitleRepository @Inject constructor( const val SOURCE_NAME = "Saved" const val IMPORTED = "Imported" private const val INDEX_FILE = "index.json" + private const val STREAM_FILE = "stream.json" + + /** 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") 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 cafefd3..f6b0772 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 @@ -61,7 +61,9 @@ class SubtitleFetcher @Inject constructor( 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)) throw IOException("Not a saved subtitle") + if (!file.path.startsWith(root) || file.extension.lowercase() !in SavedSubtitleRepository.FILE_EXTENSIONS) { + throw IOException("Not a saved subtitle") + } return file.readText() } 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 index e9f319f..21cb750 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt @@ -164,7 +164,11 @@ fun DownloadSubtitlesSheet( item(key = "online-title") { SectionHeader( title = "Find online", - detail = "OpenSubtitles and SubSource", + 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) ) } @@ -180,7 +184,7 @@ fun DownloadSubtitlesSheet( } val groups = state.groupedOnline - val searching = state.isSearching || + val searching = state.isSearching || state.isCheckingSource || (state.languageFilter != null && state.languageFilter in state.searchingLanguages) when { searching && groups.isEmpty() -> item(key = "searching") { 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 index 04ef928..b599369 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesViewModel.kt @@ -7,7 +7,9 @@ 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 @@ -16,8 +18,12 @@ 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( @@ -25,6 +31,8 @@ data class DownloadSubtitlesUiState( 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. */ @@ -60,11 +68,15 @@ data class DownloadSubtitlesUiState( } } -/** Saving subtitles for a finished download, from the subtitle sites or from a file on the device. */ +/** + * 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 savedSubtitles: SavedSubtitleRepository, + private val streamingRepository: StreamingRepository ) : ViewModel() { private val _uiState = MutableStateFlow(DownloadSubtitlesUiState()) @@ -72,19 +84,72 @@ class DownloadSubtitlesViewModel @Inject constructor( 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 @@ -95,6 +160,9 @@ class DownloadSubtitlesViewModel @Inject constructor( 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) } @@ -149,7 +217,10 @@ class DownloadSubtitlesViewModel @Inject constructor( private suspend fun saveNow(download: DownloadEntity, subtitle: SubtitleDto) { val result = runCatching { - savedSubtitles.save(download.mediaType, download.tmdbId, download.season, download.episode, subtitle) + 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 -> @@ -205,4 +276,8 @@ class DownloadSubtitlesViewModel @Inject constructor( 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 66a2cf1..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 @@ -3,8 +3,6 @@ package com.ivor.openstream.presentation.downloads import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.ivor.openstream.data.gallery.GalleryExport -import com.ivor.openstream.data.gallery.GalleryExporter import com.ivor.openstream.data.local.entity.DownloadEntity import com.ivor.openstream.data.subtitles.SavedSubtitleRepository import com.ivor.openstream.domain.model.DownloadStatus @@ -42,9 +40,7 @@ data class DownloadsUiState( /** Free space on the volume downloads are written to. */ val freeBytes: Long = 0L, /** Saved subtitles per movie or episode, by [SavedSubtitleRepository.keyFor]. */ - val subtitleCounts: Map = emptyMap(), - /** Gallery copies by download id. */ - val galleryExports: Map = emptyMap() + val subtitleCounts: Map = emptyMap() ) { fun subtitleCount(download: DownloadEntity): Int = subtitleCounts[ SavedSubtitleRepository.keyFor(download.mediaType, download.tmdbId, download.season, download.episode) @@ -57,16 +53,14 @@ data class DownloadsUiState( class DownloadViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: DownloadRepository, - savedSubtitles: SavedSubtitleRepository, - private val galleryExporter: GalleryExporter + savedSubtitles: SavedSubtitleRepository ) : ViewModel() { val uiState: StateFlow = combine( repository.getAllDownloads(), - savedSubtitles.observeCounts(), - galleryExporter.exports - ) { downloads, subtitleCounts, galleryExports -> Triple(downloads, subtitleCounts, galleryExports) } - .map { (downloads, subtitleCounts, galleryExports) -> + savedSubtitles.observeCounts() + ) { downloads, subtitleCounts -> downloads to subtitleCounts } + .map { (downloads, subtitleCounts) -> val completed = downloads.filter { it.status == DownloadStatus.COMPLETED } DownloadsUiState( isLoading = false, @@ -90,19 +84,12 @@ class DownloadViewModel @Inject constructor( completedCount = completed.size, storedBytes = completed.sumOf { it.sizeBytes }, freeBytes = (context.getExternalFilesDir(null) ?: context.filesDir).usableSpace, - subtitleCounts = subtitleCounts, - galleryExports = galleryExports + subtitleCounts = subtitleCounts ) } .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) - /** True on Android 9 and older until the storage permission is granted. */ - val needsStoragePermission: Boolean get() = galleryExporter.needsStoragePermission - - /** Copies a finished download into the gallery as an MP4; the download itself stays. */ - fun saveToGallery(download: DownloadEntity) = galleryExporter.export(download) - fun pause(download: DownloadEntity) = repository.pause(download.downloadId) fun resume(download: DownloadEntity) = repository.resume(download.downloadId) 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 53487bf..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,16 +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 android.Manifest -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.filled.ClosedCaption -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.PhotoLibrary -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.runtime.remember -import com.ivor.openstream.data.gallery.GalleryExport import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DownloadForOffline @@ -108,22 +99,6 @@ fun DownloadsScreen( val subtitlesViewModel: DownloadSubtitlesViewModel = hiltViewModel() val subtitlesFor = subtitlesForId?.let { id -> state.library.flatMap { it.items }.firstOrNull { it.downloadId == id } } - // Android 9 and older need the storage permission before writing to the shared Movies folder. - var pendingGalleryId by rememberSaveable { mutableStateOf(null) } - val storagePermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - val pending = pendingGalleryId?.let { id -> state.library.flatMap { it.items }.firstOrNull { it.downloadId == id } } - if (granted && pending != null) viewModel.saveToGallery(pending) - pendingGalleryId = null - } - val saveToGallery: (DownloadEntity) -> Unit = { download -> - if (viewModel.needsStoragePermission) { - pendingGalleryId = download.downloadId - storagePermission.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) - } else { - viewModel.saveToGallery(download) - } - } - if (subtitlesFor != null) { DownloadSubtitlesSheet( download = subtitlesFor, @@ -161,8 +136,7 @@ fun DownloadsScreen( onBackClick = onBackClick, onDownloadClick = onDownloadClick, onDeleteAll = { confirmDeleteAll = true }, - onSubtitles = { subtitlesForId = it.downloadId }, - onSaveToGallery = saveToGallery + onSubtitles = { subtitlesForId = it.downloadId } ) } else { CenteredListBox(Modifier.fillMaxSize(), minGutter = 16.dp) { gutter -> @@ -216,10 +190,8 @@ fun DownloadsScreen( LibraryGroup( group = group, subtitleCount = state::subtitleCount, - galleryExport = { state.galleryExports[it.downloadId] }, onPlay = onDownloadClick, onSubtitles = { subtitlesForId = it.downloadId }, - onSaveToGallery = saveToGallery, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier @@ -271,8 +243,7 @@ private fun WideDownloads( onBackClick: () -> Unit, onDownloadClick: (DownloadEntity) -> Unit, onDeleteAll: () -> Unit, - onSubtitles: (DownloadEntity) -> Unit, - onSaveToGallery: (DownloadEntity) -> Unit + onSubtitles: (DownloadEntity) -> Unit ) { Row(modifier = Modifier.fillMaxSize()) { Column( @@ -327,10 +298,8 @@ private fun WideDownloads( LibraryGroup( group = group, subtitleCount = state::subtitleCount, - galleryExport = { state.galleryExports[it.downloadId] }, onPlay = onDownloadClick, onSubtitles = onSubtitles, - onSaveToGallery = onSaveToGallery, onDelete = viewModel::remove, onDeleteAll = { viewModel.removeGroup(group) }, modifier = Modifier.animateItem() @@ -488,10 +457,8 @@ private fun InProgressRow( private fun LibraryGroup( group: DownloadGroup, subtitleCount: (DownloadEntity) -> Int, - galleryExport: (DownloadEntity) -> GalleryExport?, onPlay: (DownloadEntity) -> Unit, onSubtitles: (DownloadEntity) -> Unit, - onSaveToGallery: (DownloadEntity) -> Unit, onDelete: (DownloadEntity) -> Unit, onDeleteAll: () -> Unit, modifier: Modifier = Modifier @@ -544,21 +511,18 @@ private fun LibraryGroup( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (group.isMovie) GalleryStatus(galleryExport(group.items.first())) } if (group.isMovie) { val movie = group.items.first() FilledTonalIconButton(onClick = { onPlay(movie) }) { Icon(Icons.Default.PlayArrow, contentDescription = "Play") } - ItemActions( - itemName = group.title, - galleryExport = galleryExport(movie), - onSubtitles = { onSubtitles(movie) }, - onSaveToGallery = { onSaveToGallery(movie) }, - onDelete = onDeleteAll, - deleteLabel = "Delete download" - ) + IconButton(onClick = { onSubtitles(movie) }) { + Icon(Icons.Default.ClosedCaption, contentDescription = "Subtitles for ${group.title}") + } + IconButton(onClick = onDeleteAll) { + Icon(Icons.Default.Delete, contentDescription = "Delete download") + } } else { Icon( Icons.Default.ExpandMore, @@ -584,21 +548,16 @@ private fun LibraryGroup( shapes = ListItemDefaults.segmentedShapes(index = index, count = group.items.size), colors = ListItemDefaults.segmentedColors(), leadingContent = { Thumbnail(item.stillPath ?: item.posterPath) }, - supportingContent = { - Column { - Text(formatBytes(item.sizeBytes) + subtitleSuffix(subtitleCount(item))) - GalleryStatus(galleryExport(item)) - } - }, + supportingContent = { Text(formatBytes(item.sizeBytes) + subtitleSuffix(subtitleCount(item))) }, trailingContent = { - ItemActions( - itemName = "episode ${item.episode}", - galleryExport = galleryExport(item), - onSubtitles = { onSubtitles(item) }, - onSaveToGallery = { onSaveToGallery(item) }, - onDelete = { onDelete(item) }, - deleteLabel = "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") + } + } } ) { Text( @@ -620,103 +579,6 @@ private fun LibraryGroup( } } -/** Subtitles stay one tap away; gallery and delete sit in the overflow menu. */ -@Composable -private fun ItemActions( - itemName: String, - galleryExport: GalleryExport?, - onSubtitles: () -> Unit, - onSaveToGallery: () -> Unit, - onDelete: () -> Unit, - deleteLabel: String -) { - var menuOpen by remember { mutableStateOf(false) } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onSubtitles) { - Icon(Icons.Default.ClosedCaption, contentDescription = "Subtitles for $itemName") - } - Box { - IconButton(onClick = { menuOpen = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More options for $itemName") - } - DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { - val busy = galleryExport is GalleryExport.Queued || galleryExport is GalleryExport.Running - DropdownMenuItem( - text = { - Text( - when { - busy -> "Saving to gallery…" - galleryExport is GalleryExport.Done -> "Save to gallery again" - else -> "Save to gallery" - } - ) - }, - leadingIcon = { Icon(Icons.Default.PhotoLibrary, contentDescription = null) }, - enabled = !busy, - onClick = { - menuOpen = false - onSaveToGallery() - } - ) - DropdownMenuItem( - text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) }, - leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null, tint = MaterialTheme.colorScheme.error) }, - onClick = { - menuOpen = false - onDelete() - } - ) - } - } - } -} - -/** One line about the gallery copy, with progress while it is being written. */ -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -private fun GalleryStatus(export: GalleryExport?) { - if (export == null) return - val (text, isError) = when (export) { - GalleryExport.Queued -> "Waiting to save to gallery" to false - is GalleryExport.Running -> ("Saving to gallery" + (export.percent?.let { " · $it%" } ?: "…")) to false - is GalleryExport.Done -> "In your gallery" to false - is GalleryExport.Failed -> "Gallery: ${export.message}" to true - } - Column(Modifier.padding(top = 2.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (export is GalleryExport.Done) { - Icon( - Icons.Default.PhotoLibrary, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(14.dp) - ) - Spacer(Modifier.width(4.dp)) - } - Text( - text = text, - style = MaterialTheme.typography.bodySmall, - color = when { - isError -> MaterialTheme.colorScheme.error - export is GalleryExport.Done -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurfaceVariant - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - } - if (export is GalleryExport.Running) { - val percent = export.percent - if (percent != null) { - val progress by animateFloatAsState(percent / 100f, label = "galleryProgress") - LinearWavyProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } else { - LinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) - } - } - } -} - @Composable private fun Thumbnail(path: String?) { AsyncImage( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 769afed..6f41d88 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,7 +77,6 @@ androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exop androidx-media3-exoplayer-dash = { group = "androidx.media3", name = "media3-exoplayer-dash", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } androidx-media3-cast = { group = "androidx.media3", name = "media3-cast", version.ref = "media3" } -androidx-media3-transformer = { group = "androidx.media3", name = "media3-transformer", version.ref = "media3" } # Graphics Shapes (Expressive Shapes) androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" } From c0e627dfd91a66ef6d290a85a0f5e0ba78734a2c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:39:01 +0000 Subject: [PATCH 05/11] Subtitles: parse WebVTT times without hours, join HLS subtitle playlists Saving a stream's subtitles failed with "no readable subtitles": the parser only accepted hh:mm:ss.mmm, while WebVTT may write mm:ss.mmm. The player never hit this because ExoPlayer parses stream subtitles itself. The same parser feeds sideloaded subtitles in the player and the cast proxy. Subtitle URLs that turn out to be HLS playlists are now fetched segment by segment and joined. A save that gets a web page back says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- .../data/subtitles/SavedSubtitleRepository.kt | 10 +++- .../data/subtitles/SubtitleFetcher.kt | 51 ++++++++++++++++++- .../data/subtitles/SubtitleParser.kt | 18 ++++--- 3 files changed, 68 insertions(+), 11 deletions(-) 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 index 8e4483d..9a89de1 100644 --- a/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt @@ -133,7 +133,15 @@ class SavedSubtitleRepository @Inject constructor( 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("That file has no readable subtitles") + 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, 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 f6b0772..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 @@ -6,6 +6,9 @@ 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 @@ -44,13 +47,54 @@ class SubtitleFetcher @Inject constructor( download(url, mapOf("User-Agent" to OpenSubtitlesRepository.USER_AGENT)) else -> download(url, mapOf("User-Agent" to BROWSER_USER_AGENT) + headers) } - textOf(bytes) + 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") + if (text.isBlank()) throw IOException(EMPTY_FILE) return text } @@ -115,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 From 85aaf443885eec8b3f40022d8ddf32a7b9656232 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 15:54:08 +0000 Subject: [PATCH 06/11] Save subtitles with every download, in languages chosen in Settings Settings > Downloads > Subtitles picks any number of languages (English by default; none turns it off) and whether OpenSubtitles and SubSource count too. When a download starts, DownloadSubtitleSaver saves every subtitle in those languages: the stream's own, then the sites' (languages the general search leaves out are asked for by name). It stops when the download is removed and cleans up if it was removed meanwhile. Both settings are in backups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 9 +- .../openstream/data/backup/LibraryBackup.kt | 12 +- .../data/repository/DownloadRepositoryImpl.kt | 15 +- .../data/settings/AppSettingsStore.kt | 20 ++- .../data/subtitles/DownloadSubtitleSaver.kt | 83 +++++++++++ .../data/subtitles/SavedSubtitleRepository.kt | 8 + .../downloads/DownloadSubtitlesSheet.kt | 10 +- .../presentation/settings/SettingsScreen.kt | 24 ++- .../settings/SettingsViewModel.kt | 6 + .../settings/SubtitleLanguagesSheet.kt | 139 ++++++++++++++++++ 10 files changed, 301 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/subtitles/DownloadSubtitleSaver.kt create mode 100644 app/src/main/java/com/ivor/openstream/presentation/settings/SubtitleLanguagesSheet.kt diff --git a/CLAUDE.md b/CLAUDE.md index 3840145..922b0a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,15 +97,18 @@ Rules: `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. The player lists + 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/java/com/ivor/openstream/data/backup/LibraryBackup.kt b/app/src/main/java/com/ivor/openstream/data/backup/LibraryBackup.kt index f72ae14..e1200a5 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 @@ -266,7 +266,9 @@ 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 ) { fun applyTo(current: AppSettings) = current.copy( themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode, @@ -278,7 +280,9 @@ 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 ) } @@ -292,5 +296,7 @@ private fun AppSettings.toBackup() = BackupSettings( autoPlayNext = autoPlayNext, downloadMaxHeight = downloadMaxHeight, pipLeftAction = pipLeftAction.name, - pipRightAction = pipRightAction.name + pipRightAction = pipRightAction.name, + subtitleDownloadLanguages = subtitleDownloadLanguages, + subtitleDownloadFromSites = subtitleDownloadFromSites ) 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 a32b20e..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,7 @@ 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 @@ -69,7 +70,8 @@ class DownloadRepositoryImpl @Inject constructor( @Named("StreamingClient") private val client: OkHttpClient, private val json: Json, private val appSettings: AppSettingsStore, - private val savedSubtitles: SavedSubtitleRepository + private val savedSubtitles: SavedSubtitleRepository, + private val subtitleSaver: DownloadSubtitleSaver ) : DownloadRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -149,6 +151,7 @@ class DownloadRepositoryImpl @Inject constructor( /** 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) } @@ -230,13 +233,13 @@ class DownloadRepositoryImpl @Inject constructor( resolvedAt = server.resolvedAt ) ) - // The stream's own subtitles, so Downloads can offer them for saving later. + // 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, - SavedSubtitleRepository.streamCandidates(server.providerName, server.subtitles, server.headers) - ) + 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/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt index 78e38dd..b9e1b2d 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 @@ -49,7 +49,11 @@ 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 ) { companion object { val SEEK_STEPS = listOf(5, 10, 15, 30) @@ -95,6 +99,8 @@ 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) .apply() } @@ -113,7 +119,11 @@ 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) ) } @@ -121,6 +131,12 @@ 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_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/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 index 9a89de1..1015c60 100644 --- a/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt +++ b/app/src/main/java/com/ivor/openstream/data/subtitles/SavedSubtitleRepository.kt @@ -277,6 +277,14 @@ class SavedSubtitleRepository @Inject constructor( 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") 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 index 21cb750..27e7462 100644 --- a/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt +++ b/app/src/main/java/com/ivor/openstream/presentation/downloads/DownloadSubtitlesSheet.kt @@ -533,7 +533,7 @@ private fun SelectionBar(count: Int, onClear: () -> Unit, onSave: () -> Unit) { @Composable private fun LanguagePickerDialog(onPick: (String) -> Unit, onDismiss: () -> Unit) { val languages = remember { - PICKER_LANGUAGES.map { it to SavedSubtitleRepository.languageName(it) }.sortedBy { it.second } + SavedSubtitleRepository.COMMON_LANGUAGES.map { it to SavedSubtitleRepository.languageName(it) }.sortedBy { it.second } } AlertDialog( onDismissRequest = onDismiss, @@ -560,11 +560,3 @@ private fun DownloadEntity.sheetTitle(): String = } else { "$displayTitle · S$season E$episode" + (episodeTitle?.let { " · $it" } ?: "") } - -/** ISO 639-1, plus OpenSubtitles' "pb" for Brazilian Portuguese. */ -private val PICKER_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" -) 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 + ) + } + } + } + } +} From f9c8551fae4e5483b3222c3d1080de294fbb6ab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:03:09 +0000 Subject: [PATCH 07/11] Source search: real per-provider deadlines, earlier fallbacks, fairer breaker - Cancelling a resolution (leaving the player, switching episodes) was recorded as a failure of every provider still running; five of those turned a provider off until the app restarted. Cancellation now records nothing, and the breaker reopens after two minutes. If it would leave nothing to try, everything is tried. - The 20 s provider timeout could not interrupt OkHttp's blocking execute(), so one slow provider held up completion and the fallback stage. Providers now run in their own scope and are awaited with a 25 s deadline (enough for the WebView path); every provider yields an outcome, so a resolution can't wait forever. - The IMDb id lookup ran first with no limit on TMDB; it now gets 8 s, and the "searching" state is sent before it. - Fallback providers start after 8 s if direct ones have found nothing yet, instead of waiting for the slowest direct provider. - AnimeEpisodeMapper locks per episode, so a lookup stuck on the previous episode no longer blocks the next one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 5 +- .../data/streaming/StreamingRepositoryImpl.kt | 264 +++++++++++------- .../streaming/anime/AnimeEpisodeMapper.kt | 8 +- 3 files changed, 172 insertions(+), 105 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 922b0a7..f508ba1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,10 @@ 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. 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 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..0116434 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,23 @@ 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 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 @@ -30,16 +39,29 @@ class StreamingRepositoryImpl @Inject constructor( ) : 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) + + /** + * 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. + */ 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 { @@ -52,115 +74,145 @@ class StreamingRepositoryImpl @Inject constructor( } val firstStageProviders = directProviders.ifEmpty { fallbackProviders } val deferredFallbackProviders = fallbackProviders.takeIf { directProviders.isNotEmpty() }.orEmpty() - val preferredServerId = preferences.getString(preferenceKey(identity), null) + // Shown straight away, before the id lookup, so the screen says sources are being searched. send(ServerResolution(totalProviders = 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) + val events = Channel(Channel.UNLIMITED) + var pending = 0 + var total = 0 + var fallbacksStarted = false var servers = emptyList() val failedProviders = mutableListOf() - repeat(firstStageProviders.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 = 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 - ) - ) + + fun start(providers: List) { + pending += providers.size + total += providers.size + providers.forEach { provider -> launch { events.send(runProvider(provider, enrichedIdentity)) } } } - 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)) - } + 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 + } - 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 - ) + 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-- + event.result.fold( + onSuccess = { incoming -> + consecutiveFailures.remove(event.provider.id) + breakerOpenedAt.remove(event.provider.id) + providerRegistry.recordOutcome(event.provider, incoming.isNotEmpty()) + if (incoming.isEmpty()) { + failedProviders += event.provider.displayName + } else { + servers = ServerRanker.mergeAndRank( + existing = servers, + incoming = incoming, + providerPriorities = providerPriorities, + preferredServerId = preferredServerId + ) + } + }, + onFailure = { error -> + Log.w(TAG, "${event.provider.displayName} failed: ${error.message}") + recordFailure(event.provider.id) + providerRegistry.recordOutcome(event.provider, false) + failedProviders += event.provider.displayName } - }, - 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 ) - ) + // 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)) + } } } - outcomes.close() + headStart?.cancel() + events.close() + } + + /** + * 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() + } + } + + 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 +240,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. From 4ea31d02d3f59e4b8e185c2b5fbfaa9686ad887c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:14:26 +0000 Subject: [PATCH 08/11] Stremio add-on support, and five keyless Stremio add-ons in the catalog - New `stremio` engine (StremioAddonProvider): reads the add-on manifest for content types and id prefixes, asks /stream/{movie|series}/{id}.json with IMDb ids (tmdb: ids when the add-on takes them), and plays streams that carry a direct url, with the add-on's request headers and subtitles. Torrent, YouTube and external-link streams are skipped. - A Stremio manifest.json (or stremio:// link, or /configure page link) pasted into Add repository becomes a one-add-on repository and is installed right away when it serves streams without needing setup. - Official catalog: HdHub, yastream, Zeus, FrostStream and FenixFlix, each checked to return direct streams for a movie and an episode with no account or key (status Beta). Bundled copy updated. - Docs: engine tables, how Stremio add-ons map onto extensions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 3 + .../main/assets/extensions/official-repo.json | 105 +++++++++ .../data/extensions/ExtensionIndexParser.kt | 83 ++++++- .../data/extensions/ExtensionRepoClient.kt | 2 +- .../extensions/ExtensionRepositoryImpl.kt | 2 + .../data/extensions/RepoUrlNormalizer.kt | 9 +- .../streaming/ExtensionProviderRegistry.kt | 10 + .../providers/StremioAddonProvider.kt | 214 ++++++++++++++++++ .../domain/model/ExtensionMarketplace.kt | 4 + .../marketplace/MarketplaceScreen.kt | 8 +- docs/EXTENSIONS.md | 21 ++ extensions/README.md | 2 + extensions/index.json | 105 +++++++++ 13 files changed, 560 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioAddonProvider.kt diff --git a/CLAUDE.md b/CLAUDE.md index f508ba1..2ed4f84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,6 +72,9 @@ Rules: 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); 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/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..25b50d4 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 } 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/streaming/ExtensionProviderRegistry.kt b/app/src/main/java/com/ivor/openstream/data/streaming/ExtensionProviderRegistry.kt index 3101d8d..2891ff5 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 @@ -141,6 +142,15 @@ 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 } 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..5b93b76 --- /dev/null +++ b/app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioAddonProvider.kt @@ -0,0 +1,214 @@ +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.withContext +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 + +/** + * 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) + 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 fun fetchStreams(type: String, videoId: 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() + + val seenIds = HashSet() + return streams.mapNotNull { element -> + val stream = element as? JsonObject ?: return@mapNotNull null + toServer(stream)?.let { server -> + // Two streams with the same label still need different ids. + var serverId = server.id + var suffix = 2 + while (!seenIds.add(serverId)) serverId = "${server.id}-${suffix++}" + server.copy(id = serverId) + } + }.take(MAX_STREAMS) + } + + private fun toServer(stream: JsonObject): VideoServer? { + 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 nameLines = stream.text("name").nonBlankLines() + val detailLines = (stream.text("title") ?: stream.text("description")).nonBlankLines() + val label = (nameLines.take(2) + detailLines.take(1)) + .joinToString(" · ") + .ifBlank { displayName } + .take(MAX_LABEL_LENGTH) + + 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(" ") + return VideoServer( + id = "$id-" + Integer.toHexString((label + filename.orEmpty()).hashCode()), + providerId = id, + providerName = displayName, + name = label, + url = url, + quality = qualityOf(searchable), + headers = mapOf("User-Agent" to BROWSER_USER_AGENT) + requestHeaders, + subtitles = subtitles + ) + } + + /** + * 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 = 90 + } +} 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/presentation/marketplace/MarketplaceScreen.kt b/app/src/main/java/com/ivor/openstream/presentation/marketplace/MarketplaceScreen.kt index 24c5216..da00828 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 @@ -426,13 +426,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 +443,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/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 45e1069..7c3b0ae 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,6 +128,26 @@ 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. + +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 `ExtensionProviderRegistry` turns installed + enabled manifests into `StreamProvider`s at resolve 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", From f7715b3b4f58910c7ab1e523fc27bd93adbc3b67 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:27:58 +0000 Subject: [PATCH 09/11] Stremio: group mirrors, drop dead links, clearer labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HdHub listed The Matrix 27 times: about a dozen releases, each on up to four hosts, and 16 of those links could not play (12 hubcloud mirrors redirect to a 404, 3 FSLv2 signed URLs had already expired, 1 PixelDrain file wanted a captcha). The player failed over through them one by one, which looked like it switching between qualities. - Streams are grouped by release name and size; each release keeps the first mirror that answers a 16-byte range request with video bytes (or #EXTM3U for HLS). Releases are checked in parallel within 8 s. - Labels read "add-on name · size · release" instead of the host and size tags, and keep two-line add-on names together. - The player says a source "didn't play" rather than "stopped responding", which also covered links that were dead from the start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- .../providers/StremioAddonProvider.kt | 112 +++++++++++++++--- .../presentation/player/PlayerViewModel.kt | 4 +- docs/EXTENSIONS.md | 6 + 3 files changed, 102 insertions(+), 20 deletions(-) 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 index 5b93b76..57de3c6 100644 --- 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 @@ -10,7 +10,13 @@ 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 @@ -21,6 +27,8 @@ 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 @@ -80,7 +88,7 @@ class StremioAddonProvider( } } - private fun fetchStreams(type: String, videoId: String): List { + private suspend fun fetchStreams(type: String, videoId: String): List { val url = baseUrl.toHttpUrlOrNull()?.newBuilder() ?.addPathSegment("stream") ?.addPathSegment(type) @@ -90,32 +98,92 @@ class StremioAddonProvider( 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) } ?: return@forEach + releases.getOrPut(candidate.releaseKey) { mutableListOf() } += candidate.server + } val seenIds = HashSet() - return streams.mapNotNull { element -> - val stream = element as? JsonObject ?: return@mapNotNull null - toServer(stream)?.let { server -> - // Two streams with the same label still need different ids. - var serverId = server.id - var suffix = 2 - while (!seenIds.add(serverId)) serverId = "${server.id}-${suffix++}" - server.copy(id = serverId) + return firstPlayableMirrors(releases.values.take(MAX_STREAMS)).map { server -> + // Two releases with the same label still need different ids. + var serverId = server.id + var suffix = 2 + while (!seenIds.add(serverId)) serverId = "${server.id}-${suffix++}" + server.copy(id = serverId) + } + } + + /** + * 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 } - }.take(MAX_STREAMS) + } + }.getOrDefault(false) + + private val probeClient: OkHttpClient by lazy { + client.newBuilder() + .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() } - private fun toServer(stream: JsonObject): VideoServer? { + /** 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): 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 nameLines = stream.text("name").nonBlankLines() - val detailLines = (stream.text("title") ?: stream.text("description")).nonBlankLines() - val label = (nameLines.take(2) + detailLines.take(1)) + // Some add-ons split their name over lines ("HdHub" / "VM 1080p"). + val nameLine = stream.text("name").nonBlankLines().take(2).joinToString(" ").ifEmpty { null } + val detail = (stream.text("title") ?: stream.text("description")).nonBlankLines().firstOrNull().orEmpty() + val size = SIZE.find(detail)?.value?.replace(Regex("\\s+"), " ") + // "[PixelDrain] [6.3 GB] The Matrix (1999) 1080p … | host | add-on" -> "The Matrix (1999) 1080p …" + val release = detail.replace(LEADING_TAGS, "").substringBefore(" | ").trim() + val label = listOfNotNull(nameLine, size, release.ifEmpty { null }) .joinToString(" · ") .ifBlank { displayName } .take(MAX_LABEL_LENGTH) - + val releaseKey = (filename ?: release).lowercase().filter { it.isLetterOrDigit() } + .ifEmpty { url } + "|" + size.orEmpty() val requestHeaders = ((hints?.get("proxyHeaders") as? JsonObject)?.get("request") as? JsonObject) ?.mapNotNull { (name, value) -> (value as? JsonPrimitive)?.contentOrNull?.let { name to it } } ?.toMap() @@ -135,8 +203,8 @@ class StremioAddonProvider( val searchable = listOfNotNull(stream.text("name"), stream.text("title"), stream.text("description"), filename) .joinToString(" ") - return VideoServer( - id = "$id-" + Integer.toHexString((label + filename.orEmpty()).hashCode()), + val server = VideoServer( + id = "$id-" + Integer.toHexString(releaseKey.hashCode()), providerId = id, providerName = displayName, name = label, @@ -145,6 +213,7 @@ class StremioAddonProvider( headers = mapOf("User-Agent" to BROWSER_USER_AGENT) + requestHeaders, subtitles = subtitles ) + return Candidate(server, releaseKey) } /** @@ -209,6 +278,13 @@ class StremioAddonProvider( private companion object { const val TAG = "StremioAddon" const val MAX_STREAMS = 30 - const val MAX_LABEL_LENGTH = 90 + 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 + /** "6.3 GB", "764.92 MB"; not "10Gbps". */ + 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*)+") } } 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 b1b2992..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 @@ -623,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/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 7c3b0ae..8429fb0 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -139,6 +139,12 @@ the add-on's `behaviorHints.proxyHeaders.request` headers and any `subtitles`. T 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. + 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 From 5eac16af9512e31bfeed67214b9b731a629b74a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 16:46:01 +0000 Subject: [PATCH 10/11] Let users rank sources and choose how they are searched - Marketplace > Installed shows each source's rank with arrows to move it. The order is stored per device; sources not ranked yet follow the catalog's priority. A provider's priority is now its position in it. - Search order: "All at once" (default, unchanged behaviour: everything in parallel, streams sorted by quality, the order breaking ties) or "In my order": one source at a time from the top, stopping at the first that finds streams, ranked by the order before quality. When those streams all fail, or on Find more, everything else is searched as before. - The mode is in AppSettings and in backups. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- CLAUDE.md | 4 + .../openstream/data/backup/LibraryBackup.kt | 10 ++- .../extensions/ExtensionRepositoryImpl.kt | 22 ++++- .../data/extensions/ExtensionStateStore.kt | 8 ++ .../data/settings/AppSettingsStore.kt | 17 +++- .../streaming/ExtensionProviderRegistry.kt | 28 ++++--- .../openstream/data/streaming/ServerRanker.kt | 16 ++-- .../data/streaming/StreamingRepositoryImpl.kt | 83 ++++++++++++------- .../domain/repository/ExtensionRepository.kt | 5 +- .../marketplace/MarketplaceComponents.kt | 19 ++++- .../marketplace/MarketplaceScreen.kt | 64 +++++++++++++- .../marketplace/MarketplaceViewModel.kt | 23 ++++- docs/EXTENSIONS.md | 6 ++ 13 files changed, 243 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2ed4f84..a7b4c82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ Rules: "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 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 e1200a5..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 @@ -268,7 +269,8 @@ private data class BackupSettings( val pipLeftAction: String? = null, val pipRightAction: String? = null, val subtitleDownloadLanguages: List? = null, - val subtitleDownloadFromSites: Boolean? = null + val subtitleDownloadFromSites: Boolean? = null, + val sourceSearchMode: String? = null ) { fun applyTo(current: AppSettings) = current.copy( themeMode = ThemeMode.entries.firstOrNull { it.name == themeMode } ?: current.themeMode, @@ -282,7 +284,8 @@ private data class BackupSettings( pipLeftAction = PipAction.entries.firstOrNull { it.name == pipLeftAction } ?: current.pipLeftAction, pipRightAction = PipAction.entries.firstOrNull { it.name == pipRightAction } ?: current.pipRightAction, subtitleDownloadLanguages = subtitleDownloadLanguages?.filter { it.isNotBlank() }?.distinct() ?: current.subtitleDownloadLanguages, - subtitleDownloadFromSites = subtitleDownloadFromSites ?: current.subtitleDownloadFromSites + subtitleDownloadFromSites = subtitleDownloadFromSites ?: current.subtitleDownloadFromSites, + sourceSearchMode = SourceSearchMode.entries.firstOrNull { it.name == sourceSearchMode } ?: current.sourceSearchMode ) } @@ -298,5 +301,6 @@ private fun AppSettings.toBackup() = BackupSettings( pipLeftAction = pipLeftAction.name, pipRightAction = pipRightAction.name, subtitleDownloadLanguages = subtitleDownloadLanguages, - subtitleDownloadFromSites = subtitleDownloadFromSites + subtitleDownloadFromSites = subtitleDownloadFromSites, + sourceSearchMode = sourceSearchMode.name ) 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 25b50d4..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 @@ -192,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) { @@ -286,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/settings/AppSettingsStore.kt b/app/src/main/java/com/ivor/openstream/data/settings/AppSettingsStore.kt index b9e1b2d..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, @@ -53,7 +62,8 @@ data class AppSettings( /** 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 subtitleDownloadFromSites: Boolean = true, + val sourceSearchMode: SourceSearchMode = SourceSearchMode.ALL_AT_ONCE ) { companion object { val SEEK_STEPS = listOf(5, 10, 15, 30) @@ -101,6 +111,7 @@ class AppSettingsStore @Inject constructor( .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() } @@ -123,7 +134,8 @@ class AppSettingsStore @Inject constructor( subtitleDownloadLanguages = prefs.getString(KEY_SUBTITLE_LANGUAGES, null) ?.let { parseLanguages(it) } ?: defaults.subtitleDownloadLanguages, - subtitleDownloadFromSites = prefs.getBoolean(KEY_SUBTITLE_FROM_SITES, defaults.subtitleDownloadFromSites) + subtitleDownloadFromSites = prefs.getBoolean(KEY_SUBTITLE_FROM_SITES, defaults.subtitleDownloadFromSites), + sourceSearchMode = enumOrDefault(prefs.getString(KEY_SOURCE_SEARCH_MODE, null), defaults.sourceSearchMode) ) } @@ -137,6 +149,7 @@ class AppSettingsStore @Inject constructor( 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 2891ff5..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 @@ -63,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 @@ -154,13 +163,8 @@ class ExtensionProviderRegistry @Inject constructor( 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 0116434..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 @@ -6,6 +6,8 @@ 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 @@ -35,7 +37,8 @@ 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() @@ -49,9 +52,11 @@ class StreamingRepositoryImpl @Inject constructor( private val providerScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** - * 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. + * 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, @@ -72,10 +77,11 @@ 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() // Shown straight away, before the id lookup, so the screen says sources are being searched. - send(ServerResolution(totalProviders = firstStageProviders.size)) + send(ServerResolution(totalProviders = if (inOrder) enabledProviders.size else firstStageProviders.size)) if (firstStageProviders.isEmpty()) { send(ServerResolution(isComplete = true)) return@channelFlow @@ -85,12 +91,51 @@ class StreamingRepositoryImpl @Inject constructor( val enrichedIdentity = withTimeoutOrNull(ID_LOOKUP_TIMEOUT_MS) { idMappingService.enrich(identity) } ?: identity val preferredServerId = preferences.getString(preferenceKey(identity), null) + var servers = emptyList() + val failedProviders = mutableListOf() + + fun absorb(outcome: ResolutionEvent.Outcome) { + outcome.result.fold( + onSuccess = { incoming -> + consecutiveFailures.remove(outcome.provider.id) + breakerOpenedAt.remove(outcome.provider.id) + 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, + providerFirst = inOrder + ) + } + }, + onFailure = { error -> + Log.w(TAG, "${outcome.provider.displayName} failed: ${error.message}") + recordFailure(outcome.provider.id) + providerRegistry.recordOutcome(outcome.provider, false) + failedProviders += outcome.provider.displayName + } + ) + } + + 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 - var servers = emptyList() - val failedProviders = mutableListOf() fun start(providers: List) { pending += providers.size @@ -122,29 +167,7 @@ class StreamingRepositoryImpl @Inject constructor( } is ResolutionEvent.Outcome -> { pending-- - event.result.fold( - onSuccess = { incoming -> - consecutiveFailures.remove(event.provider.id) - breakerOpenedAt.remove(event.provider.id) - providerRegistry.recordOutcome(event.provider, incoming.isNotEmpty()) - if (incoming.isEmpty()) { - failedProviders += event.provider.displayName - } else { - servers = ServerRanker.mergeAndRank( - existing = servers, - incoming = incoming, - providerPriorities = providerPriorities, - preferredServerId = preferredServerId - ) - } - }, - onFailure = { error -> - Log.w(TAG, "${event.provider.displayName} failed: ${error.message}") - recordFailure(event.provider.id) - providerRegistry.recordOutcome(event.provider, false) - failedProviders += event.provider.displayName - } - ) + 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)) 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/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 da00828..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, 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/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 8429fb0..6eac2fb 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -156,6 +156,12 @@ 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 From 5f8a7b3d8849b9dff63c6998132b86b1786e0e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 17:10:17 +0000 Subject: [PATCH 11/11] Stremio: clean, consistent stream names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add-ons label streams with host tags, emoji and scene file names ("HdHub VM 1080p · 1.98 GB · Matrix.1.The.Matrix.(1999).1080p.Dual.Audio. (Hin.Eng).Vegamovies.is.mkv"). StremioStreamLabels rebuilds each name from what the add-on says, in a fixed order: resolution, HDR, IMAX, source, codec, 60fps, audio languages (or dual/multi audio), dubbed/subtitled, size -- "1080p · BluRay · HEVC 10-bit · Hindi + English · 1.98 GB". Streams that say too little lead with a cleaned remainder of their own text, minus the film's title, years, episode markers and emoji ("kisskh", "Source 2 · Dubbed"). Same names within one add-on get numbered. Also fixes mirror grouping: it keyed on the first title line only, so Zeus's dubbed and subtitled streams (both "Fonte: Filme/Serie 02") were merged. The key now uses every descriptive line except host footers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012Q3Wvy4u4xbBdt49QcBbkR --- .../providers/StremioAddonProvider.kt | 37 ++-- .../providers/StremioStreamLabels.kt | 159 ++++++++++++++++++ docs/EXTENSIONS.md | 5 + 3 files changed, 179 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/com/ivor/openstream/data/streaming/providers/StremioStreamLabels.kt 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 index 57de3c6..8c967e5 100644 --- 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 @@ -64,7 +64,7 @@ class StremioAddonProvider( // 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) + val servers = fetchStreams(type, videoId, identity.title) if (servers.isNotEmpty()) return@withContext Result.success(servers) } Result.success(emptyList()) @@ -88,7 +88,7 @@ class StremioAddonProvider( } } - private suspend fun fetchStreams(type: String, videoId: String): List { + private suspend fun fetchStreams(type: String, videoId: String, title: String): List { val url = baseUrl.toHttpUrlOrNull()?.newBuilder() ?.addPathSegment("stream") ?.addPathSegment(type) @@ -101,16 +101,18 @@ class StremioAddonProvider( // 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) } ?: return@forEach + 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. + // 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++}" - server.copy(id = serverId) + 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) } } @@ -166,24 +168,19 @@ class StremioAddonProvider( /** 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): Candidate? { + 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") - // Some add-ons split their name over lines ("HdHub" / "VM 1080p"). - val nameLine = stream.text("name").nonBlankLines().take(2).joinToString(" ").ifEmpty { null } - val detail = (stream.text("title") ?: stream.text("description")).nonBlankLines().firstOrNull().orEmpty() - val size = SIZE.find(detail)?.value?.replace(Regex("\\s+"), " ") - // "[PixelDrain] [6.3 GB] The Matrix (1999) 1080p … | host | add-on" -> "The Matrix (1999) 1080p …" - val release = detail.replace(LEADING_TAGS, "").substringBefore(" | ").trim() - val label = listOfNotNull(nameLine, size, release.ifEmpty { null }) - .joinToString(" · ") - .ifBlank { displayName } - .take(MAX_LABEL_LENGTH) - val releaseKey = (filename ?: release).lowercase().filter { it.isLetterOrDigit() } - .ifEmpty { url } + "|" + size.orEmpty() + 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() @@ -282,9 +279,5 @@ class StremioAddonProvider( const val VERIFY_PARALLELISM = 8 const val VERIFY_BUDGET_MS = 8_000L const val PROBE_TIMEOUT_MS = 4_000L - /** "6.3 GB", "764.92 MB"; not "10Gbps". */ - 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*)+") } } 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/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 6eac2fb..7d782a0 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -145,6 +145,11 @@ engine groups mirrors by release name and size, then keeps the first mirror per 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