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)
+ }
+}