From 9815adc2b63a8051bc5baab497dc3dcb6869809f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 02:30:53 +0530 Subject: [PATCH 1/3] feat: init debrid direct streaming --- composeApp/build.gradle.kts | 13 + .../kotlin/com/nuvio/app/MainActivity.kt | 2 + .../debrid/DebridSettingsStorage.android.kt | 112 +++++ .../composeResources/values/strings.xml | 19 + .../commonMain/kotlin/com/nuvio/app/App.kt | 171 ++++++-- .../app/core/sync/ProfileSettingsSync.kt | 10 + .../app/features/debrid/DebridApiClients.kt | 244 +++++++++++ .../app/features/debrid/DebridApiModels.kt | 94 +++++ .../features/debrid/DebridFileSelectors.kt | 126 ++++++ .../app/features/debrid/DebridProvider.kt | 83 ++++ .../app/features/debrid/DebridSettings.kt | 19 + .../debrid/DebridSettingsRepository.kt | 122 ++++++ .../features/debrid/DebridSettingsStorage.kt | 19 + .../features/debrid/DebridStreamFormatter.kt | 140 +++++++ .../debrid/DebridStreamFormatterDefaults.kt | 8 + .../debrid/DebridStreamTemplateEngine.kt | 390 ++++++++++++++++++ .../app/features/debrid/DebridUrlEncoding.kt | 38 ++ .../debrid/DirectDebridConfigEncoder.kt | 39 ++ .../features/debrid/DirectDebridResolver.kt | 210 ++++++++++ .../debrid/DirectDebridStreamFilter.kt | 41 ++ .../debrid/DirectDebridStreamSource.kt | 97 +++++ .../features/player/PlayerEpisodesPanel.kt | 2 +- .../nuvio/app/features/player/PlayerScreen.kt | 72 ++++ .../app/features/player/PlayerSourcesPanel.kt | 2 +- .../player/PlayerStreamsRepository.kt | 25 +- .../features/settings/DebridSettingsPage.kt | 294 +++++++++++++ .../settings/IntegrationsSettingsPage.kt | 13 + .../app/features/settings/SettingsModels.kt | 6 + .../app/features/settings/SettingsScreen.kt | 20 + .../streams/StreamAutoPlaySelector.kt | 12 +- .../app/features/streams/StreamModels.kt | 78 +++- .../app/features/streams/StreamParser.kt | 81 +++- .../app/features/streams/StreamsRepository.kt | 70 +++- .../app/features/streams/StreamsScreen.kt | 6 +- .../features/debrid/DebridFileSelectorTest.kt | 75 ++++ .../debrid/DebridStreamTemplateEngineTest.kt | 36 ++ .../debrid/DirectDebridConfigEncoderTest.kt | 27 ++ .../debrid/DirectDebridStreamFilterTest.kt | 72 ++++ .../streams/StreamAutoPlaySelectorTest.kt | 33 ++ .../app/features/streams/StreamParserTest.kt | 52 +++ .../debrid/DebridSettingsStorage.ios.kt | 98 +++++ iosApp/Configuration/Version.xcconfig | 2 +- 42 files changed, 3006 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5c5811e4..89ebdf46 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/debrid").apply { + mkdirs() + resolve("DebridConfig.kt").writeText( + """ + |package com.nuvio.app.features.debrid + | + |object DebridConfig { + | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 1b2734cd..e4e5c4d6 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage @@ -73,6 +74,7 @@ class MainActivity : AppCompatActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext) + DebridSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt new file mode 100644 index 00000000..4f20f2d8 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -0,0 +1,112 @@ +package com.nuvio.app.features.debrid + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +actual object DebridSettingsStorage { + private const val preferencesName = "nuvio_debrid_settings" + private const val enabledKey = "debrid_enabled" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val syncKeys = listOf( + enabledKey, + torboxApiKeyKey, + realDebridApiKeyKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getBoolean(scopedKey, false) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(key), enabled) + ?.apply() + } + + private fun loadString(key: String): String? = + preferences?.getString(ProfileScopedKey.of(key), null) + + private fun saveString(key: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(key), value) + ?.apply() + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c32e32d0..f6f0e681 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -18,6 +18,7 @@ Resume Retry Save + Validate Installing Addons Active @@ -365,6 +366,7 @@ Layout Content & Discovery Continue Watching + Debrid Home Layout Integrations Licenses & Attribution @@ -573,6 +575,20 @@ Integrations Metadata enrichment controls External ratings providers + Instant cached Debrid streams + Direct Debrid + Enable Debrid streams + Show instant cached Debrid streams. + Add an API key before enabling Debrid streams. + Account + Use Torbox for instant cached streams. + Stream Formatting + Name template + Controls how Debrid stream names appear in source lists. + Description template + Controls the metadata lines shown under each Debrid stream. + API key validated. + Could not validate this API key. Add your MDBList API key below before turning ratings on. Required to fetch ratings from MDBList API Key @@ -1108,6 +1124,9 @@ Resume from %1$s SIZE %1$s Torrent streams are not supported + Add a Debrid API key in Settings. + This Debrid result expired. Refreshing streams. + Could not resolve this Debrid stream. Couldn't open external player Choose an external player in settings first No external player is available diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2069679d..45d138f9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen import com.nuvio.app.features.details.MetaDetailsRepository @@ -159,6 +162,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsSyncService import com.nuvio.app.features.collection.FolderDetailScreen import com.nuvio.app.features.collection.FolderDetailRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLaunch import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamLinkCacheRepository @@ -1316,6 +1320,8 @@ private fun MainAppContent( return@composable } val pauseDescription = launch.pauseDescription + val streamRouteScope = rememberCoroutineScope() + var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } val lifecycleOwner = backStackEntry DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> @@ -1465,7 +1471,30 @@ private fun MainAppContent( if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect - val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val stream = when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + StreamsRepository.consumeAutoPlay() + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect + } + } val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { @@ -1537,12 +1566,46 @@ private fun MainAppContent( } fun openSelectedStream( - stream: com.nuvio.app.features.streams.StreamItem, + stream: StreamItem, resolvedResumePositionMs: Long?, resolvedResumeProgressFraction: Float?, forceExternal: Boolean, forceInternal: Boolean, ) { + if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) { + if (resolvingDebridStream) return + streamRouteScope.launch { + resolvingDebridStream = true + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + resolvingDebridStream = false + when (resolved) { + is DirectDebridPlayableResult.Success -> openSelectedStream( + stream = resolved.stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = forceExternal, + forceInternal = forceInternal, + ) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + } + } + } + return + } val sourceUrl = stream.directPlaybackUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1604,47 +1667,69 @@ private fun MainAppContent( ) } - StreamsScreen( - type = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId ?: effectiveVideoId, - parentMetaType = launch.parentMetaType ?: launch.type, - title = launch.title, - logo = launch.logo, - poster = launch.poster, - background = launch.background, - seasonNumber = launch.seasonNumber, - episodeNumber = launch.episodeNumber, - episodeTitle = launch.episodeTitle, - episodeThumbnail = launch.episodeThumbnail, - resumePositionMs = launch.resumePositionMs, - resumeProgressFraction = launch.resumeProgressFraction, - manualSelection = launch.manualSelection, - startFromBeginning = launch.startFromBeginning, - onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = false, - forceInternal = false, - ) - }, - onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = openExternally, - forceInternal = !openExternally, - ) - }, - onBack = { - StreamsRepository.clear() - navController.popBackStack() - }, - modifier = Modifier.fillMaxSize(), - ) + Box(modifier = Modifier.fillMaxSize()) { + StreamsScreen( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + title = launch.title, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + resumePositionMs = launch.resumePositionMs, + resumeProgressFraction = launch.resumeProgressFraction, + manualSelection = launch.manualSelection, + startFromBeginning = launch.startFromBeginning, + onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> + openSelectedStream( + stream = stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = false, + forceInternal = false, + ) + }, + onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction -> + openSelectedStream( + stream = stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = openExternally, + forceInternal = !openExternally, + ) + }, + onBack = { + StreamsRepository.clear() + navController.popBackStack() + }, + modifier = Modifier.fillMaxSize(), + ) + if (resolvingDebridStream) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.82f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = Color.White) + Text( + text = stringResource(Res.string.streams_finding_source), + color = Color.White.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } } composable( enterTransition = { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 58df719e..aacb5336 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionMobileSettingsStorage +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -157,6 +159,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, + DebridSettingsRepository.uiState.map { "debrid" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, @@ -202,6 +205,7 @@ object ProfileSettingsSync { themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), @@ -226,6 +230,9 @@ object ProfileSettingsSync { PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() + DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings) + DebridSettingsRepository.onProfileChanged() + TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings) TmdbSettingsRepository.onProfileChanged() @@ -255,6 +262,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() + DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() @@ -277,6 +285,7 @@ object ProfileSettingsSync { "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", + "debrid=${DebridSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", @@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt new file mode 100644 index 00000000..cc89019a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -0,0 +1,244 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +internal data class DebridApiResponse( + val status: Int, + val body: T?, + val rawBody: String, +) { + val isSuccessful: Boolean + get() = status in 200..299 +} + +internal object DebridApiJson { + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } +} + +internal object TorboxApiClient { + private const val BASE_URL = "https://api.torbox.app" + + suspend fun validateApiKey(apiKey: String): Boolean = + getUser(apiKey.trim()).status in 200..299 + + private suspend fun getUser(apiKey: String): RawHttpResponse = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/v1/api/user/me", + headers = authHeaders(apiKey), + body = "", + ) + + suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse> { + val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}" + val body = multipartFormBody( + boundary = boundary, + "magnet" to magnet, + "add_only_if_cached" to "true", + "allow_zip" to "false", + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/createtorrent", + apiKey = apiKey, + body = body, + contentType = "multipart/form-data; boundary=$boundary", + ) + } + + suspend fun getTorrent(apiKey: String, id: Int): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist?${ + queryString( + "id" to id.toString(), + "bypass_cache" to "true", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestDownloadLink( + apiKey: String, + torrentId: Int, + fileId: Int?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/requestdl?${ + queryString( + "token" to apiKey, + "torrent_id" to torrentId.toString(), + "file_id" to fileId?.toString(), + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +internal object RealDebridApiClient { + private const val BASE_URL = "https://api.real-debrid.com/rest/1.0" + + suspend fun validateApiKey(apiKey: String): Boolean = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/user", + headers = authHeaders(apiKey.trim()), + body = "", + ).status in 200..299 + + suspend fun addMagnet(apiKey: String, magnet: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/addMagnet", + apiKey = apiKey, + fields = listOf("magnet" to magnet), + ) + + suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/torrents/info/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/selectFiles/${encodePathSegment(id)}", + apiKey = apiKey, + fields = listOf("files" to files), + ) + + suspend fun unrestrictLink(apiKey: String, link: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/unrestrict/link", + apiKey = apiKey, + fields = listOf("link" to link), + ) + + suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse = + request( + method = "DELETE", + url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + private suspend inline fun formRequest( + method: String, + url: String, + apiKey: String, + fields: List>, + ): DebridApiResponse { + val body = fields.joinToString("&") { (key, value) -> + "${encodeFormValue(key)}=${encodeFormValue(value)}" + } + return request( + method = method, + url = url, + apiKey = apiKey, + body = body, + contentType = "application/x-www-form-urlencoded", + ) + } + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +object DebridCredentialValidator { + suspend fun validateProvider(providerId: String, apiKey: String): Boolean { + val normalized = apiKey.trim() + if (normalized.isBlank()) return false + return when (DebridProviders.byId(providerId)?.id) { + DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized) + DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized) + else -> false + } + } +} + +private inline fun RawHttpResponse.decodeBody(): T? { + if (body.isBlank() || T::class == Unit::class) return null + return try { + DebridApiJson.json.decodeFromString(body) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } +} + +private fun multipartFormBody(boundary: String, vararg fields: Pair): String = + buildString { + fields.forEach { (name, value) -> + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n") + append(value).append("\r\n") + } + append("--").append(boundary).append("--\r\n") + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt new file mode 100644 index 00000000..50a89fde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class TorboxEnvelopeDto( + val success: Boolean? = null, + val data: T? = null, + val error: String? = null, + val detail: String? = null, +) + +@Serializable +internal data class TorboxCreateTorrentDataDto( + @SerialName("torrent_id") val torrentId: Int? = null, + val id: Int? = null, + val hash: String? = null, + @SerialName("auth_id") val authId: String? = null, +) { + fun resolvedTorrentId(): Int? = torrentId ?: id +} + +@Serializable +internal data class TorboxTorrentDataDto( + val id: Int? = null, + val hash: String? = null, + val name: String? = null, + val files: List? = null, +) + +@Serializable +internal data class TorboxTorrentFileDto( + val id: Int? = null, + val name: String? = null, + @SerialName("short_name") val shortName: String? = null, + @SerialName("absolute_path") val absolutePath: String? = null, + @SerialName("mimetype") val mimeType: String? = null, + val size: Long? = null, +) { + fun displayName(): String = + listOfNotNull(name, shortName, absolutePath) + .firstOrNull { it.isNotBlank() } + .orEmpty() +} + +@Serializable +internal data class RealDebridAddTorrentDto( + val id: String? = null, + val uri: String? = null, +) + +@Serializable +internal data class RealDebridTorrentInfoDto( + val id: String? = null, + val filename: String? = null, + @SerialName("original_filename") val originalFilename: String? = null, + val hash: String? = null, + val bytes: Long? = null, + @SerialName("original_bytes") val originalBytes: Long? = null, + val host: String? = null, + val split: Int? = null, + val progress: Int? = null, + val status: String? = null, + val files: List? = null, + val links: List? = null, +) + +@Serializable +internal data class RealDebridTorrentFileDto( + val id: Int? = null, + val path: String? = null, + val bytes: Long? = null, + val selected: Int? = null, +) { + fun displayName(): String = + path.orEmpty().substringAfterLast('/').ifBlank { path.orEmpty() } +} + +@Serializable +internal data class RealDebridUnrestrictLinkDto( + val id: String? = null, + val filename: String? = null, + val mimeType: String? = null, + val filesize: Long? = null, + val link: String? = null, + val host: String? = null, + val chunks: Int? = null, + val crc: Int? = null, + val download: String? = null, + val streamable: Int? = null, + val type: String? = null, +) + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt new file mode 100644 index 00000000..fe427ba0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -0,0 +1,126 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve + +internal class TorboxFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): TorboxTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + resolve.fileIdx?.let { fileIdx -> + playable.firstOrNull { it.id == fileIdx }?.let { return it } + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it } + } + + val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName) + .map { it.normalizedName() } + .filter { it.isNotBlank() } + if (names.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().normalizedName() + names.any { name -> fileName.contains(name) || name.contains(fileName) } + }?.let { return it } + } + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { return it } + } + + return playable.maxByOrNull { it.size ?: 0L } + } + + private fun TorboxTorrentFileDto.isPlayableVideo(): Boolean { + val mime = mimeType.orEmpty().lowercase() + if (mime.startsWith("video/")) return true + return displayName().lowercase().hasVideoExtension() + } +} + +internal class RealDebridFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): RealDebridTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + resolve.fileIdx?.let { fileIdx -> + playable.firstOrNull { it.id == fileIdx }?.let { return it } + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it } + } + + val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName) + .map { it.normalizedName() } + .filter { it.isNotBlank() } + if (names.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().normalizedName() + names.any { name -> fileName.contains(name) || name.contains(fileName) } + }?.let { return it } + } + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { return it } + } + + return playable.maxByOrNull { it.bytes ?: 0L } + } + + private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean = + displayName().lowercase().hasVideoExtension() +} + +private fun String.normalizedName(): String = + substringAfterLast('/') + .substringBeforeLast('.') + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + +private fun buildEpisodePatterns(season: Int?, episode: Int?): List { + if (season == null || episode == null) return emptyList() + val seasonTwo = season.toString().padStart(2, '0') + val episodeTwo = episode.toString().padStart(2, '0') + return listOf( + "s${seasonTwo}e$episodeTwo", + "${season}x$episodeTwo", + "${season}x$episode", + ) +} + +private fun String.hasVideoExtension(): Boolean = + videoExtensions.any { endsWith(it) } + +private val videoExtensions = setOf( + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".m4v", + ".ts", + ".m2ts", + ".wmv", + ".flv", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt new file mode 100644 index 00000000..c37e584d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -0,0 +1,83 @@ +package com.nuvio.app.features.debrid + +data class DebridProvider( + val id: String, + val displayName: String, + val shortName: String, + val visibleInUi: Boolean = true, +) + +data class DebridServiceCredential( + val provider: DebridProvider, + val apiKey: String, +) + +object DebridProviders { + const val TORBOX_ID = "torbox" + const val REAL_DEBRID_ID = "realdebrid" + + val Torbox = DebridProvider( + id = TORBOX_ID, + displayName = "Torbox", + shortName = "TB", + ) + + val RealDebrid = DebridProvider( + id = REAL_DEBRID_ID, + displayName = "Real-Debrid", + shortName = "RD", + visibleInUi = false, + ) + + private val registered = listOf(Torbox, RealDebrid) + + fun all(): List = registered + + fun visible(): List = registered.filter { it.visibleInUi } + + fun byId(id: String?): DebridProvider? { + val normalized = id?.trim()?.takeIf { it.isNotBlank() } ?: return null + return registered.firstOrNull { it.id.equals(normalized, ignoreCase = true) } + } + + fun isSupported(id: String?): Boolean = byId(id) != null + + fun isVisible(id: String?): Boolean = byId(id)?.visibleInUi == true + + fun instantName(id: String?): String = "${displayName(id)} Instant" + + fun addonId(id: String?): String = + "debrid:${byId(id)?.id ?: id?.trim().orEmpty().ifBlank { "unknown" }}" + + fun displayName(id: String?): String = + byId(id)?.displayName ?: id.toFallbackDisplayName() + + fun shortName(id: String?): String = + byId(id)?.shortName ?: id?.trim()?.takeIf { it.isNotBlank() }?.uppercase().orEmpty() + + fun configuredServices(settings: DebridSettings): List = + buildList { + settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey -> + add(DebridServiceCredential(Torbox, apiKey)) + } + settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey -> + add(DebridServiceCredential(RealDebrid, apiKey)) + } + } + + fun configuredSourceNames(settings: DebridSettings): List = + configuredServices(settings).map { instantName(it.provider.id) } + + private fun String?.toFallbackDisplayName(): String { + val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return "Debrid" + return value + .replace('-', ' ') + .replace('_', ' ') + .split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.lowercase().replaceFirstChar { it.titlecase() } + } + .ifBlank { "Debrid" } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt new file mode 100644 index 00000000..75ee04c8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.debrid + +data class DebridSettings( + val enabled: Boolean = false, + val torboxApiKey: String = "", + val realDebridApiKey: String = "", + val preResolveLimit: Int = 0, + val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, + val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, +) { + val hasAnyApiKey: Boolean + get() = DebridProviders.configuredServices(this).isNotEmpty() +} + +internal const val DEBRID_PRE_RESOLVE_DEFAULT_LIMIT = 2 +internal const val DEBRID_PRE_RESOLVE_MAX_LIMIT = 5 + +internal fun normalizeDebridPreResolveLimit(value: Int): Int = + value.coerceIn(0, DEBRID_PRE_RESOLVE_MAX_LIMIT) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt new file mode 100644 index 00000000..6eba755a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -0,0 +1,122 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object DebridSettingsRepository { + private val _uiState = MutableStateFlow(DebridSettings()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + private var enabled = false + private var torboxApiKey = "" + private var realDebridApiKey = "" + private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE + private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun snapshot(): DebridSettings { + ensureLoaded() + return _uiState.value + } + + fun setEnabled(value: Boolean) { + ensureLoaded() + if (value && !hasVisibleApiKey()) return + if (enabled == value) return + enabled = value + publish() + DebridSettingsStorage.saveEnabled(value) + } + + fun setTorboxApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (torboxApiKey == normalized) return + torboxApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveTorboxApiKey(normalized) + } + + fun setRealDebridApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (realDebridApiKey == normalized) return + realDebridApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveRealDebridApiKey(normalized) + } + + fun setStreamNameTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + if (streamNameTemplate == normalized) return + streamNameTemplate = normalized + publish() + DebridSettingsStorage.saveStreamNameTemplate(normalized) + } + + fun setStreamDescriptionTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + if (streamDescriptionTemplate == normalized) return + streamDescriptionTemplate = normalized + publish() + DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) + } + + fun resetStreamTemplates() { + ensureLoaded() + streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE + streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + publish() + DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) + DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) + } + + private fun disableIfNoKeys() { + if (!hasVisibleApiKey()) { + enabled = false + DebridSettingsStorage.saveEnabled(false) + } + } + + private fun hasVisibleApiKey(): Boolean = + (DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) || + (DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank()) + + private fun loadFromDisk() { + hasLoaded = true + torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty() + realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty() + enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.NAME_TEMPLATE + streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + publish() + } + + private fun publish() { + _uiState.value = DebridSettings( + enabled = enabled, + torboxApiKey = torboxApiKey, + realDebridApiKey = realDebridApiKey, + streamNameTemplate = streamNameTemplate, + streamDescriptionTemplate = streamDescriptionTemplate, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt new file mode 100644 index 00000000..641968ed --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.json.JsonObject + +internal expect object DebridSettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) + fun loadTorboxApiKey(): String? + fun saveTorboxApiKey(apiKey: String) + fun loadRealDebridApiKey(): String? + fun saveRealDebridApiKey(apiKey: String) + fun loadStreamNameTemplate(): String? + fun saveStreamNameTemplate(template: String) + fun loadStreamDescriptionTemplate(): String? + fun saveStreamDescriptionTemplate(template: String) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt new file mode 100644 index 00000000..29829ee9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -0,0 +1,140 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamItem + +class DebridStreamFormatter( + private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), +) { + fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + if (!stream.isDirectDebridStream) return stream + val values = buildValues(stream) + val formattedName = engine.render(settings.streamNameTemplate, values) + .lineSequence() + .joinToString(" ") { it.trim() } + .replace(Regex("\\s+"), " ") + .trim() + val formattedDescription = engine.render(settings.streamDescriptionTemplate, values) + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + .trim() + + return stream.copy( + name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) }, + description = formattedDescription.ifBlank { stream.description ?: stream.title }, + ) + } + + private fun buildValues(stream: StreamItem): Map { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val season = resolve?.season + val episode = resolve?.episode + val seasons = parsed?.seasons.orEmpty() + val episodes = parsed?.episodes.orEmpty() + val visualTags = buildList { + addAll(parsed?.hdr.orEmpty()) + parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } + } + val edition = parsed?.edition ?: buildEdition(parsed) + + return linkedMapOf( + "stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title), + "stream.year" to parsed?.year, + "stream.season" to season, + "stream.episode" to episode, + "stream.seasons" to seasons, + "stream.episodes" to episodes, + "stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes), + "stream.formattedEpisodes" to formatEpisodes(episodes), + "stream.formattedSeasons" to formatSeasons(seasons), + "stream.resolution" to parsed?.resolution, + "stream.library" to false, + "stream.quality" to parsed?.quality, + "stream.visualTags" to visualTags, + "stream.audioTags" to parsed?.audio.orEmpty(), + "stream.audioChannels" to parsed?.channels.orEmpty(), + "stream.languages" to parsed?.languages.orEmpty(), + "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, + "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize), + "stream.folderSize" to raw?.folderSize, + "stream.encode" to parsed?.codec?.uppercase(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker), + "stream.network" to (parsed?.network ?: raw?.network), + "stream.releaseGroup" to parsed?.group, + "stream.duration" to parsed?.duration, + "stream.edition" to edition, + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename), + "stream.regexMatched" to null, + "stream.type" to streamType(resolve), + "service.cached" to resolve?.isCached, + "service.shortName" to serviceShortName(resolve), + "service.name" to serviceName(resolve), + "addon.name" to "Nuvio Direct Debrid", + ) + } + + private fun streamType(resolve: StreamClientResolve?): String = + when { + resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" + resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" + else -> resolve?.type.orEmpty() + } + + private fun serviceShortName(resolve: StreamClientResolve?): String = + resolve?.serviceExtension?.takeIf { it.isNotBlank() } + ?: DebridProviders.shortName(resolve?.service) + + private fun serviceName(resolve: StreamClientResolve?): String = + DebridProviders.displayName(resolve?.service) + + private fun buildEdition(parsed: StreamClientResolveParsed?): String? { + if (parsed == null) return null + return buildList { + if (parsed.extended == true) add("extended") + if (parsed.theatrical == true) add("theatrical") + if (parsed.remastered == true) add("remastered") + if (parsed.unrated == true) add("unrated") + }.joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun buildSeasonEpisodeList( + season: Int?, + episode: Int?, + seasons: List, + episodes: List, + ): List { + if (season != null && episode != null) return listOf("S${season.twoDigits()}E${episode.twoDigits()}") + if (seasons.isEmpty() || episodes.isEmpty()) return emptyList() + return seasons.flatMap { s -> episodes.map { e -> "S${s.twoDigits()}E${e.twoDigits()}" } } + } + + private fun formatEpisodes(episodes: List): String = + episodes.joinToString(" | ") { "E${it.twoDigits()}" } + + private fun formatSeasons(seasons: List): String = + seasons.joinToString(" | ") { "S${it.twoDigits()}" } + + private fun Int.twoDigits(): String = toString().padStart(2, '0') + + private fun languageEmoji(language: String): String = + when (language.lowercase()) { + "en", "eng", "english" -> "GB" + "hi", "hin", "hindi" -> "IN" + "ml", "mal", "malayalam" -> "IN" + "ta", "tam", "tamil" -> "IN" + "te", "tel", "telugu" -> "IN" + "ja", "jpn", "japanese" -> "JP" + "ko", "kor", "korean" -> "KR" + "fr", "fre", "fra", "french" -> "FR" + "es", "spa", "spanish" -> "ES" + "de", "ger", "deu", "german" -> "DE" + "it", "ita", "italian" -> "IT" + "multi" -> "Multi" + else -> language + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt new file mode 100644 index 00000000..bb5d25b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.debrid + +object DebridStreamFormatterDefaults { + const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant" + + const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt new file mode 100644 index 00000000..0e3ad2c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -0,0 +1,390 @@ +package com.nuvio.app.features.debrid + +import kotlin.math.abs +import kotlin.math.roundToLong + +class DebridStreamTemplateEngine { + fun render(template: String, values: Map): String { + if (template.isEmpty()) return "" + val out = StringBuilder() + var index = 0 + while (index < template.length) { + val start = template.indexOf('{', index) + if (start < 0) { + out.append(template.substring(index)) + break + } + out.append(template.substring(index, start)) + val end = findPlaceholderEnd(template, start + 1) + if (end < 0) { + out.append(template.substring(start)) + break + } + val expression = template.substring(start + 1, end) + out.append(renderExpression(expression, values)) + index = end + 1 + } + return out.toString() + } + + private fun renderExpression(expression: String, values: Map): String { + val bracket = findTopLevelChar(expression, '[') + if (bracket >= 0 && expression.endsWith("]")) { + val condition = expression.substring(0, bracket) + val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1)) + val selected = if (evaluateCondition(condition, values)) branches.first else branches.second + return render(selected, values) + } + + val tokens = splitOps(expression) + if (tokens.isEmpty()) return "" + var value: Any? = values[tokens.first()] + tokens.drop(1).forEach { op -> + value = applyTransform(value, op) + } + return valueToText(value) + } + + private fun evaluateCondition(expression: String, values: Map): Boolean { + val tokens = splitOps(expression).filter { it.isNotBlank() } + if (tokens.isEmpty()) return false + val groups = mutableListOf>() + var currentGroup = mutableListOf() + var index = 0 + while (index < tokens.size) { + when (tokens[index]) { + "or" -> { + groups += currentGroup + currentGroup = mutableListOf() + index++ + } + "and" -> index++ + else -> { + val field = tokens[index] + index++ + val ops = mutableListOf() + while ( + index < tokens.size && + tokens[index] != "and" && + tokens[index] != "or" && + !tokens[index].isFieldPath() + ) { + ops += tokens[index] + index++ + } + currentGroup += evaluateSingleCondition(values[field], ops) + } + } + } + groups += currentGroup + return groups.any { group -> group.isNotEmpty() && group.all { it } } + } + + private fun evaluateSingleCondition(value: Any?, ops: List): Boolean { + if (ops.isEmpty()) return isTruthy(value) + var result = false + var hasResult = false + ops.forEach { op -> + when { + op == "exists" -> { + result = exists(value) + hasResult = true + } + op == "istrue" -> { + result = if (hasResult) result else asBoolean(value) == true + hasResult = true + } + op == "isfalse" -> { + result = if (hasResult) !result else asBoolean(value) == false + hasResult = true + } + op.startsWith("~=") -> { + result = containsText(value, op.drop(2).trim()) + hasResult = true + } + op.startsWith("~") -> { + result = containsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith("=") -> { + result = equalsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith(">=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left >= right } + hasResult = true + } + op.startsWith("<=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left <= right } + hasResult = true + } + op.startsWith(">") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left > right } + hasResult = true + } + op.startsWith("<") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left < right } + hasResult = true + } + } + } + return result + } + + private fun applyTransform(value: Any?, op: String): Any? = + when { + op == "title" -> valueToText(value).titleCased() + op == "lower" -> valueToText(value).lowercase() + op == "upper" -> valueToText(value).uppercase() + op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty() + op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty() + op.startsWith("join(") -> { + val separator = parseArgs(op).firstOrNull() ?: ", " + when (value) { + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator) + else -> valueToText(value) + } + } + op.startsWith("replace(") -> { + val args = parseArgs(op) + if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1]) + } + else -> value + } + + private fun findPlaceholderEnd(text: String, start: Int): Int { + var quote: Char? = null + var index = start + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + } else { + when (char) { + '\'', '"' -> quote = char + '}' -> return index + } + } + index++ + } + return -1 + } + + private fun findTopLevelChar(text: String, target: Char): Int { + var quote: Char? = null + var parenDepth = 0 + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + target -> if (parenDepth == 0) return index + } + } + return -1 + } + + private fun splitOps(text: String): List { + val tokens = mutableListOf() + var quote: Char? = null + var parenDepth = 0 + var start = 0 + var index = 0 + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + index++ + continue + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ':' -> { + if (parenDepth == 0 && text.getOrNull(index + 1) == ':') { + tokens += text.substring(start, index).trim() + index += 2 + start = index + continue + } + } + } + index++ + } + tokens += text.substring(start).trim() + return tokens.filter { it.isNotEmpty() } + } + + private fun parseBranches(text: String): Pair { + val split = findBranchSeparator(text) + if (split < 0) return parseQuoted(text) to "" + return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2)) + } + + private fun findBranchSeparator(text: String): Int { + var quote: Char? = null + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '|' -> if (text.getOrNull(index + 1) == '|') return index + } + } + return -1 + } + + private fun parseArgs(op: String): List { + val start = op.indexOf('(') + val end = op.lastIndexOf(')') + if (start < 0 || end <= start) return emptyList() + val body = op.substring(start + 1, end) + val args = mutableListOf() + var quote: Char? = null + var argStart = 0 + body.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && body.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + ',' -> { + args += parseQuoted(body.substring(argStart, index)) + argStart = index + 1 + } + } + } + args += parseQuoted(body.substring(argStart)) + return args + } + + private fun parseQuoted(raw: String): String { + val trimmed = raw.trim() + val unquoted = if ( + trimmed.length >= 2 && + ((trimmed.first() == '"' && trimmed.last() == '"') || + (trimmed.first() == '\'' && trimmed.last() == '\'')) + ) { + trimmed.substring(1, trimmed.length - 1) + } else { + trimmed + } + return unquoted + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\'", "'") + .replace("\\\\", "\\") + } + + private fun String.isFieldPath(): Boolean = + startsWith("stream.") || startsWith("service.") || startsWith("addon.") + + private fun exists(value: Any?): Boolean = + when (value) { + null -> false + is String -> value.isNotBlank() + is Iterable<*> -> value.any() + is Array<*> -> value.isNotEmpty() + else -> true + } + + private fun isTruthy(value: Any?): Boolean = + when (value) { + is Boolean -> value + is Number -> value.toDouble() != 0.0 + else -> exists(value) + } + + private fun asBoolean(value: Any?): Boolean? = + when (value) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() + else -> null + } + + private fun asNumber(value: Any?): Double? = + when (value) { + is Number -> value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + + private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean { + val left = asNumber(value) ?: return false + val right = rawTarget.trim().toDoubleOrNull() ?: return false + return compare(left, right) + } + + private fun equalsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) } + else -> valueToText(value).trim().equals(target, ignoreCase = true) + } + + private fun containsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) } + else -> valueToText(value).contains(target, ignoreCase = true) + } + + private fun valueToText(value: Any?): String = + when (value) { + null -> "" + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() + is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() + else -> value.toString() + } + + private fun String.titleCased(): String = + split(Regex("\\s+")) + .joinToString(" ") { word -> + if (word.isBlank()) { + word + } else { + word.lowercase().replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + private fun formatBytes(value: Double): String { + val bytes = abs(value) + if (bytes < 1024.0) return "${value.toLong()} B" + val units = listOf("KB", "MB", "GB", "TB") + var current = bytes + var unitIndex = -1 + while (current >= 1024.0 && unitIndex < units.lastIndex) { + current /= 1024.0 + unitIndex++ + } + val signed = if (value < 0) -current else current + return if (signed >= 10 || signed % 1.0 == 0.0) { + "${signed.toLong()} ${units[unitIndex]}" + } else { + val tenths = (signed * 10.0).roundToLong() + "${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}" + } + } + + private fun formatTime(value: Double): String { + val seconds = value.toLong() + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m ${remainingSeconds}s" + else -> "${remainingSeconds}s" + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt new file mode 100644 index 00000000..2d9465d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.debrid + +internal fun encodePathSegment(value: String): String = + percentEncode(value, spaceAsPlus = false) + +internal fun encodeFormValue(value: String): String = + percentEncode(value, spaceAsPlus = true) + +internal fun queryString(vararg pairs: Pair): String = + pairs + .mapNotNull { (key, value) -> + value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" } + } + .joinToString("&") + +private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString { + val hex = "0123456789ABCDEF" + value.encodeToByteArray().forEach { byte -> + val code = byte.toInt() and 0xFF + val isUnreserved = (code in 'A'.code..'Z'.code) || + (code in 'a'.code..'z'.code) || + (code in '0'.code..'9'.code) || + code == '-'.code || + code == '.'.code || + code == '_'.code || + code == '~'.code + when { + isUnreserved -> append(code.toChar()) + spaceAsPlus && code == 0x20 -> append('+') + else -> { + append('%') + append(hex[code shr 4]) + append(hex[code and 0x0F]) + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt new file mode 100644 index 00000000..855e9124 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.debrid + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class DirectDebridConfigEncoder { + @OptIn(ExperimentalEncodingApi::class) + fun encode(service: DebridServiceCredential): String { + val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}""" + val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}""" + return Base64.Default.encode(json.encodeToByteArray()) + } + + fun encodeTorbox(apiKey: String): String = + encode(DebridServiceCredential(DebridProviders.Torbox, apiKey)) +} + +private fun String.jsonEscaped(): String = buildString { + this@jsonEscaped.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt new file mode 100644 index 00000000..86b33ef7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -0,0 +1,210 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.debrid_missing_api_key +import nuvio.composeapp.generated.resources.debrid_resolve_failed +import nuvio.composeapp.generated.resources.debrid_stream_stale +import org.jetbrains.compose.resources.getString + +object DirectDebridPlaybackResolver { + private val torboxResolver = TorboxDirectDebridResolver() + private val realDebridResolver = RealDebridDirectDebridResolver() + + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = + when (DebridProviders.byId(stream.clientResolve?.service)?.id) { + DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) + DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) + else -> DirectDebridResolveResult.Error + } + + suspend fun resolveToPlayableStream( + stream: StreamItem, + season: Int?, + episode: Int?, + ): DirectDebridPlayableResult { + if (!stream.isDirectDebridStream) return DirectDebridPlayableResult.Success(stream) + return when (val result = resolve(stream, season, episode)) { + is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) + DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey + DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale + DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error + } + } +} + +sealed class DirectDebridPlayableResult { + data class Success(val stream: StreamItem) : DirectDebridPlayableResult() + data object MissingApiKey : DirectDebridPlayableResult() + data object Stale : DirectDebridPlayableResult() + data object Error : DirectDebridPlayableResult() +} + +sealed class DirectDebridResolveResult { + data class Success( + val url: String, + val filename: String?, + val videoSize: Long?, + ) : DirectDebridResolveResult() + + data object MissingApiKey : DirectDebridResolveResult() + data object Stale : DirectDebridResolveResult() + data object Error : DirectDebridResolveResult() +} + +fun DirectDebridPlayableResult.toastMessage(): String? = + when (this) { + is DirectDebridPlayableResult.Success -> null + DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) } + DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } + DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } + } + +private class TorboxDirectDebridResolver( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() + if (apiKey.isBlank()) return DirectDebridResolveResult.MissingApiKey + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) return DirectDebridResolveResult.Stale + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) return DirectDebridResolveResult.Stale + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() }, + videoSize = file.size, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } + + private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } +} + +private class RealDebridDirectDebridResolver( + private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim() + if (apiKey.isBlank()) return DirectDebridResolveResult.MissingApiKey + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val add = RealDebridApiClient.addMagnet(apiKey, magnet) + val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() } + ?: return add.toFailureForAdd() + var resolved = false + try { + val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoBefore.isSuccessful) return DirectDebridResolveResult.Stale + val file = fileSelector.selectFile( + files = infoBefore.body?.files.orEmpty(), + resolve = resolve, + season = season, + episode = episode, + ) ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) + if (!select.isSuccessful && select.status != 202) return DirectDebridResolveResult.Stale + + val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoAfter.isSuccessful) return DirectDebridResolveResult.Stale + val link = infoAfter.body?.firstDownloadLink() + ?: return DirectDebridResolveResult.Stale + val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) + if (!unrestrict.isSuccessful) return DirectDebridResolveResult.Stale + val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + resolved = true + DirectDebridResolveResult.Success( + url = url, + filename = unrestrict.body.filename?.takeIf { it.isNotBlank() } + ?: file.displayName().takeIf { it.isNotBlank() }, + videoSize = unrestrict.body.filesize ?: file.bytes, + ) + } finally { + if (!resolved) { + runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) } + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } + + private fun DebridApiResponse.toFailureForAdd(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } + + private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? { + if (!status.equals("downloaded", ignoreCase = true)) return null + return links.orEmpty().firstOrNull { it.isNotBlank() } + } +} + +private fun buildMagnetUri(resolve: StreamClientResolve): String? { + val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + resolve.sources + .filter { it.isNotBlank() } + .forEach { source -> + append("&tr=") + append(encodePathSegment(source)) + } + } +} + +private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem = + copy( + url = result.url, + externalUrl = null, + behaviorHints = behaviorHints.mergeResolvedDebridHints(result), + ) + +private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints = + copy( + filename = result.filename ?: filename, + videoSize = result.videoSize ?: videoSize, + ) + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt new file mode 100644 index 00000000..28c03dde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -0,0 +1,41 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +object DirectDebridStreamFilter { + const val FALLBACK_SOURCE_NAME = "Direct Debrid" + + fun filterInstant(streams: List): List = + streams + .filter(::isInstantCandidate) + .map { stream -> + val providerId = stream.clientResolve?.service + val sourceName = DebridProviders.instantName(providerId) + stream.copy( + name = stream.name ?: sourceName, + addonName = sourceName, + addonId = DebridProviders.addonId(providerId), + sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME, + ) + } + .distinctBy { stream -> + listOf( + stream.clientResolve?.infoHash?.lowercase(), + stream.clientResolve?.fileIdx?.toString(), + stream.clientResolve?.filename, + stream.name, + stream.title, + ).joinToString("|") + } + + fun isInstantCandidate(stream: StreamItem): Boolean { + val resolve = stream.clientResolve ?: return false + return resolve.type.equals("debrid", ignoreCase = true) && + DebridProviders.isSupported(resolve.service) && + resolve.isCached == true + } + + fun isDirectDebridSourceName(addonName: String): Boolean = + DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt new file mode 100644 index 00000000..9c3ee933 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -0,0 +1,97 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamParser +import kotlinx.coroutines.CancellationException + +private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" + +data class DirectDebridStreamTarget( + val provider: DebridProvider, + val apiKey: String, +) { + val addonId: String = DebridProviders.addonId(provider.id) + val addonName: String = DebridProviders.instantName(provider.id) +} + +object DirectDebridStreamSource { + private val log = Logger.withTag(DIRECT_DEBRID_TAG) + private val encoder = DirectDebridConfigEncoder() + private val formatter = DebridStreamFormatter() + + fun configuredTargets(): List { + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList() + return DebridProviders.configuredServices(settings).map { credential -> + DirectDebridStreamTarget( + provider = credential.provider, + apiKey = credential.apiKey, + ) + } + } + + fun placeholders(): List = + configuredTargets().map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) + } + + suspend fun fetchProviderStreams( + type: String, + videoId: String, + target: DirectDebridStreamTarget, + ): AddonStreamGroup { + val settings = DebridSettingsRepository.snapshot() + val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/') + if (!settings.enabled || baseUrl.isBlank()) { + return target.emptyGroup() + } + + val credential = DebridServiceCredential(target.provider, target.apiKey) + val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" + return try { + val payload = httpGetText(url) + val streams = StreamParser.parse( + payload = payload, + addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, + addonId = target.addonId, + ) + .let(DirectDebridStreamFilter::filterInstant) + .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } + .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } + + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = false, + error = error.message, + ) + } + } + + private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + ) +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 69eb462e..255205cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> EpisodeSourceStreamRow( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 50c727c1..279aae9f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.ManagedAddon @@ -802,7 +806,55 @@ fun PlayerScreen( playerController?.seekTo(targetPositionMs) } + fun resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, + ): Boolean { + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false + scope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = season, + episode = episode, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + onStale() + } + } + } + } + return true + } + fun switchToSource(stream: StreamItem) { + if ( + resolveDebridForPlayer( + stream = stream, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + onResolved = ::switchToSource, + onStale = { + val type = contentType ?: parentMetaType + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = type, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + ) + ) return val url = stream.directPlaybackUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) @@ -844,6 +896,26 @@ fun PlayerScreen( } fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { + if ( + resolveDebridForPlayer( + stream = stream, + season = episode.season, + episode = episode.episode, + onResolved = { resolvedStream -> + switchToEpisodeStream(resolvedStream, episode) + }, + onStale = { + val type = contentType ?: parentMetaType + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + }, + ) + ) return val url = stream.directPlaybackUrl ?: return showNextEpisodeCard = false showSourcesPanel = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index 9e64a911..d57dd46d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -203,7 +203,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 78f55bdb..b05539b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -152,6 +153,7 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -159,7 +161,7 @@ object PlayerStreamsRepository { emptyList() } - if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -185,7 +187,7 @@ object PlayerStreamsRepository { ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -207,6 +209,13 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) } stateFlow.value = StreamsUiState( groups = initialGroups, @@ -275,7 +284,17 @@ object PlayerStreamsRepository { } } - val jobs = addonJobs + pluginJobs + val debridJobs = debridTargets.map { target -> + async { + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ) + } + } + + val jobs = addonJobs + pluginJobs + debridJobs jobs.forEach { deferred -> val result = deferred.await() stateFlow.update { current -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt new file mode 100644 index 00000000..3307a322 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -0,0 +1,294 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.debrid.DebridCredentialValidator +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.action_validate +import nuvio.composeapp.generated.resources.settings_debrid_add_key_first +import nuvio.composeapp.generated.resources.settings_debrid_description_template +import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_enable +import nuvio.composeapp.generated.resources.settings_debrid_enable_description +import nuvio.composeapp.generated.resources.settings_debrid_key_valid +import nuvio.composeapp.generated.resources.settings_debrid_key_invalid +import nuvio.composeapp.generated.resources.settings_debrid_name_template +import nuvio.composeapp.generated.resources.settings_debrid_name_template_description +import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description +import nuvio.composeapp.generated.resources.settings_debrid_section_formatting +import nuvio.composeapp.generated.resources.settings_debrid_section_providers +import nuvio.composeapp.generated.resources.settings_debrid_section_title +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.debridSettingsContent( + isTablet: Boolean, + settings: DebridSettings, +) { + item { + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_title), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_enable), + description = stringResource(Res.string.settings_debrid_enable_description), + checked = settings.enabled, + enabled = settings.hasAnyApiKey, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setEnabled, + ) + if (!settings.hasAnyApiKey) { + SettingsGroupDivider(isTablet = isTablet) + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_add_key_first), + ) + } + } + } + } + + item { + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_providers), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridApiKeyRow( + isTablet = isTablet, + providerId = DebridProviders.TORBOX_ID, + title = DebridProviders.Torbox.displayName, + description = stringResource(Res.string.settings_debrid_provider_torbox_description), + value = settings.torboxApiKey, + onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey, + ) + } + } + } + + item { + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_formatting), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridTemplateRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + value = settings.streamNameTemplate, + singleLine = true, + onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridTemplateRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + value = settings.streamDescriptionTemplate, + singleLine = false, + onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate, + ) + SettingsGroupDivider(isTablet = isTablet) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp), + ) { + TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) { + Text(stringResource(Res.string.action_reset)) + } + } + } + } + } +} + +@Composable +private fun DebridApiKeyRow( + isTablet: Boolean, + providerId: String, + title: String, + description: String, + value: String, + onApiKeyCommitted: (String) -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + val scope = rememberCoroutineScope() + var draft by rememberSaveable(value) { mutableStateOf(value) } + var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } + var validationMessage by rememberSaveable(providerId, value) { mutableStateOf(null) } + val normalizedDraft = draft.trim() + val validMessage = stringResource(Res.string.settings_debrid_key_valid) + val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SettingsSecretTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + label = "$title API key", + ) + + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + draft = normalizedDraft + onApiKeyCommitted(normalizedDraft) + }, + enabled = normalizedDraft != value && !isValidating, + ) { + Text(stringResource(Res.string.action_save)) + } + TextButton( + onClick = { + scope.launch { + isValidating = true + val valid = runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + validationMessage = if (valid) validMessage else invalidMessage + isValidating = false + } + }, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text(stringResource(Res.string.action_validate)) + } + } + } +} + +@Composable +private fun DebridTemplateRow( + isTablet: Boolean, + title: String, + description: String, + value: String, + singleLine: Boolean, + onTemplateCommitted: (String) -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + var draft by rememberSaveable(value) { mutableStateOf(value) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = singleLine, + minLines = if (singleLine) 1 else 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + + Row(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { onTemplateCommitted(draft) }, + enabled = draft != value, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } +} + +@Composable +private fun DebridInfoRow( + isTablet: Boolean, + text: String, +) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index 7602c3e2..a4999c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,10 +1,14 @@ package com.nuvio.app.features.settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudQueue import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_debrid_description import nuvio.composeapp.generated.resources.settings_integrations_section_title import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description import org.jetbrains.compose.resources.stringResource @@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent( isTablet: Boolean, onTmdbClick: () -> Unit, onMdbListClick: () -> Unit, + onDebridClick: () -> Unit, ) { item { SettingsSection( @@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent( isTablet = isTablet, onClick = onMdbListClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_debrid), + description = stringResource(Res.string.settings_integrations_debrid_description), + icon = Icons.Rounded.CloudQueue, + isTablet = isTablet, + onClick = onDebridClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index d030a785..a6eb2a40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_addons import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching import nuvio.composeapp.generated.resources.compose_settings_page_homescreen import nuvio.composeapp.generated.resources.compose_settings_page_integrations @@ -129,6 +130,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Integrations, ), + Debrid( + titleRes = Res.string.compose_settings_page_debrid, + category = SettingsCategory.General, + parentPage = Integrations, + ), TraktAuthentication( titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 4cd95d64..519dc8d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -127,6 +129,10 @@ fun SettingsScreen( MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState @@ -232,6 +238,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -279,6 +286,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -336,6 +344,7 @@ private fun MobileSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -544,6 +553,7 @@ private fun MobileSettingsScreen( isTablet = false, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, @@ -553,6 +563,10 @@ private fun MobileSettingsScreen( isTablet = false, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = false, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, @@ -637,6 +651,7 @@ private fun TabletSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -904,6 +919,7 @@ private fun TabletSettingsScreen( isTablet = true, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, @@ -913,6 +929,10 @@ private fun TabletSettingsScreen( isTablet = true, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = true, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index cd32101a..5fbfb6fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -34,14 +34,14 @@ object StreamAutoPlaySelector { val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { val bingeGroupMatch = candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && stream.directPlaybackUrl != null + stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() } if (bingeGroupMatch != null) return bingeGroupMatch } return when (mode) { StreamAutoPlayMode.MANUAL -> null - StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.directPlaybackUrl != null } + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() } StreamAutoPlayMode.REGEX_MATCH -> { val pattern = regexPattern.trim() @@ -61,7 +61,8 @@ object StreamAutoPlaySelector { } else null val matchingStreams = candidateStreams.filter { stream -> - val url = stream.directPlaybackUrl ?: return@filter false + if (!stream.isAutoPlayable()) return@filter false + val url = stream.directPlaybackUrl.orEmpty() val searchableText = buildString { append(stream.addonName).append(' ') @@ -81,8 +82,11 @@ object StreamAutoPlaySelector { } if (matchingStreams.isEmpty()) return null - matchingStreams.firstOrNull { it.directPlaybackUrl != null } + matchingStreams.firstOrNull { it.isAutoPlayable() } } } } + + private fun StreamItem.isAutoPlayable(): Boolean = + directPlaybackUrl != null || isDirectDebridStream } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 784dff47..0b3d8b24 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -6,15 +6,18 @@ import org.jetbrains.compose.resources.getString data class StreamItem( val name: String? = null, + val title: String? = null, val description: String? = null, val url: String? = null, val infoHash: String? = null, val fileIdx: Int? = null, val externalUrl: String? = null, + val sources: List = emptyList(), val sourceName: String? = null, val addonName: String, val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), + val clientResolve: StreamClientResolve? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -25,13 +28,18 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val isDirectDebridStream: Boolean + get() = clientResolve?.isDirectDebridCandidate == true + val isTorrentStream: Boolean - get() = !infoHash.isNullOrBlank() || + get() = !isDirectDebridStream && ( + !infoHash.isNullOrBlank() || url.isMagnetLink() || externalUrl.isMagnetLink() + ) val hasPlayableSource: Boolean - get() = url != null || infoHash != null || externalUrl != null + get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } private fun String?.isMagnetLink(): Boolean = @@ -40,6 +48,7 @@ private fun String?.isMagnetLink(): Boolean = data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, + val videoHash: String? = null, val videoSize: Long? = null, val filename: String? = null, val proxyHeaders: StreamProxyHeaders? = null, @@ -50,6 +59,71 @@ data class StreamProxyHeaders( val response: Map? = null, ) +data class StreamClientResolve( + val type: String? = null, + val infoHash: String? = null, + val fileIdx: Int? = null, + val magnetUri: String? = null, + val sources: List = emptyList(), + val torrentName: String? = null, + val filename: String? = null, + val mediaType: String? = null, + val mediaId: String? = null, + val mediaOnlyId: String? = null, + val title: String? = null, + val season: Int? = null, + val episode: Int? = null, + val service: String? = null, + val serviceIndex: Int? = null, + val serviceExtension: String? = null, + val isCached: Boolean? = null, + val stream: StreamClientResolveStream? = null, +) { + val isDirectDebridCandidate: Boolean + get() = type.equals("debrid", ignoreCase = true) && + !service.isNullOrBlank() && + isCached == true +} + +data class StreamClientResolveStream( + val raw: StreamClientResolveRaw? = null, +) + +data class StreamClientResolveRaw( + val torrentName: String? = null, + val filename: String? = null, + val size: Long? = null, + val folderSize: Long? = null, + val tracker: String? = null, + val indexer: String? = null, + val network: String? = null, + val parsed: StreamClientResolveParsed? = null, +) + +data class StreamClientResolveParsed( + val rawTitle: String? = null, + val parsedTitle: String? = null, + val year: Int? = null, + val resolution: String? = null, + val seasons: List = emptyList(), + val episodes: List = emptyList(), + val quality: String? = null, + val hdr: List = emptyList(), + val codec: String? = null, + val audio: List = emptyList(), + val channels: List = emptyList(), + val languages: List = emptyList(), + val group: String? = null, + val network: String? = null, + val edition: String? = null, + val duration: Long? = null, + val bitDepth: String? = null, + val extended: Boolean? = null, + val theatrical: Boolean? = null, + val remastered: Boolean? = null, + val unrated: Boolean? = null, +) + data class AddonStreamGroup( val addonName: String, val addonId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 1f6e4e92..7c186f4a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -26,9 +26,10 @@ object StreamParser { val url = obj.string("url") val infoHash = obj.string("infoHash") val externalUrl = obj.string("externalUrl") + val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() // Must have at least one playable source - if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null + if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject val proxyHeaders = hintsObj @@ -36,16 +37,20 @@ object StreamParser { ?.toProxyHeaders() StreamItem( name = obj.string("name"), + title = obj.string("title"), description = obj.string("description") ?: obj.string("title"), url = url, infoHash = infoHash, fileIdx = obj.int("fileIdx"), externalUrl = externalUrl, + sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, + videoHash = hintsObj?.string("videoHash"), videoSize = hintsObj?.long("videoSize"), filename = hintsObj?.string("filename"), proxyHeaders = proxyHeaders, @@ -69,6 +74,16 @@ object StreamParser { private fun JsonObject.objectValue(name: String): JsonObject? = this[name] as? JsonObject + private fun JsonObject.stringList(name: String): List = + (this[name] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } + .orEmpty() + + private fun JsonObject.intList(name: String): List = + (this[name] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.intOrNull } + .orEmpty() + private fun JsonObject.stringMap(): Map = entries.mapNotNull { (key, value) -> (value as? JsonPrimitive)?.contentOrNull @@ -87,4 +102,68 @@ object StreamParser { response = responseHeaders, ) } + + private fun JsonObject.toClientResolve(): StreamClientResolve = + StreamClientResolve( + type = string("type"), + infoHash = string("infoHash"), + fileIdx = int("fileIdx"), + magnetUri = string("magnetUri"), + sources = stringList("sources"), + torrentName = string("torrentName"), + filename = string("filename"), + mediaType = string("mediaType"), + mediaId = string("mediaId"), + mediaOnlyId = string("mediaOnlyId"), + title = string("title"), + season = int("season"), + episode = int("episode"), + service = string("service"), + serviceIndex = int("serviceIndex"), + serviceExtension = string("serviceExtension"), + isCached = boolean("isCached"), + stream = objectValue("stream")?.toClientResolveStream(), + ) + + private fun JsonObject.toClientResolveStream(): StreamClientResolveStream = + StreamClientResolveStream( + raw = objectValue("raw")?.toClientResolveRaw(), + ) + + private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw = + StreamClientResolveRaw( + torrentName = string("torrentName"), + filename = string("filename"), + size = long("size"), + folderSize = long("folderSize"), + tracker = string("tracker"), + indexer = string("indexer"), + network = string("network"), + parsed = objectValue("parsed")?.toClientResolveParsed(), + ) + + private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed = + StreamClientResolveParsed( + rawTitle = string("raw_title"), + parsedTitle = string("parsed_title"), + year = int("year"), + resolution = string("resolution"), + seasons = intList("seasons"), + episodes = intList("episodes"), + quality = string("quality"), + hdr = stringList("hdr"), + codec = string("codec"), + audio = stringList("audio"), + channels = stringList("channels"), + languages = stringList("languages"), + group = string("group"), + network = string("network"), + edition = string("edition"), + duration = long("duration"), + bitDepth = string("bit_depth"), + extended = boolean("extended"), + theatrical = boolean("theatrical"), + remastered = boolean("remastered"), + unrated = boolean("unrated"), + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index daa96a7b..b5b4ee8a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -14,6 +15,7 @@ import com.nuvio.app.features.plugins.PluginRepositoryItem import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -131,6 +133,7 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { @@ -141,7 +144,7 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -170,7 +173,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -194,6 +197,13 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) } _uiState.value = StreamsUiState( requestToken = requestToken, @@ -211,13 +221,20 @@ object StreamsRepository { .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = streamAddons.size + pluginRemainingByAddonId.values.sum() + val totalTasks = streamAddons.size + + pluginProviderGroups.sumOf { it.scrapers.size } + + debridTargets.size val installedAddonNames = installedAddons .map { it.displayTitle } .toSet() var autoSelectTriggered = false var timeoutElapsed = false + fun publishCompletion(completion: StreamLoadCompletion) { + if (completions.trySend(completion).isFailure) { + log.d { "Ignoring late stream load completion after channel close" } + } + } val timeoutJob = if (isAutoPlayEnabled) { val timeoutMs = playerSettings.streamAutoPlayTimeoutSeconds * 1_000L @@ -271,7 +288,7 @@ object StreamsRepository { log.d { "Fetching streams from: $url" } val displayName = addon.addonName - val group = runCatching { + val group = runCatchingUnlessCancelled { val payload = httpGetText(url) StreamParser.parse( payload = payload, @@ -299,7 +316,7 @@ object StreamsRepository { ) }, ) - completions.send(StreamLoadCompletion.Addon(group)) + publishCompletion(StreamLoadCompletion.Addon(group)) } } @@ -340,11 +357,25 @@ object StreamsRepository { ) }, ) - completions.send(completion) + publishCompletion(completion) } } } + debridTargets.forEach { target -> + launch { + publishCompletion( + StreamLoadCompletion.Debrid( + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ), + ), + ) + } + } + repeat(totalTasks) { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { @@ -400,11 +431,24 @@ object StreamsRepository { ) } } + + is StreamLoadCompletion.Debrid -> { + val result = completion.group + _uiState.update { current -> + val updated = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + } + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + } } } - completions.close() - if (isAutoPlayEnabled && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } @@ -493,6 +537,7 @@ private data class PluginProviderGroup( private sealed interface StreamLoadCompletion { data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion + data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion data class PluginScraper( val addonId: String, val streams: List, @@ -538,6 +583,15 @@ private fun List.toEmptyStateReason(anyLoading: Boolean): Stre } } +private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } + private fun PluginRuntimeResult.toStreamItem( scraper: PluginScraper, addonName: String = scraper.name, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 68eeca73..e7078fd9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -864,7 +864,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream) { + if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, @@ -896,7 +896,7 @@ internal fun streamCardRenderKey( append(':') append(itemIndex) append(':') - append(stream.url ?: stream.infoHash ?: stream.streamLabel) + append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) } // --------------------------------------------------------------------------- @@ -970,7 +970,7 @@ private fun StreamCard( onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream + val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt new file mode 100644 index 00000000..86132213 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -0,0 +1,75 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridFileSelectorTest { + @Test + fun `Torbox selector prefers exact file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1), + TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 8), + season = null, + episode = null, + ) + + assertEquals(8, selected?.id) + } + + @Test + fun `Torbox selector falls back to largest playable video`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999), + TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200), + TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(), + season = null, + episode = null, + ) + + assertEquals(3, selected?.id) + } + + @Test + fun `Real-Debrid selector matches episode pattern before largest file`() { + val files = listOf( + RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000), + RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000), + ) + + val selected = RealDebridFileSelector().selectFile( + files = files, + resolve = resolve(season = 1, episode = 1), + season = null, + episode = null, + ) + + assertEquals(1, selected?.id) + } + + private fun resolve( + fileIdx: Int? = null, + season: Int? = null, + episode: Int? = null, + ): StreamClientResolve = + StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = "hash", + fileIdx = fileIdx, + season = season, + episode = episode, + ) +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt new file mode 100644 index 00000000..b9fd86f9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridStreamTemplateEngineTest { + private val engine = DebridStreamTemplateEngine() + + @Test + fun `renders nested condition branches and transforms`() { + val rendered = engine.render( + "{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}", + mapOf( + "stream.resolution" to "2160p", + "stream.title" to "sample movie", + "service.shortName" to "RD", + ), + ) + + assertEquals("4K RD Sample Movie", rendered) + } + + @Test + fun `formats bytes and joins list values`() { + val rendered = engine.render( + "{stream.size::bytes} {stream.audioTags::join(' | ')}", + mapOf( + "stream.size" to 1_610_612_736L, + "stream.audioTags" to listOf("DTS", "Atmos"), + ), + ) + + assertEquals("1.5 GB DTS | Atmos", rendered) + } +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt new file mode 100644 index 00000000..15fcf1e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt @@ -0,0 +1,27 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridConfigEncoderTest { + @Test + fun `encodes Torbox config exactly like TV`() { + val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key") + + assertEquals( + "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9", + encoded, + ) + } + + @Test + fun `escapes API key before base64 encoding`() { + val encoded = DirectDebridConfigEncoder().encode( + DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"), + ) + + val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0=" + assertEquals(expected, encoded) + } +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt new file mode 100644 index 00000000..271f6f42 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DirectDebridStreamFilterTest { + @Test + fun `keeps only cached supported debrid streams`() { + val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true) + val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false) + val unsupported = stream(service = "other", cached = true) + val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent") + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent)) + + assertEquals(1, filtered.size) + assertEquals("Torbox Instant", filtered.single().addonName) + assertEquals("debrid:torbox", filtered.single().addonId) + } + + @Test + fun `dedupes by hash file and filename identity`() { + val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2) + val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2) + val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3) + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile)) + + assertEquals(2, filtered.size) + } + + @Test + fun `direct debrid stream is not treated as unsupported torrent`() { + val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash") + val plainTorrent = StreamItem( + name = "Torrent", + infoHash = "hash", + addonName = "Addon", + addonId = "addon", + ) + + assertTrue(direct.isDirectDebridStream) + assertFalse(direct.isTorrentStream) + assertTrue(plainTorrent.isTorrentStream) + } + + private fun stream( + service: String?, + cached: Boolean?, + type: String = "debrid", + infoHash: String = "hash", + fileIdx: Int = 1, + ): StreamItem = + StreamItem( + name = "Stream", + addonName = "Direct Debrid", + addonId = "debrid", + clientResolve = StreamClientResolve( + type = type, + service = service, + isCached = cached, + infoHash = infoHash, + fileIdx = fileIdx, + filename = "video.mkv", + ), + ) +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 45fa4740..1ebf6b84 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest { assertNull(selected) } + @Test + fun `first stream mode can select direct debrid candidate without resolved URL`() { + val directDebrid = stream( + addonName = "Torbox Instant", + url = null, + name = "TB Instant", + directDebrid = true, + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(directDebrid), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = emptySet(), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + ) + + assertEquals(directDebrid, selected) + } + private fun stream( addonName: String, url: String? = null, name: String? = null, bingeGroup: String? = null, + directDebrid: Boolean = false, ): StreamItem = StreamItem( name = name, url = url, addonName = addonName, addonId = addonName, + clientResolve = if (directDebrid) { + StreamClientResolve( + type = "debrid", + service = "torbox", + isCached = true, + infoHash = "hash", + ) + } else { + null + }, behaviorHints = StreamBehaviorHints( bingeGroup = bingeGroup, ), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 3f297d04..9260e883 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -119,4 +119,56 @@ class StreamParserTest { assertEquals("video/mp4", responseHeaders["content-type"]) assertEquals("ok", responseHeaders["x-test"]) } + + @Test + fun `parse keeps client resolve metadata without direct URL`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "name": "Instant", + "clientResolve": { + "type": "debrid", + "infoHash": "abc123", + "fileIdx": 4, + "sources": ["udp://tracker.example"], + "torrentName": "Movie Pack", + "filename": "Movie.2024.2160p.mkv", + "service": "torbox", + "isCached": true, + "stream": { + "raw": { + "size": 1610612736, + "indexer": "Indexer", + "parsed": { + "parsed_title": "Movie", + "year": 2024, + "resolution": "2160p", + "hdr": ["DV"], + "audio": ["Atmos"], + "episodes": [1, 2], + "bit_depth": "10bit" + } + } + } + } + } + ] + } + """.trimIndent(), + addonName = "Direct Debrid", + addonId = "debrid:torbox", + ) + + val stream = streams.single() + assertTrue(stream.isDirectDebridStream) + assertFalse(stream.isTorrentStream) + assertEquals("abc123", stream.clientResolve?.infoHash) + assertEquals(4, stream.clientResolve?.fileIdx) + assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single()) + assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) + assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt new file mode 100644 index 00000000..2454cb43 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -0,0 +1,98 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import platform.Foundation.NSUserDefaults + +actual object DebridSettingsStorage { + private const val enabledKey = "debrid_enabled" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val syncKeys = listOf( + enabledKey, + torboxApiKeyKey, + realDebridApiKeyKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.boolForKey(scopedKey) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) + } + + private fun loadString(key: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key)) + + private fun saveString(key: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key)) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} + diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index b89cbc9c..c8d1abd7 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=61 -MARKETING_VERSION=0.1.19 +MARKETING_VERSION=0.1.0 From b35010fd4b7710391f22b3c380ebb86b4358da49 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 03:23:57 +0530 Subject: [PATCH 2/3] ref: improve file detection --- .../debrid/DebridSettingsStorage.android.kt | 30 ++- .../composeResources/values/strings.xml | 6 + .../features/debrid/DebridFileSelectors.kt | 107 ++++++--- .../app/features/debrid/DebridSettings.kt | 10 +- .../debrid/DebridSettingsRepository.kt | 14 ++ .../features/debrid/DebridSettingsStorage.kt | 3 +- .../features/debrid/DebridStreamFormatter.kt | 7 +- .../features/debrid/DirectDebridResolver.kt | 207 ++++++++++++++++-- .../debrid/DirectDebridStreamPreparer.kt | 196 +++++++++++++++++ .../debrid/DirectDebridStreamSource.kt | 1 - .../player/PlayerStreamsRepository.kt | 27 +++ .../features/settings/DebridSettingsPage.kt | 149 +++++++++++++ .../app/features/streams/StreamParser.kt | 8 +- .../app/features/streams/StreamsRepository.kt | 24 ++ .../features/debrid/DebridFileSelectorTest.kt | 75 ++++++- .../debrid/DebridStreamFormatterTest.kt | 122 +++++++++++ .../debrid/DirectDebridStreamPreparerTest.kt | 70 ++++++ .../debrid/DebridSettingsStorage.ios.kt | 27 ++- 18 files changed, 1016 insertions(+), 67 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 4f20f2d8..2ae1bccc 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -4,8 +4,10 @@ import android.content.Context import android.content.SharedPreferences import com.nuvio.app.core.storage.ProfileScopedKey import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt import com.nuvio.app.core.sync.decodeSyncString import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt import com.nuvio.app.core.sync.encodeSyncString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject @@ -16,12 +18,14 @@ actual object DebridSettingsStorage { private const val enabledKey = "debrid_enabled" private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" private val syncKeys = listOf( enabledKey, torboxApiKeyKey, realDebridApiKeyKey, + instantPlaybackPreparationLimitKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -50,6 +54,12 @@ actual object DebridSettingsStorage { saveString(realDebridApiKeyKey, apiKey) } + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) actual fun saveStreamNameTemplate(template: String) { @@ -79,6 +89,23 @@ actual object DebridSettingsStorage { ?.apply() } + private fun loadInt(key: String): Int? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getInt(scopedKey, 0) + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + preferences + ?.edit() + ?.putInt(ProfileScopedKey.of(key), value) + ?.apply() + } + private fun loadString(key: String): String? = preferences?.getString(ProfileScopedKey.of(key), null) @@ -93,6 +120,7 @@ actual object DebridSettingsStorage { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } } @@ -105,8 +133,8 @@ actual object DebridSettingsStorage { payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } } - diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index f6f0e681..d7e44118 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -582,6 +582,12 @@ Add an API key before enabling Debrid streams. Account Use Torbox for instant cached streams. + Instant Playback + Prepare instant playback + Get Torbox streams ready before you press play. + Streams to prepare + 1 stream + %d streams Stream Formatting Name template Controls how Debrid stream names appear in source lists. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt index fe427ba0..0718df7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -12,30 +12,38 @@ internal class TorboxFileSelector { val playable = files.filter { it.isPlayableVideo() } if (playable.isEmpty()) return null - resolve.fileIdx?.let { fileIdx -> - playable.firstOrNull { it.id == fileIdx }?.let { return it } - files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it } - } - - val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName) - .map { it.normalizedName() } - .filter { it.isNotBlank() } - if (names.isNotEmpty()) { - playable.firstOrNull { file -> - val fileName = file.displayName().normalizedName() - names.any { name -> fileName.contains(name) || name.contains(fileName) } - }?.let { return it } - } - val episodePatterns = buildEpisodePatterns( season = season ?: resolve.season, episode = episode ?: resolve.episode, ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + if (episodePatterns.isNotEmpty()) { playable.firstOrNull { file -> val fileName = file.displayName().lowercase() episodePatterns.any { pattern -> fileName.contains(pattern) } - }?.let { return it } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } } return playable.maxByOrNull { it.size ?: 0L } @@ -58,30 +66,38 @@ internal class RealDebridFileSelector { val playable = files.filter { it.isPlayableVideo() } if (playable.isEmpty()) return null - resolve.fileIdx?.let { fileIdx -> - playable.firstOrNull { it.id == fileIdx }?.let { return it } - files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { return it } - } - - val names = listOfNotNull(resolve.filename, resolve.title, resolve.torrentName) - .map { it.normalizedName() } - .filter { it.isNotBlank() } - if (names.isNotEmpty()) { - playable.firstOrNull { file -> - val fileName = file.displayName().normalizedName() - names.any { name -> fileName.contains(name) || name.contains(fileName) } - }?.let { return it } - } - val episodePatterns = buildEpisodePatterns( season = season ?: resolve.season, episode = episode ?: resolve.episode, ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + if (episodePatterns.isNotEmpty()) { playable.firstOrNull { file -> val fileName = file.displayName().lowercase() episodePatterns.any { pattern -> fileName.contains(pattern) } - }?.let { return it } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } } return playable.maxByOrNull { it.bytes ?: 0L } @@ -98,6 +114,33 @@ private fun String.normalizedName(): String = .replace(Regex("[^a-z0-9]+"), " ") .trim() +private fun StreamClientResolve.specificFileNames(episodePatterns: List): List { + val raw = stream?.raw + return listOfNotNull( + filename, + raw?.filename, + raw?.parsed?.rawTitle?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + torrentName?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + ) + .map { it.normalizedName() } + .filter { it.isNotBlank() } + .distinct() +} + +private fun String.looksSpecificForSelection(episodePatterns: List): Boolean { + val lower = lowercase() + return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) } +} + +private fun List.firstNameMatch( + names: List, + displayName: (T) -> String, +): T? = + firstOrNull { item -> + val fileName = displayName(item).normalizedName() + names.any { name -> fileName.contains(name) || name.contains(fileName) } + } + private fun buildEpisodePatterns(season: Int?, episode: Int?): List { if (season == null || episode == null) return emptyList() val seasonTwo = season.toString().padStart(2, '0') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index 75ee04c8..b2d40b0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -4,7 +4,7 @@ data class DebridSettings( val enabled: Boolean = false, val torboxApiKey: String = "", val realDebridApiKey: String = "", - val preResolveLimit: Int = 0, + val instantPlaybackPreparationLimit: Int = 0, val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, ) { @@ -12,8 +12,8 @@ data class DebridSettings( get() = DebridProviders.configuredServices(this).isNotEmpty() } -internal const val DEBRID_PRE_RESOLVE_DEFAULT_LIMIT = 2 -internal const val DEBRID_PRE_RESOLVE_MAX_LIMIT = 5 +internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 -internal fun normalizeDebridPreResolveLimit(value: Int): Int = - value.coerceIn(0, DEBRID_PRE_RESOLVE_MAX_LIMIT) +internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = + value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 6eba755a..17938a41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -12,6 +12,7 @@ object DebridSettingsRepository { private var enabled = false private var torboxApiKey = "" private var realDebridApiKey = "" + private var instantPlaybackPreparationLimit = 0 private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE @@ -58,6 +59,15 @@ object DebridSettingsRepository { DebridSettingsStorage.saveRealDebridApiKey(normalized) } + fun setInstantPlaybackPreparationLimit(value: Int) { + ensureLoaded() + val normalized = normalizeDebridInstantPlaybackPreparationLimit(value) + if (instantPlaybackPreparationLimit == normalized) return + instantPlaybackPreparationLimit = normalized + publish() + DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized) + } + fun setStreamNameTemplate(value: String) { ensureLoaded() val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } @@ -101,6 +111,9 @@ object DebridSettingsRepository { torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty() realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty() enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( + DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, + ) streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() ?.takeIf { it.isNotBlank() } ?: DebridStreamFormatterDefaults.NAME_TEMPLATE @@ -115,6 +128,7 @@ object DebridSettingsRepository { enabled = enabled, torboxApiKey = torboxApiKey, realDebridApiKey = realDebridApiKey, + instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 641968ed..6c4f238f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -9,6 +9,8 @@ internal expect object DebridSettingsStorage { fun saveTorboxApiKey(apiKey: String) fun loadRealDebridApiKey(): String? fun saveRealDebridApiKey(apiKey: String) + fun loadInstantPlaybackPreparationLimit(): Int? + fun saveInstantPlaybackPreparationLimit(limit: Int) fun loadStreamNameTemplate(): String? fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? @@ -16,4 +18,3 @@ internal expect object DebridSettingsStorage { fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index 29829ee9..99057f3b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -32,10 +32,10 @@ class DebridStreamFormatter( val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed - val season = resolve?.season - val episode = resolve?.episode val seasons = parsed?.seasons.orEmpty() val episodes = parsed?.episodes.orEmpty() + val season = resolve?.season ?: seasons.singleOrFirstOrNull() + val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() val visualTags = buildList { addAll(parsed?.hdr.orEmpty()) parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } @@ -119,6 +119,9 @@ class DebridStreamFormatter( private fun formatSeasons(seasons: List): String = seasons.joinToString(" | ") { "S${it.twoDigits()}" } + private fun List.singleOrFirstOrNull(): Int? = + singleOrNull() ?: firstOrNull() + private fun Int.twoDigits(): String = toString().padStart(2, '0') private fun languageEmoji(language: String): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt index 86b33ef7..6b8e3425 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -3,8 +3,17 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.debrid_missing_api_key import nuvio.composeapp.generated.resources.debrid_resolve_failed @@ -14,8 +23,88 @@ import org.jetbrains.compose.resources.getString object DirectDebridPlaybackResolver { private val torboxResolver = TorboxDirectDebridResolver() private val realDebridResolver = RealDebridDirectDebridResolver() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val resolvedCache = mutableMapOf() + private val inFlightResolves = mutableMapOf>() - suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) + if (cacheKey == null) { + return resolveUncached(stream, season, episode) + } + getCachedResult(cacheKey)?.let { + return it + } + + var ownsResolve = false + val newResolve = scope.async(start = CoroutineStart.LAZY) { + resolveUncached(stream, season, episode) + } + val activeResolve = mutex.withLock { + getCachedResultLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightResolves[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightResolves[cacheKey] = newResolve + ownsResolve = true + newResolve to null + } + } + activeResolve.second?.let { + newResolve.cancel() + return it + } + val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error + if (!ownsResolve) newResolve.cancel() + if (ownsResolve) deferred.start() + + return try { + val result = deferred.await() + if (ownsResolve && result is DirectDebridResolveResult.Success) { + mutex.withLock { + resolvedCache[cacheKey] = CachedDirectDebridResolve( + result = result, + cachedAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsResolve) { + mutex.withLock { + if (inFlightResolves[cacheKey] === deferred) { + inFlightResolves.remove(cacheKey) + } + } + } + } + } + + suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null + return getCachedResult(cacheKey) + ?.let { result -> stream.withResolvedDebridUrl(result) } + } + + private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? = + mutex.withLock { getCachedResultLocked(cacheKey) } + + private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? { + val cached = resolvedCache[cacheKey] ?: return null + val age = epochMs() - cached.cachedAtMs + return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) { + cached.result + } else { + resolvedCache.remove(cacheKey) + null + } + } + + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = when (DebridProviders.byId(stream.clientResolve?.service)?.id) { DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) @@ -27,7 +116,9 @@ object DirectDebridPlaybackResolver { season: Int?, episode: Int?, ): DirectDebridPlayableResult { - if (!stream.isDirectDebridStream) return DirectDebridPlayableResult.Success(stream) + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) { + return DirectDebridPlayableResult.Success(stream) + } return when (val result = resolve(stream, season, episode)) { is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey @@ -37,6 +128,13 @@ object DirectDebridPlaybackResolver { } } +private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L + +private data class CachedDirectDebridResolve( + val result: DirectDebridResolveResult.Success, + val cachedAtMs: Long, +) + sealed class DirectDebridPlayableResult { data class Success(val stream: StreamItem) : DirectDebridPlayableResult() data object MissingApiKey : DirectDebridPlayableResult() @@ -70,10 +168,14 @@ private class TorboxDirectDebridResolver( suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() - if (apiKey.isBlank()) return DirectDebridResolveResult.MissingApiKey + if (apiKey.isBlank()) { + return DirectDebridResolveResult.MissingApiKey + } val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } ?: buildMagnetUri(resolve) - ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } return try { val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) @@ -81,20 +183,31 @@ private class TorboxDirectDebridResolver( ?: return create.toFailureForCreate() val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) - if (!torrent.isSuccessful) return DirectDebridResolveResult.Stale + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } val files = torrent.body?.data?.files.orEmpty() val file = fileSelector.selectFile(files, resolve, season, episode) - ?: return DirectDebridResolveResult.Stale - val fileId = file.id ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } + val fileId = file.id + ?: run { + return DirectDebridResolveResult.Stale + } val link = TorboxApiClient.requestDownloadLink( apiKey = apiKey, torrentId = torrentId, fileId = fileId, ) - if (!link.isSuccessful) return DirectDebridResolveResult.Stale + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } val url = link.body?.data?.takeIf { it.isNotBlank() } - ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } DirectDebridResolveResult.Success( url = url, @@ -120,10 +233,14 @@ private class RealDebridDirectDebridResolver( suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim() - if (apiKey.isBlank()) return DirectDebridResolveResult.MissingApiKey + if (apiKey.isBlank()) { + return DirectDebridResolveResult.MissingApiKey + } val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } ?: buildMagnetUri(resolve) - ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } return try { val add = RealDebridApiClient.addMagnet(apiKey, magnet) @@ -132,25 +249,44 @@ private class RealDebridDirectDebridResolver( var resolved = false try { val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) - if (!infoBefore.isSuccessful) return DirectDebridResolveResult.Stale + if (!infoBefore.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val filesBefore = infoBefore.body?.files.orEmpty() val file = fileSelector.selectFile( - files = infoBefore.body?.files.orEmpty(), + files = filesBefore, resolve = resolve, season = season, episode = episode, - ) ?: return DirectDebridResolveResult.Stale - val fileId = file.id ?: return DirectDebridResolveResult.Stale + ) + ?: run { + return DirectDebridResolveResult.Stale + } + val fileId = file.id + ?: run { + return DirectDebridResolveResult.Stale + } val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) - if (!select.isSuccessful && select.status != 202) return DirectDebridResolveResult.Stale + if (!select.isSuccessful && select.status != 202) { + return DirectDebridResolveResult.Stale + } val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) - if (!infoAfter.isSuccessful) return DirectDebridResolveResult.Stale + if (!infoAfter.isSuccessful) { + return DirectDebridResolveResult.Stale + } val link = infoAfter.body?.firstDownloadLink() - ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) - if (!unrestrict.isSuccessful) return DirectDebridResolveResult.Stale + if (!unrestrict.isSuccessful) { + return DirectDebridResolveResult.Stale + } val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } - ?: return DirectDebridResolveResult.Stale + ?: run { + return DirectDebridResolveResult.Stale + } resolved = true DirectDebridResolveResult.Success( url = url, @@ -195,6 +331,36 @@ private fun buildMagnetUri(resolve: StreamClientResolve): String? { } } +private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? { + val resolve = clientResolve ?: return null + val providerId = DebridProviders.byId(resolve.service)?.id ?: return null + val apiKey = when (providerId) { + DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey + DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey + else -> "" + }.trim().takeIf { it.isNotBlank() } ?: return null + val identity = resolve.infoHash + ?: resolve.magnetUri + ?: resolve.torrentName + ?: resolve.filename + ?: return null + + return listOf( + providerId, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + (resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(), + (season ?: resolve.season)?.toString().orEmpty(), + (episode ?: resolve.episode)?.toString().orEmpty(), + ).joinToString("|") +} + +private fun String.stableFingerprint(): String { + val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code } + return hash.toULong().toString(16) +} + private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem = copy( url = result.url, @@ -207,4 +373,3 @@ private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridRes filename = result.filename ?: filename, videoSize = result.videoSize ?: videoSize, ) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt new file mode 100644 index 00000000..61952674 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object DirectDebridStreamPreparer { + private val log = Logger.withTag("DirectDebridPreparer") + private val budgetMutex = Mutex() + private val minuteStarts = ArrayDeque() + private val hourStarts = ArrayDeque() + + suspend fun prepare( + streams: List, + season: Int?, + episode: Int?, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + onPrepared: (original: StreamItem, prepared: StreamItem) -> Unit, + ) { + val settings = DebridSettingsRepository.snapshot() + val limit = settings.instantPlaybackPreparationLimit + if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return + + val candidates = prioritizeCandidates( + streams = streams, + limit = limit, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) + for (stream in candidates) { + DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached -> + onPrepared(stream, cached) + continue + } + + if (!consumeBackgroundBudget()) { + log.d { "Skipping instant playback preparation; local Torbox budget reached" } + return + } + + try { + when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { + is DirectDebridPlayableResult.Success -> { + if (result.stream.directPlaybackUrl != null) { + onPrepared(stream, result.stream) + } + } + else -> Unit + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + log.d(error) { "Instant playback preparation failed" } + } + } + } + + internal fun prioritizeCandidates( + streams: List, + limit: Int, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + ): List { + if (limit <= 0) return emptyList() + val candidates = streams + .filter { it.isDirectDebridStream && it.directPlaybackUrl == null } + .distinctBy { it.preparationKey() } + if (candidates.isEmpty()) return emptyList() + + val prioritized = mutableListOf() + val autoPlaySelection = StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = playerSettings.streamAutoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + ) + if (autoPlaySelection?.isDirectDebridStream == true) { + candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } + ?.let(prioritized::add) + } + + if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { + val regex = runCatching { + Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE) + }.getOrNull() + if (regex != null) { + candidates + .filter { candidate -> + prioritized.none { it.preparationKey() == candidate.preparationKey() } && + regex.containsMatchIn(candidate.searchableText()) + } + .forEach(prioritized::add) + } + } + + candidates + .filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } } + .forEach(prioritized::add) + + return prioritized.take(limit) + } + + fun replacePreparedStream( + groups: List, + original: StreamItem, + prepared: StreamItem, + ): List { + val key = original.preparationKey() + return groups.map { group -> + var changed = false + val updatedStreams = group.streams.map { stream -> + if (stream.preparationKey() == key) { + changed = true + prepared.copy( + addonName = stream.addonName, + addonId = stream.addonId, + sourceName = stream.sourceName, + ) + } else { + stream + } + } + if (changed) group.copy(streams = updatedStreams) else group + } + } + + private suspend fun consumeBackgroundBudget(): Boolean { + val now = epochMs() + return budgetMutex.withLock { + minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS) + hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS) + if ( + minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE || + hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR + ) { + false + } else { + minuteStarts.addLast(now) + hourStarts.addLast(now) + true + } + } + } +} + +private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6 +private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30 +private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L +private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L + +private fun ArrayDeque.removeOlderThan(cutoffMs: Long) { + while (firstOrNull()?.let { it < cutoffMs } == true) { + removeFirst() + } +} + +private fun StreamItem.preparationKey(): String { + val resolve = clientResolve + if (resolve != null) { + return listOf( + resolve.service.orEmpty().lowercase(), + resolve.infoHash.orEmpty().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + resolve.filename.orEmpty().lowercase(), + resolve.torrentName.orEmpty().lowercase(), + resolve.magnetUri.orEmpty().lowercase(), + ).joinToString("|") + } + + return listOf( + addonId.lowercase(), + directPlaybackUrl.orEmpty().lowercase(), + name.orEmpty().lowercase(), + title.orEmpty().lowercase(), + ).joinToString("|") +} + +private fun StreamItem.searchableText(): String = + buildString { + append(addonName).append(' ') + append(name.orEmpty()).append(' ') + append(title.orEmpty()).append(' ') + append(description.orEmpty()).append(' ') + append(directPlaybackUrl.orEmpty()) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt index 9c3ee933..7cd0e03a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -94,4 +94,3 @@ object DirectDebridStreamSource { isLoading = false, ) } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index b05539b7..6e9487ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -153,6 +154,9 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() + PlayerSettingsRepository.ensureLoaded() + val playerSettings = PlayerSettingsRepository.uiState.value val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() @@ -295,6 +299,7 @@ object PlayerStreamsRepository { } val jobs = addonJobs + pluginJobs + debridJobs + var debridPreparationLaunched = false jobs.forEach { deferred -> val result = deferred.await() stateFlow.update { current -> @@ -312,6 +317,28 @@ object PlayerStreamsRepository { } else null, ) } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } } } setJob(job) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 3307a322..e0880503 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -1,15 +1,25 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -18,9 +28,11 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings @@ -35,11 +47,17 @@ import nuvio.composeapp.generated.resources.settings_debrid_description_template import nuvio.composeapp.generated.resources.settings_debrid_description_template_description import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count import nuvio.composeapp.generated.resources.settings_debrid_key_valid import nuvio.composeapp.generated.resources.settings_debrid_key_invalid import nuvio.composeapp.generated.resources.settings_debrid_name_template import nuvio.composeapp.generated.resources.settings_debrid_name_template_description import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description +import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting import nuvio.composeapp.generated.resources.settings_debrid_section_providers import nuvio.composeapp.generated.resources.settings_debrid_section_title @@ -92,6 +110,52 @@ internal fun LazyListScope.debridSettingsContent( } } + item { + var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) } + val prepareLimit = settings.instantPlaybackPreparationLimit + val prepareEnabled = settings.enabled && prepareLimit > 0 + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_instant_playback), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_prepare_instant_playback), + description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description), + checked = prepareEnabled, + enabled = settings.enabled && settings.hasAnyApiKey, + isTablet = isTablet, + onCheckedChange = { enabled -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit( + if (enabled) DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT else 0, + ) + }, + ) + if (prepareEnabled) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_debrid_prepare_stream_count), + description = prepareCountLabel(prepareLimit), + isTablet = isTablet, + onClick = { showPrepareCountDialog = true }, + ) + } + } + } + + if (showPrepareCountDialog) { + DebridPrepareCountDialog( + selectedLimit = prepareLimit, + onLimitSelected = { limit -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit(limit) + showPrepareCountDialog = false + }, + onDismiss = { showPrepareCountDialog = false }, + ) + } + } + item { SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), @@ -130,6 +194,91 @@ internal fun LazyListScope.debridSettingsContent( } } +@Composable +private fun prepareCountLabel(limit: Int): String = + if (limit == 1) { + stringResource(Res.string.settings_debrid_prepare_count_one) + } else { + stringResource(Res.string.settings_debrid_prepare_count_many, limit) + } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridPrepareCountDialog( + selectedLimit: Int, + onLimitSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf(1, 2, 3, 5) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { limit -> + val isSelected = limit == selectedLimit + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onLimitSelected(limit) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = prepareCountLabel(limit), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} + @Composable private fun DebridApiKeyRow( isTablet: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 7c186f4a..9a6aa866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -63,10 +63,14 @@ object StreamParser { this[name]?.jsonPrimitive?.contentOrNull private fun JsonObject.int(name: String): Int? = - this[name]?.jsonPrimitive?.intOrNull + this[name]?.jsonPrimitive?.let { primitive -> + primitive.intOrNull ?: primitive.contentOrNull?.toIntOrNull() + } private fun JsonObject.long(name: String): Long? = - this[name]?.jsonPrimitive?.longOrNull + this[name]?.jsonPrimitive?.let { primitive -> + primitive.longOrNull ?: primitive.contentOrNull?.toLongOrNull() + } private fun JsonObject.boolean(name: String): Boolean? = this[name]?.jsonPrimitive?.booleanOrNull diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index b5b4ee8a..1f7d42e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -5,6 +5,7 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository @@ -230,6 +231,7 @@ object StreamsRepository { .toSet() var autoSelectTriggered = false var timeoutElapsed = false + var debridPreparationLaunched = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } @@ -445,6 +447,28 @@ object StreamsRepository { emptyStateReason = updated.toEmptyStateReason(anyLoading), ) } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + _uiState.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } } } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt index 86132213..ad4f9eab 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -22,6 +22,76 @@ class DebridFileSelectorTest { assertEquals(8, selected?.id) } + @Test + fun `Torbox selector prefers filename match before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto( + id = 85, + name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + size = 5_303_936_915, + ), + TorboxTorrentFileDto( + id = 1, + name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv", + size = 5_859_312_140, + ), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + fileIdx = 1, + season = 1, + episode = 1, + filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector treats fileIdx as source list index before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500), + TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 1), + season = null, + episode = null, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector uses episode pattern before broad title`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900), + TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + season = 1, + episode = 1, + title = "The Office", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + @Test fun `Torbox selector falls back to largest playable video`() { val files = listOf( @@ -61,6 +131,8 @@ class DebridFileSelectorTest { fileIdx: Int? = null, season: Int? = null, episode: Int? = null, + filename: String? = null, + title: String? = null, ): StreamClientResolve = StreamClientResolve( type = "debrid", @@ -68,8 +140,9 @@ class DebridFileSelectorTest { isCached = true, infoHash = "hash", fileIdx = fileIdx, + filename = filename, + title = title, season = season, episode = episode, ) } - diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt new file mode 100644 index 00000000..83b127cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt @@ -0,0 +1,122 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamParser +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class DebridStreamFormatterTest { + private val formatter = DebridStreamFormatter() + + @Test + fun `formats real client stream episode fields and behavior size`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertEquals(0, stream.clientResolve?.fileIdx) + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + assertFalse(description.contains("6761331156")) + } + + @Test + fun `formats season episode from parsed fields when top level resolve omits them`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(includeTopLevelSeasonEpisode = false), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + } + + private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String { + val seasonEpisode = if (includeTopLevelSeasonEpisode) { + """ + "season": 5, + "episode": 2, + """.trimIndent() + } else { + "" + } + return """ + { + "streams": [ + { + "name": "TB 2160p cached", + "description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "clientResolve": { + "type": "debrid", + "service": "torbox", + "isCached": true, + "infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4", + "sources": [], + "fileIdx": "0", + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "title": "The Boys", + "torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + $seasonEpisode + "stream": { + "raw": { + "parsed": { + "resolution": "2160p", + "quality": "WEB-DL", + "codec": "hevc", + "audio": ["Atmos", "Dolby Digital Plus"], + "channels": ["5.1"], + "hdr": ["DV", "HDR10+"], + "group": "Kitsune", + "seasons": [5], + "episodes": [2], + "raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv" + } + } + } + }, + "behaviorHints": { + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "videoSize": 6761331156 + } + } + ] + } + """.trimIndent() + } + + private companion object { + private const val CLIENT_TEMPLATE = + "{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" + + "{stream.season::>=0[\"🍂 S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" + + "{stream.quality::exists[\"🎥 {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"📺 {stream.visualTags::join(' | ')} \"||\"\"]}\n" + + "{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"🔊 {stream.audioChannels::join(' | ')}\"||\"\"]}\n" + + "{stream.size::>0[\"📦 {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"📡{stream.indexer}\"||\"\"]}\n" + + "{service.cached::istrue[\"⚡Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}🔍{addon.name}" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt new file mode 100644 index 00000000..68acd752 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridStreamPreparerTest { + + @Test + fun `prioritizes autoplay direct debrid match before display order`() { + val first = directDebridStream(name = "1080p", infoHash = "hash-1") + val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2") + val remaining = directDebridStream(name = "720p", infoHash = "hash-3") + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(first, autoPlayMatch, remaining), + limit = 2, + playerSettings = PlayerSettingsUiState( + streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH, + streamAutoPlayRegex = "2160p", + ), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(autoPlayMatch, first), selected) + } + + @Test + fun `skips already resolved and duplicate direct debrid candidates`() { + val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1") + val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1") + val alreadyResolved = directDebridStream( + name = "2160p", + infoHash = "hash-2", + url = "https://example.com/ready.mp4", + ) + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(unresolved, duplicate, alreadyResolved), + limit = 5, + playerSettings = PlayerSettingsUiState(), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(unresolved), selected) + } + + private fun directDebridStream( + name: String, + infoHash: String, + url: String? = null, + ): StreamItem = + StreamItem( + name = name, + url = url, + addonName = "Torbox Instant", + addonId = "debrid:torbox", + clientResolve = StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = infoHash, + fileIdx = 1, + filename = "video.mkv", + ), + ) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index 2454cb43..0ac46039 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -2,8 +2,10 @@ package com.nuvio.app.features.debrid import com.nuvio.app.core.storage.ProfileScopedKey import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt import com.nuvio.app.core.sync.decodeSyncString import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt import com.nuvio.app.core.sync.encodeSyncString import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject @@ -14,12 +16,14 @@ actual object DebridSettingsStorage { private const val enabledKey = "debrid_enabled" private const val torboxApiKeyKey = "debrid_torbox_api_key" private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" private val syncKeys = listOf( enabledKey, torboxApiKeyKey, realDebridApiKeyKey, + instantPlaybackPreparationLimitKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -42,6 +46,12 @@ actual object DebridSettingsStorage { saveString(realDebridApiKeyKey, apiKey) } + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) actual fun saveStreamNameTemplate(template: String) { @@ -68,6 +78,20 @@ actual object DebridSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) } + private fun loadInt(key: String): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.integerForKey(scopedKey).toInt() + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key)) + } + private fun loadString(key: String): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key)) @@ -79,6 +103,7 @@ actual object DebridSettingsStorage { loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } } @@ -91,8 +116,8 @@ actual object DebridSettingsStorage { payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } } - From b217d9c4a63a2473abff05b645f6b0aa220ec54a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 03:43:10 +0530 Subject: [PATCH 3/3] ref: adjusting formatters --- .../composeResources/values-cs/strings.xml | 2 +- .../composeResources/values-id/strings.xml | 2 +- .../composeResources/values/strings.xml | 30 +++++++++---------- .../features/debrid/DebridStreamFormatter.kt | 4 +-- .../debrid/DebridStreamTemplateEngine.kt | 6 +++- .../debrid/DebridStreamTemplateEngineTest.kt | 11 ++++++- 6 files changed, 34 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml index d1c9be28..ca8498e3 100644 --- a/composeApp/src/commonMain/composeResources/values-cs/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -1068,7 +1068,7 @@ Pokračovat od %1$d%% Pokračovat od %1$s VELIKOST %1$s - Torrent streamy nejsou podporovány + Tento typ streamu není podporován Zavřít trailer Trailer nelze přehrát Nepodařilo se načíst seznamy Trakt diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 6f21bf11..80539baa 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -1100,7 +1100,7 @@ Lanjutkan dari %1$d% Lanjutkan dari %1$s UKURAN %1$s - Stream torrent tidak didukung + Jenis stream ini tidak didukung Tutup trailer Tidak dapat memutar trailer Gagal memuat daftar Trakt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d7e44118..e24d703f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -575,24 +575,24 @@ Integrations Metadata enrichment controls External ratings providers - Instant cached Debrid streams - Direct Debrid - Enable Debrid streams - Show instant cached Debrid streams. - Add an API key before enabling Debrid streams. + Cloud account sources + Debrid + Enable sources + Show playable results from connected accounts. + Add an API key first. Account - Use Torbox for instant cached streams. + Connect your Torbox account. Instant Playback - Prepare instant playback - Get Torbox streams ready before you press play. - Streams to prepare - 1 stream - %d streams - Stream Formatting + Prepare links + Resolve the first sources before playback starts. + Sources to prepare + 1 source + %1$d sources + Formatting Name template - Controls how Debrid stream names appear in source lists. + Controls how source names appear. Description template - Controls the metadata lines shown under each Debrid stream. + Controls the metadata shown under each source. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. @@ -1129,7 +1129,7 @@ Resume from %1$d% Resume from %1$s SIZE %1$s - Torrent streams are not supported + This stream type is not supported Add a Debrid API key in Settings. This Debrid result expired. Refreshing streams. Could not resolve this Debrid stream. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index 99057f3b..dd73d303 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -60,8 +60,8 @@ class DebridStreamFormatter( "stream.audioChannels" to parsed?.channels.orEmpty(), "stream.languages" to parsed?.languages.orEmpty(), "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, - "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize), - "stream.folderSize" to raw?.folderSize, + "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes), + "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), "stream.encode" to parsed?.codec?.uppercase(), "stream.indexer" to (raw?.indexer ?: raw?.tracker), "stream.network" to (parsed?.network ?: raw?.network), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt index 0e3ad2c1..23e635e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.debrid import kotlin.math.abs import kotlin.math.roundToLong +internal data class DebridTemplateBytes(val value: Long) + class DebridStreamTemplateEngine { fun render(template: String, values: Map): String { if (template.isEmpty()) return "" @@ -299,6 +301,7 @@ class DebridStreamTemplateEngine { private fun isTruthy(value: Any?): Boolean = when (value) { is Boolean -> value + is DebridTemplateBytes -> value.value != 0L is Number -> value.toDouble() != 0.0 else -> exists(value) } @@ -313,6 +316,7 @@ class DebridStreamTemplateEngine { private fun asNumber(value: Any?): Double? = when (value) { is Number -> value.toDouble() + is DebridTemplateBytes -> value.value.toDouble() is String -> value.toDoubleOrNull() else -> null } @@ -339,6 +343,7 @@ class DebridStreamTemplateEngine { when (value) { null -> "" is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is DebridTemplateBytes -> formatBytes(value.value.toDouble()) is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() else -> value.toString() @@ -387,4 +392,3 @@ class DebridStreamTemplateEngine { } } } - diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt index b9fd86f9..7a670339 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt @@ -32,5 +32,14 @@ class DebridStreamTemplateEngineTest { assertEquals("1.5 GB DTS | Atmos", rendered) } -} + @Test + fun `renders Debrid size values as readable text while keeping numeric comparisons`() { + val rendered = engine.render( + "{stream.size::>0[\"{stream.size}\"||\"\"]}", + mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)), + ) + + assertEquals("6.9 GB", rendered) + } +}