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 2c8bebbb..94036653 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
@@ -74,6 +75,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..d1ff44e5
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
@@ -0,0 +1,210 @@
+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.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
+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 instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
+ private const val streamMaxResultsKey = "debrid_stream_max_results"
+ private const val streamSortModeKey = "debrid_stream_sort_mode"
+ private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
+ private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
+ private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
+ private const val streamCodecFilterKey = "debrid_stream_codec_filter"
+ private const val streamPreferencesKey = "debrid_stream_preferences"
+ private const val streamNameTemplateKey = "debrid_stream_name_template"
+ private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
+ private val syncKeys = listOf(
+ enabledKey,
+ torboxApiKeyKey,
+ realDebridApiKeyKey,
+ instantPlaybackPreparationLimitKey,
+ streamMaxResultsKey,
+ streamSortModeKey,
+ streamMinimumQualityKey,
+ streamDolbyVisionFilterKey,
+ streamHdrFilterKey,
+ streamCodecFilterKey,
+ streamPreferencesKey,
+ 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 loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
+
+ actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
+ saveInt(instantPlaybackPreparationLimitKey, limit)
+ }
+
+ actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
+
+ actual fun saveStreamMaxResults(maxResults: Int) {
+ saveInt(streamMaxResultsKey, maxResults)
+ }
+
+ actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
+
+ actual fun saveStreamSortMode(mode: String) {
+ saveString(streamSortModeKey, mode)
+ }
+
+ actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
+
+ actual fun saveStreamMinimumQuality(quality: String) {
+ saveString(streamMinimumQualityKey, quality)
+ }
+
+ actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
+
+ actual fun saveStreamDolbyVisionFilter(filter: String) {
+ saveString(streamDolbyVisionFilterKey, filter)
+ }
+
+ actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
+
+ actual fun saveStreamHdrFilter(filter: String) {
+ saveString(streamHdrFilterKey, filter)
+ }
+
+ actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
+
+ actual fun saveStreamCodecFilter(filter: String) {
+ saveString(streamCodecFilterKey, filter)
+ }
+
+ actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
+
+ actual fun saveStreamPreferences(preferences: String) {
+ saveString(streamPreferencesKey, preferences)
+ }
+
+ 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 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)
+
+ 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)) }
+ loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
+ loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
+ loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
+ loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
+ loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
+ loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
+ loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
+ loadStreamPreferences()?.let { put(streamPreferencesKey, 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.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
+ payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
+ payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
+ payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
+ payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
+ payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
+ payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
+ payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
+ payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
+ payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
+ }
+}
diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml
index 89e10815..06b43610 100644
--- a/composeApp/src/commonMain/composeResources/values-no/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml
@@ -379,6 +379,7 @@
Utseende
Innhold & oppdagelse
Fortsett å se
+ Debrid
Hjemmeoppsett
Integrasjoner
Lisenser & attribusjon
@@ -587,6 +588,27 @@
Integrasjoner
Metadata-berikelse-kontroller
Eksterne vurderingsleverandører
+ Eksperimentelle sky-konto-kilder
+ Debrid
+ Debrid-støtte er eksperimentell og kan endres eller fjernes senere.
+ Aktiver kilder
+ Vis spillbare resultater fra tilkoblede kontoer.
+ Legg til en API-nøkkel først.
+ Konto
+ Koble til Torbox-kontoen din.
+ Umiddelbar avspilling
+ Forbered lenker
+ Løs første kilder før avspilling starter.
+ Kilder å forberede
+ 1 kilde
+ %1$d kilder
+ Formatering
+ Navnemal
+ Styrer hvordan kildenavn vises.
+ Beskrivelsesmal
+ Styrer metadata vist under hver kilde.
+ API-nøkkel validert.
+ Kunne ikke validere denne API-nøkkelen.
Legg til MDBList API-nøkkel før du skrur på vurderinger.
Kreves for å hente vurderinger fra MDBList
API-nøkkel
@@ -1122,6 +1144,9 @@
Gjenoppta fra %1$s
STØRRELSE %1$s
Denne strømtypen støttes ikke
+ Legg til en Debrid API-nøkkel i Innstillinger.
+ Dette Debrid-resultatet er utgått. Oppdaterer strømmer.
+ Kunne ikke løse denne Debrid-strømmen.
Kunne ikke åpne ekstern avspiller
Velg en ekstern avspiller i innstillinger først
Ingen ekstern avspiller er tilgjengelig
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index 3fa135b3..330c2cd6 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -380,6 +380,7 @@
Wygląd
Treści i odkrywanie
Kontynuuj oglądanie
+ Debrid
Ekran główny
Integracje
Licencje i atrybucje
@@ -588,6 +589,34 @@
INTEGRACJE
Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.
Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.
+ Eksperymentalne źródła z kont chmurowych
+ Debrid
+ Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.
+ Włącz źródła
+ Pokaż odtwarzalne wyniki z połączonych kont.
+ Najpierw dodaj klucz API.
+ Konto
+ Połącz swoje konto Torbox.
+ Klucz API Torbox
+ Wprowadź swój klucz API Torbox.
+ Wprowadź klucz API Torbox
+ Nie ustawiono
+ Natychmiastowe odtwarzanie
+ Przygotuj linki
+ Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.
+ Źródła do przygotowania
+ Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem.
+ 1 źródło
+ %1$d źródeł
+ Formatowanie
+ Szablon nazwy
+ Kontroluje sposób wyświetlania nazw źródeł.
+ Szablon opisu
+ Kontroluje metadane wyświetlane pod każdym źródłem.
+ Resetuj formatowanie
+ Przywróć domyślne formatowanie źródeł.
+ Klucz API zweryfikowany.
+ Nie udało się zweryfikować tego klucza API.
Dodaj klucz API MDBList poniżej przed włączeniem ocen.
Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.
Klucz API
@@ -1125,6 +1154,9 @@
Wznów od %1$s
ROZMIAR %1$s
Ten typ strumienia nie jest obsługiwany
+ Dodaj klucz API Debrid w Ustawieniach.
+ Ten wynik Debrid wygasł. Odświeżanie strumieni.
+ Nie udało się rozwiązać tego strumienia Debrid.
Nie udało się otworzyć zewnętrznego odtwarzacza
Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach
Brak dostępnego zewnętrznego odtwarzacza
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 8f585ada..3267280d 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -380,6 +380,7 @@
Layout
Content & Discovery
Continue Watching
+ Debrid
Home Layout
Integrations
Licenses & Attribution
@@ -588,6 +589,34 @@
Integrations
Metadata enrichment controls
External ratings providers
+ Experimental cloud account sources
+ Debrid
+ Debrid support is experimental and may be kept, changed, or removed later.
+ Enable sources
+ Show playable results from connected accounts.
+ Add an API key first.
+ Account
+ Connect your Torbox account.
+ Torbox API Key
+ Enter your Torbox API key.
+ Enter Torbox API key
+ Not set
+ Instant Playback
+ Prepare links
+ Resolve the first sources before playback starts.
+ Sources to prepare
+ Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time.
+ 1 source
+ %1$d sources
+ Formatting
+ Name template
+ Controls how source names appear.
+ Description template
+ Controls the metadata shown under each source.
+ Reset formatting
+ Restore default source formatting.
+ 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
@@ -1126,6 +1155,9 @@
Resume from %1$s
SIZE %1$s
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.
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 10f6361d..e486476d 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
@@ -1357,6 +1360,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 ->
@@ -1506,7 +1511,31 @@ 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,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ manualSelection = launch.manualSelection,
+ )
+ }
+ return@LaunchedEffect
+ }
+ }
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
@@ -1584,6 +1613,41 @@ private fun MainAppContent(
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,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ manualSelection = launch.manualSelection,
+ )
+ }
+ }
+ }
+ }
+ return
+ }
val sourceUrl = stream.directPlaybackUrl ?: return
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
@@ -1687,6 +1751,26 @@ private fun MainAppContent(
},
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(
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..0718df7a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
@@ -0,0 +1,169 @@
+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
+
+ 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
+ }
+ }
+
+ 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 }
+ }
+
+ 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
+
+ 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
+ }
+ }
+
+ 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 }
+ }
+
+ private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean =
+ displayName().lowercase().hasVideoExtension()
+}
+
+private fun String.normalizedName(): String =
+ substringAfterLast('/')
+ .substringBeforeLast('.')
+ .lowercase()
+ .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')
+ 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..6e48cc07
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
@@ -0,0 +1,256 @@
+package com.nuvio.app.features.debrid
+
+import kotlinx.serialization.Serializable
+
+data class DebridSettings(
+ val enabled: Boolean = false,
+ val torboxApiKey: String = "",
+ val realDebridApiKey: String = "",
+ val instantPlaybackPreparationLimit: Int = 0,
+ val streamMaxResults: Int = 0,
+ val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
+ val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY,
+ val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
+ val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
+ val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY,
+ val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
+ val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
+ val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
+) {
+ val hasAnyApiKey: Boolean
+ get() = DebridProviders.configuredServices(this).isNotEmpty()
+}
+
+const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
+const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
+
+enum class DebridStreamSortMode {
+ DEFAULT,
+ QUALITY_DESC,
+ SIZE_DESC,
+ SIZE_ASC,
+}
+
+enum class DebridStreamMinimumQuality(val minResolution: Int) {
+ ANY(0),
+ P720(720),
+ P1080(1080),
+ P2160(2160),
+}
+
+enum class DebridStreamFeatureFilter {
+ ANY,
+ EXCLUDE,
+ ONLY,
+}
+
+enum class DebridStreamCodecFilter {
+ ANY,
+ H264,
+ HEVC,
+ AV1,
+}
+
+@Serializable
+data class DebridStreamPreferences(
+ val maxResults: Int = 0,
+ val maxPerResolution: Int = 0,
+ val maxPerQuality: Int = 0,
+ val sizeMinGb: Int = 0,
+ val sizeMaxGb: Int = 0,
+ val preferredResolutions: List = DebridStreamResolution.defaultOrder,
+ val requiredResolutions: List = emptyList(),
+ val excludedResolutions: List = emptyList(),
+ val preferredQualities: List = DebridStreamQuality.defaultOrder,
+ val requiredQualities: List = emptyList(),
+ val excludedQualities: List = emptyList(),
+ val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder,
+ val requiredVisualTags: List = emptyList(),
+ val excludedVisualTags: List = emptyList(),
+ val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder,
+ val requiredAudioTags: List = emptyList(),
+ val excludedAudioTags: List = emptyList(),
+ val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder,
+ val requiredAudioChannels: List = emptyList(),
+ val excludedAudioChannels: List = emptyList(),
+ val preferredEncodes: List = DebridStreamEncode.defaultOrder,
+ val requiredEncodes: List = emptyList(),
+ val excludedEncodes: List = emptyList(),
+ val preferredLanguages: List = emptyList(),
+ val requiredLanguages: List = emptyList(),
+ val excludedLanguages: List = emptyList(),
+ val requiredReleaseGroups: List = emptyList(),
+ val excludedReleaseGroups: List = emptyList(),
+ val sortCriteria: List = DebridStreamSortCriterion.defaultOrder,
+)
+
+@Serializable
+enum class DebridStreamResolution(val label: String, val value: Int) {
+ P2160("2160p", 2160),
+ P1440("1440p", 1440),
+ P1080("1080p", 1080),
+ P720("720p", 720),
+ P576("576p", 576),
+ P480("480p", 480),
+ P360("360p", 360),
+ UNKNOWN("Unknown", 0);
+
+ companion object {
+ val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamQuality(val label: String) {
+ BLURAY_REMUX("BluRay REMUX"),
+ BLURAY("BluRay"),
+ WEB_DL("WEB-DL"),
+ WEBRIP("WEBRip"),
+ HDRIP("HDRip"),
+ HD_RIP("HC HD-Rip"),
+ DVDRIP("DVDRip"),
+ HDTV("HDTV"),
+ CAM("CAM"),
+ TS("TS"),
+ TC("TC"),
+ SCR("SCR"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamVisualTag(val label: String) {
+ HDR_DV("HDR+DV"),
+ DV_ONLY("DV Only"),
+ HDR_ONLY("HDR Only"),
+ HDR10_PLUS("HDR10+"),
+ HDR10("HDR10"),
+ DV("DV"),
+ HDR("HDR"),
+ HLG("HLG"),
+ TEN_BIT("10bit"),
+ THREE_D("3D"),
+ IMAX("IMAX"),
+ AI("AI"),
+ SDR("SDR"),
+ H_OU("H-OU"),
+ H_SBS("H-SBS"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamAudioTag(val label: String) {
+ ATMOS("Atmos"),
+ DD_PLUS("DD+"),
+ DD("DD"),
+ DTS_X("DTS:X"),
+ DTS_HD_MA("DTS-HD MA"),
+ DTS_HD("DTS-HD"),
+ DTS_ES("DTS-ES"),
+ DTS("DTS"),
+ TRUEHD("TrueHD"),
+ OPUS("OPUS"),
+ FLAC("FLAC"),
+ AAC("AAC"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamAudioChannel(val label: String) {
+ CH_2_0("2.0"),
+ CH_5_1("5.1"),
+ CH_6_1("6.1"),
+ CH_7_1("7.1"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamEncode(val label: String) {
+ AV1("AV1"),
+ HEVC("HEVC"),
+ AVC("AVC"),
+ XVID("XviD"),
+ DIVX("DivX"),
+ UNKNOWN("Unknown");
+
+ companion object {
+ val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN)
+ }
+}
+
+@Serializable
+enum class DebridStreamLanguage(val code: String, val label: String) {
+ EN("en", "English"),
+ HI("hi", "Hindi"),
+ IT("it", "Italian"),
+ ES("es", "Spanish"),
+ FR("fr", "French"),
+ DE("de", "German"),
+ PT("pt", "Portuguese"),
+ PL("pl", "Polish"),
+ CS("cs", "Czech"),
+ LA("la", "Latino"),
+ JA("ja", "Japanese"),
+ KO("ko", "Korean"),
+ ZH("zh", "Chinese"),
+ MULTI("multi", "Multi"),
+ UNKNOWN("unknown", "Unknown"),
+}
+
+@Serializable
+data class DebridStreamSortCriterion(
+ val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION,
+ val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC,
+) {
+ companion object {
+ val defaultOrder = listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ }
+}
+
+@Serializable
+enum class DebridStreamSortKey(val label: String) {
+ RESOLUTION("Resolution"),
+ QUALITY("Quality"),
+ VISUAL_TAG("Visual tag"),
+ AUDIO_TAG("Audio"),
+ AUDIO_CHANNEL("Audio channel"),
+ ENCODE("Encode"),
+ SIZE("Size"),
+ LANGUAGE("Language"),
+ RELEASE_GROUP("Release group"),
+}
+
+@Serializable
+enum class DebridStreamSortDirection {
+ ASC,
+ DESC,
+}
+
+fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int =
+ value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT)
+
+fun normalizeDebridStreamMaxResults(value: Int): Int =
+ if (value <= 0) 0 else value.coerceIn(1, 100)
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..d8c7625b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
@@ -0,0 +1,419 @@
+package com.nuvio.app.features.debrid
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+object DebridSettingsRepository {
+ private val _uiState = MutableStateFlow(DebridSettings())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ @OptIn(ExperimentalSerializationApi::class)
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ }
+
+ private var hasLoaded = false
+ private var enabled = false
+ private var torboxApiKey = ""
+ private var realDebridApiKey = ""
+ private var instantPlaybackPreparationLimit = 0
+ private var streamMaxResults = 0
+ private var streamSortMode = DebridStreamSortMode.DEFAULT
+ private var streamMinimumQuality = DebridStreamMinimumQuality.ANY
+ private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY
+ private var streamHdrFilter = DebridStreamFeatureFilter.ANY
+ private var streamCodecFilter = DebridStreamCodecFilter.ANY
+ private var streamPreferences = DebridStreamPreferences()
+ 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 setInstantPlaybackPreparationLimit(value: Int) {
+ ensureLoaded()
+ val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
+ if (instantPlaybackPreparationLimit == normalized) return
+ instantPlaybackPreparationLimit = normalized
+ publish()
+ DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
+ }
+
+ fun setStreamMaxResults(value: Int) {
+ ensureLoaded()
+ val normalized = normalizeDebridStreamMaxResults(value)
+ if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return
+ streamMaxResults = normalized
+ streamPreferences = streamPreferences.copy(maxResults = normalized).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamMaxResults(normalized)
+ saveStreamPreferences()
+ }
+
+ fun setStreamSortMode(value: DebridStreamSortMode) {
+ ensureLoaded()
+ if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return
+ streamSortMode = value
+ streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamSortMode(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) {
+ ensureLoaded()
+ if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return
+ streamMinimumQuality = value
+ streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamMinimumQuality(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) {
+ ensureLoaded()
+ if (streamDolbyVisionFilter == value) return
+ streamDolbyVisionFilter = value
+ streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamHdrFilter(value: DebridStreamFeatureFilter) {
+ ensureLoaded()
+ if (streamHdrFilter == value) return
+ streamHdrFilter = value
+ streamPreferences = streamPreferences.applyHdrFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamHdrFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamCodecFilter(value: DebridStreamCodecFilter) {
+ ensureLoaded()
+ if (streamCodecFilter == value) return
+ streamCodecFilter = value
+ streamPreferences = streamPreferences.applyCodecFilter(value).normalized()
+ publish()
+ DebridSettingsStorage.saveStreamCodecFilter(value.name)
+ saveStreamPreferences()
+ }
+
+ fun setStreamPreferences(value: DebridStreamPreferences) {
+ ensureLoaded()
+ val normalized = value.normalized()
+ if (streamPreferences == normalized) return
+ streamPreferences = normalized
+ streamMaxResults = normalized.maxResults
+ publish()
+ DebridSettingsStorage.saveStreamMaxResults(streamMaxResults)
+ saveStreamPreferences()
+ }
+
+ 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 setStreamTemplates(nameTemplate: String, descriptionTemplate: String) {
+ ensureLoaded()
+ streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
+ streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
+ publish()
+ DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
+ DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
+ }
+
+ fun resetStreamTemplates() {
+ setStreamTemplates(
+ nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE,
+ descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
+ )
+ }
+
+ 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()
+ instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
+ DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
+ )
+ streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0)
+ streamSortMode = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamSortMode(),
+ DebridStreamSortMode.DEFAULT,
+ )
+ streamMinimumQuality = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamMinimumQuality(),
+ DebridStreamMinimumQuality.ANY,
+ )
+ streamDolbyVisionFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamDolbyVisionFilter(),
+ DebridStreamFeatureFilter.ANY,
+ )
+ streamHdrFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamHdrFilter(),
+ DebridStreamFeatureFilter.ANY,
+ )
+ streamCodecFilter = enumValueOrDefault(
+ DebridSettingsStorage.loadStreamCodecFilter(),
+ DebridStreamCodecFilter.ANY,
+ )
+ streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
+ ?: legacyStreamPreferences(
+ maxResults = streamMaxResults,
+ sortMode = streamSortMode,
+ minimumQuality = streamMinimumQuality,
+ dolbyVisionFilter = streamDolbyVisionFilter,
+ hdrFilter = streamHdrFilter,
+ codecFilter = streamCodecFilter,
+ )
+ 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,
+ instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
+ streamMaxResults = streamMaxResults,
+ streamSortMode = streamSortMode,
+ streamMinimumQuality = streamMinimumQuality,
+ streamDolbyVisionFilter = streamDolbyVisionFilter,
+ streamHdrFilter = streamHdrFilter,
+ streamCodecFilter = streamCodecFilter,
+ streamPreferences = streamPreferences,
+ streamNameTemplate = streamNameTemplate,
+ streamDescriptionTemplate = streamDescriptionTemplate,
+ )
+ }
+
+ private fun saveStreamPreferences() {
+ DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized()))
+ }
+
+ private inline fun > enumValueOrDefault(value: String?, default: T): T =
+ runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default)
+
+ private fun parseStreamPreferences(value: String?): DebridStreamPreferences? {
+ if (value.isNullOrBlank()) return null
+ return try {
+ json.decodeFromString(value).normalized()
+ } catch (_: SerializationException) {
+ null
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+}
+
+internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
+ copy(
+ maxResults = normalizeDebridStreamMaxResults(maxResults),
+ maxPerResolution = maxPerResolution.coerceIn(0, 100),
+ maxPerQuality = maxPerQuality.coerceIn(0, 100),
+ sizeMinGb = sizeMinGb.coerceIn(0, 100),
+ sizeMaxGb = sizeMaxGb.coerceIn(0, 100),
+ preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder },
+ requiredResolutions = requiredResolutions,
+ excludedResolutions = excludedResolutions,
+ preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder },
+ requiredQualities = requiredQualities,
+ excludedQualities = excludedQualities,
+ preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder },
+ requiredVisualTags = requiredVisualTags,
+ excludedVisualTags = excludedVisualTags,
+ preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder },
+ requiredAudioTags = requiredAudioTags,
+ excludedAudioTags = excludedAudioTags,
+ preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder },
+ requiredAudioChannels = requiredAudioChannels,
+ excludedAudioChannels = excludedAudioChannels,
+ preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder },
+ requiredEncodes = requiredEncodes,
+ excludedEncodes = excludedEncodes,
+ preferredLanguages = preferredLanguages,
+ requiredLanguages = requiredLanguages,
+ excludedLanguages = excludedLanguages,
+ requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
+ excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
+ sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder },
+ )
+
+private fun legacyStreamPreferences(
+ maxResults: Int,
+ sortMode: DebridStreamSortMode,
+ minimumQuality: DebridStreamMinimumQuality,
+ dolbyVisionFilter: DebridStreamFeatureFilter,
+ hdrFilter: DebridStreamFeatureFilter,
+ codecFilter: DebridStreamCodecFilter,
+): DebridStreamPreferences =
+ DebridStreamPreferences(
+ maxResults = normalizeDebridStreamMaxResults(maxResults),
+ sortCriteria = sortCriteriaForLegacyMode(sortMode),
+ requiredResolutions = resolutionsForMinimumQuality(minimumQuality),
+ )
+ .applyDolbyVisionFilter(dolbyVisionFilter)
+ .applyHdrFilter(hdrFilter)
+ .applyCodecFilter(codecFilter)
+ .normalized()
+
+private fun DebridStreamPreferences.applyDolbyVisionFilter(
+ filter: DebridStreamFeatureFilter,
+): DebridStreamPreferences =
+ when (filter) {
+ DebridStreamFeatureFilter.ANY -> copy(
+ requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
+ excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
+ )
+ DebridStreamFeatureFilter.EXCLUDE -> copy(
+ requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(),
+ excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(),
+ )
+ DebridStreamFeatureFilter.ONLY -> copy(
+ requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(),
+ excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(),
+ )
+ }
+
+private fun DebridStreamPreferences.applyHdrFilter(
+ filter: DebridStreamFeatureFilter,
+): DebridStreamPreferences =
+ when (filter) {
+ DebridStreamFeatureFilter.ANY -> copy(
+ requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
+ excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
+ )
+ DebridStreamFeatureFilter.EXCLUDE -> copy(
+ requiredVisualTags = requiredVisualTags - hdrTags.toSet(),
+ excludedVisualTags = (excludedVisualTags + hdrTags).distinct(),
+ )
+ DebridStreamFeatureFilter.ONLY -> copy(
+ requiredVisualTags = (requiredVisualTags + hdrTags).distinct(),
+ excludedVisualTags = excludedVisualTags - hdrTags.toSet(),
+ )
+ }
+
+private fun DebridStreamPreferences.applyCodecFilter(
+ filter: DebridStreamCodecFilter,
+): DebridStreamPreferences =
+ copy(
+ requiredEncodes = when (filter) {
+ DebridStreamCodecFilter.ANY -> emptyList()
+ DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC)
+ DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC)
+ DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1)
+ },
+ )
+
+private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List =
+ DebridStreamResolution.defaultOrder.filter {
+ it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN
+ }
+
+private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List =
+ when (mode) {
+ DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder
+ DebridStreamSortMode.QUALITY_DESC -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
+ DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
+ }
+
+private val dolbyVisionTags = listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+)
+
+private val hdrTags = listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+)
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..62fddac4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
@@ -0,0 +1,34 @@
+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 loadInstantPlaybackPreparationLimit(): Int?
+ fun saveInstantPlaybackPreparationLimit(limit: Int)
+ fun loadStreamMaxResults(): Int?
+ fun saveStreamMaxResults(maxResults: Int)
+ fun loadStreamSortMode(): String?
+ fun saveStreamSortMode(mode: String)
+ fun loadStreamMinimumQuality(): String?
+ fun saveStreamMinimumQuality(quality: String)
+ fun loadStreamDolbyVisionFilter(): String?
+ fun saveStreamDolbyVisionFilter(filter: String)
+ fun loadStreamHdrFilter(): String?
+ fun saveStreamHdrFilter(filter: String)
+ fun loadStreamCodecFilter(): String?
+ fun saveStreamCodecFilter(filter: String)
+ fun loadStreamPreferences(): String?
+ fun saveStreamPreferences(preferences: 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..dd73d303
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt
@@ -0,0 +1,143 @@
+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 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) }
+ }
+ 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)?.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),
+ "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 List.singleOrFirstOrNull(): Int? =
+ singleOrNull() ?: firstOrNull()
+
+ 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..23e635e9
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt
@@ -0,0 +1,394 @@
+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 ""
+ 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 DebridTemplateBytes -> value.value != 0L
+ 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 DebridTemplateBytes -> value.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 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()
+ }
+
+ 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..6b8e3425
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
@@ -0,0 +1,375 @@
+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
+import nuvio.composeapp.generated.resources.debrid_stream_stale
+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 {
+ 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)
+ else -> DirectDebridResolveResult.Error
+ }
+
+ suspend fun resolveToPlayableStream(
+ stream: StreamItem,
+ season: Int?,
+ episode: Int?,
+ ): DirectDebridPlayableResult {
+ 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
+ DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale
+ DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error
+ }
+ }
+}
+
+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()
+ 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)
+ ?: run {
+ 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)
+ ?: 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
+ }
+ val url = link.body?.data?.takeIf { it.isNotBlank() }
+ ?: run {
+ 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)
+ ?: run {
+ 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 filesBefore = infoBefore.body?.files.orEmpty()
+ val file = fileSelector.selectFile(
+ files = filesBefore,
+ resolve = resolve,
+ season = season,
+ episode = episode,
+ )
+ ?: 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
+ }
+
+ val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
+ if (!infoAfter.isSuccessful) {
+ return DirectDebridResolveResult.Stale
+ }
+ val link = infoAfter.body?.firstDownloadLink()
+ ?: run {
+ return DirectDebridResolveResult.Stale
+ }
+ val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
+ if (!unrestrict.isSuccessful) {
+ return DirectDebridResolveResult.Stale
+ }
+ val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
+ ?: run {
+ 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.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,
+ 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..6647d607
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
@@ -0,0 +1,425 @@
+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, settings: DebridSettings? = null): List {
+ val instantStreams = 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("|")
+ }
+ return if (settings == null) instantStreams else applyPreferences(instantStreams, settings)
+ }
+
+ 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) }
+
+ private fun applyPreferences(streams: List, settings: DebridSettings): List {
+ val preferences = effectivePreferences(settings)
+ return streams.map { it to streamFacts(it, preferences) }
+ .filter { (_, facts) -> facts.matchesFilters(preferences) }
+ .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) }
+ .let { sorted -> applyLimits(sorted, preferences) }
+ .map { it.first }
+ }
+
+ private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences {
+ val default = DebridStreamPreferences()
+ if (settings.streamPreferences != default) return settings.streamPreferences.normalized()
+ if (
+ settings.streamMaxResults == 0 &&
+ settings.streamSortMode == DebridStreamSortMode.DEFAULT &&
+ settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY &&
+ settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY &&
+ settings.streamHdrFilter == DebridStreamFeatureFilter.ANY &&
+ settings.streamCodecFilter == DebridStreamCodecFilter.ANY
+ ) {
+ return default
+ }
+ var preferences = default.copy(
+ maxResults = settings.streamMaxResults,
+ sortCriteria = when (settings.streamSortMode) {
+ DebridStreamSortMode.DEFAULT -> default.sortCriteria
+ DebridStreamSortMode.QUALITY_DESC -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
+ DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
+ },
+ requiredResolutions = DebridStreamResolution.defaultOrder.filter {
+ it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN
+ },
+ )
+ preferences = when (settings.streamDolbyVisionFilter) {
+ DebridStreamFeatureFilter.ANY -> preferences
+ DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
+ excludedVisualTags = preferences.excludedVisualTags + listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ DebridStreamFeatureFilter.ONLY -> preferences.copy(
+ requiredVisualTags = preferences.requiredVisualTags + listOf(
+ DebridStreamVisualTag.DV,
+ DebridStreamVisualTag.DV_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ }
+ preferences = when (settings.streamHdrFilter) {
+ DebridStreamFeatureFilter.ANY -> preferences
+ DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
+ excludedVisualTags = preferences.excludedVisualTags + listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ DebridStreamFeatureFilter.ONLY -> preferences.copy(
+ requiredVisualTags = preferences.requiredVisualTags + listOf(
+ DebridStreamVisualTag.HDR,
+ DebridStreamVisualTag.HDR10,
+ DebridStreamVisualTag.HDR10_PLUS,
+ DebridStreamVisualTag.HLG,
+ DebridStreamVisualTag.HDR_ONLY,
+ DebridStreamVisualTag.HDR_DV,
+ ),
+ )
+ }
+ return when (settings.streamCodecFilter) {
+ DebridStreamCodecFilter.ANY -> preferences
+ DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC))
+ DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC))
+ DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1))
+ }.normalized()
+ }
+
+ private fun applyLimits(
+ streams: List>,
+ preferences: DebridStreamPreferences,
+ ): List> {
+ val resolutionCounts = mutableMapOf()
+ val qualityCounts = mutableMapOf()
+ val result = mutableListOf>()
+ for (stream in streams) {
+ if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break
+ if (preferences.maxPerResolution > 0) {
+ val count = resolutionCounts[stream.second.resolution] ?: 0
+ if (count >= preferences.maxPerResolution) continue
+ }
+ if (preferences.maxPerQuality > 0) {
+ val count = qualityCounts[stream.second.quality] ?: 0
+ if (count >= preferences.maxPerQuality) continue
+ }
+ resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1
+ qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1
+ result += stream
+ }
+ return result
+ }
+
+ private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean {
+ if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false
+ if (resolution in preferences.excludedResolutions) return false
+ if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false
+ if (quality in preferences.excludedQualities) return false
+ if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false
+ if (visualTags.any { it in preferences.excludedVisualTags }) return false
+ if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false
+ if (audioTags.any { it in preferences.excludedAudioTags }) return false
+ if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false
+ if (audioChannels.any { it in preferences.excludedAudioChannels }) return false
+ if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false
+ if (encode in preferences.excludedEncodes) return false
+ if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false
+ if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false
+ if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false
+ if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false
+ if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false
+ if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false
+ return true
+ }
+
+ private fun compareFacts(
+ left: StreamFacts,
+ right: StreamFacts,
+ criteria: List,
+ ): Int {
+ for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) {
+ val comparison = compareKey(left, right, criterion)
+ if (comparison != 0) return comparison
+ }
+ return 0
+ }
+
+ private fun compareKey(
+ left: StreamFacts,
+ right: StreamFacts,
+ criterion: DebridStreamSortCriterion,
+ ): Int {
+ val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1
+ return when (criterion.key) {
+ DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction
+ DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction
+ DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction
+ DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction
+ DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction
+ DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction
+ DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction
+ DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction
+ DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true)
+ }
+ }
+
+ private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts {
+ val parsed = stream.clientResolve?.stream?.raw?.parsed
+ val searchText = streamSearchText(stream)
+ val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText)
+ val quality = streamQuality(parsed?.quality, searchText)
+ val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText)
+ val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText)
+ val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText)
+ val encode = streamEncode(parsed?.codec, searchText)
+ val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty {
+ DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) }
+ }
+ val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText)
+ return StreamFacts(
+ resolution = resolution,
+ quality = quality,
+ visualTags = visualTags,
+ audioTags = audioTags,
+ audioChannels = audioChannels,
+ encode = encode,
+ languages = languages,
+ releaseGroup = releaseGroup,
+ size = streamSize(stream),
+ resolutionRank = rank(resolution, preferences.preferredResolutions),
+ qualityRank = rank(quality, preferences.preferredQualities),
+ visualRank = rankAny(visualTags, preferences.preferredVisualTags),
+ audioRank = rankAny(audioTags, preferences.preferredAudioTags),
+ channelRank = rankAny(audioChannels, preferences.preferredAudioChannels),
+ encodeRank = rank(encode, preferences.preferredEncodes),
+ languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) },
+ )
+ }
+
+ private fun streamResolution(vararg values: String?): DebridStreamResolution =
+ values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN
+
+ private fun resolutionValue(value: String?): DebridStreamResolution? {
+ val normalized = value?.lowercase().orEmpty()
+ return when {
+ normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160
+ normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440
+ normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080
+ normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720
+ normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576
+ normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480
+ normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360
+ else -> null
+ }
+ }
+
+ private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality {
+ val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase()
+ return when {
+ text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX
+ text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY
+ text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL
+ text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP
+ text.contains("hdrip") -> DebridStreamQuality.HDRIP
+ text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP
+ text.contains("dvdrip") -> DebridStreamQuality.DVDRIP
+ text.contains("hdtv") -> DebridStreamQuality.HDTV
+ text.hasToken("cam") -> DebridStreamQuality.CAM
+ text.hasToken("ts") -> DebridStreamQuality.TS
+ text.hasToken("tc") -> DebridStreamQuality.TC
+ text.hasToken("scr") -> DebridStreamQuality.SCR
+ else -> DebridStreamQuality.UNKNOWN
+ }
+ }
+
+ private fun streamVisualTags(parsedHdr: List, searchText: String): List {
+ val text = (parsedHdr + searchText).joinToString(" ").lowercase()
+ val tags = mutableListOf()
+ val hasDv = parsedHdr.any { it.isDolbyVisionToken() } ||
+ Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText)
+ val hasHdr = parsedHdr.any { it.isHdrToken() } ||
+ Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText)
+ if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV
+ if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY
+ if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY
+ if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS
+ if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10
+ if (hasDv) tags += DebridStreamVisualTag.DV
+ if (hasHdr) tags += DebridStreamVisualTag.HDR
+ if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG
+ if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT
+ if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D
+ if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX
+ if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI
+ if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR
+ if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU
+ if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS
+ return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) }
+ }
+
+ private fun streamAudioTags(parsedAudio: List, searchText: String): List {
+ val text = (parsedAudio + searchText).joinToString(" ").lowercase()
+ val tags = mutableListOf()
+ if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS
+ if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS
+ if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD
+ if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X
+ if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA
+ if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD
+ if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES
+ if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS
+ if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD
+ if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS
+ if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC
+ if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC
+ return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) }
+ }
+
+ private fun streamAudioChannels(parsedChannels: List, searchText: String): List {
+ val text = (parsedChannels + searchText).joinToString(" ").lowercase()
+ val channels = mutableListOf()
+ if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1
+ if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1
+ if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1
+ if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0
+ return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) }
+ }
+
+ private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode {
+ val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase()
+ return when {
+ text.hasToken("av1") -> DebridStreamEncode.AV1
+ text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC
+ text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC
+ text.hasToken("xvid") -> DebridStreamEncode.XVID
+ text.hasToken("divx") -> DebridStreamEncode.DIVX
+ else -> DebridStreamEncode.UNKNOWN
+ }
+ }
+
+ private fun languageFor(value: String): DebridStreamLanguage? {
+ val normalized = value.lowercase()
+ return DebridStreamLanguage.entries.firstOrNull {
+ normalized == it.code || normalized == it.label.lowercase()
+ }
+ }
+
+ private fun releaseGroupFromText(text: String): String =
+ Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE)
+ .find(text)
+ ?.groupValues
+ ?.getOrNull(1)
+ .orEmpty()
+
+ private fun rank(value: T, preferred: List): Int {
+ val index = preferred.indexOf(value)
+ return if (index >= 0) index else Int.MAX_VALUE
+ }
+
+ private fun rankAny(values: List, preferred: List): Int =
+ values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE
+
+ private fun String.hasResolutionToken(vararg tokens: String): Boolean =
+ Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this)
+
+ private fun String.hasToken(token: String): Boolean =
+ Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase())
+
+ private fun String.isDolbyVisionToken(): Boolean {
+ val normalized = lowercase().replace(Regex("[^a-z0-9]"), "")
+ return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision"
+ }
+
+ private fun String.isHdrToken(): Boolean {
+ val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "")
+ return normalized == "hdr" ||
+ normalized == "hdr10" ||
+ normalized == "hdr10+" ||
+ normalized == "hdr10plus" ||
+ normalized == "hlg"
+ }
+
+ private fun streamSize(stream: StreamItem): Long? =
+ stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize
+
+ private fun streamSearchText(stream: StreamItem): String {
+ val resolve = stream.clientResolve
+ val raw = resolve?.stream?.raw
+ val parsed = raw?.parsed
+ return listOfNotNull(
+ stream.name,
+ stream.title,
+ stream.description,
+ resolve?.torrentName,
+ resolve?.filename,
+ raw?.torrentName,
+ raw?.filename,
+ parsed?.resolution,
+ parsed?.quality,
+ parsed?.codec,
+ parsed?.hdr?.joinToString(" "),
+ parsed?.audio?.joinToString(" "),
+ ).joinToString(" ").lowercase()
+ }
+
+ private fun Int.gigabytes(): Long = this * 1_000_000_000L
+
+ private data class StreamFacts(
+ val resolution: DebridStreamResolution,
+ val quality: DebridStreamQuality,
+ val visualTags: List,
+ val audioTags: List,
+ val audioChannels: List,
+ val encode: DebridStreamEncode,
+ val languages: List,
+ val releaseGroup: String,
+ val size: Long?,
+ val resolutionRank: Int,
+ val qualityRank: Int,
+ val visualRank: Int,
+ val audioRank: Int,
+ val channelRank: Int,
+ val encodeRank: Int,
+ val languageRank: Int,
+ )
+}
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
new file mode 100644
index 00000000..6cd5573d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
@@ -0,0 +1,253 @@
+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 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.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+private const val DIRECT_DEBRID_TAG = "DirectDebridStreams"
+private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L
+
+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()
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val mutex = Mutex()
+ private val streamCache = mutableMapOf()
+ private val inFlightFetches = mutableMapOf>()
+
+ 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 sourceNames(): List =
+ configuredTargets().map { it.addonName }
+
+ fun isEnabled(): Boolean =
+ sourceNames().isNotEmpty()
+
+ fun placeholders(): List =
+ configuredTargets().map { target ->
+ AddonStreamGroup(
+ addonName = target.addonName,
+ addonId = target.addonId,
+ streams = emptyList(),
+ isLoading = true,
+ )
+ }
+
+ fun preloadStreams(type: String, videoId: String) {
+ if (type.isBlank() || videoId.isBlank()) return
+ configuredTargets().forEach { target ->
+ scope.launch {
+ runCatching { fetchProviderStreams(type, videoId, target) }
+ }
+ }
+ }
+
+ suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult {
+ val targets = configuredTargets()
+ if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled
+
+ val results = mutableListOf()
+ val errors = mutableListOf()
+ targets.forEach { target ->
+ val group = fetchProviderStreams(type, videoId, target)
+ when {
+ group.streams.isNotEmpty() -> results += group
+ !group.error.isNullOrBlank() -> errors += group.error
+ }
+ }
+
+ return when {
+ results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results)
+ errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first())
+ else -> DirectDebridStreamFetchResult.Empty
+ }
+ }
+
+ 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 cacheKey = DirectDebridStreamCacheKey(
+ providerId = target.provider.id,
+ type = type.trim().lowercase(),
+ videoId = videoId.trim(),
+ baseUrl = baseUrl,
+ settingsFingerprint = settings.toString(),
+ )
+ cachedGroup(cacheKey)?.let { return it }
+
+ var ownsFetch = false
+ val newFetch = scope.async(start = CoroutineStart.LAZY) {
+ fetchProviderStreamsUncached(
+ baseUrl = baseUrl,
+ type = type,
+ videoId = videoId,
+ target = target,
+ settings = settings,
+ )
+ }
+ val activeFetch = mutex.withLock {
+ cachedGroupLocked(cacheKey)?.let { cached ->
+ return@withLock null to cached
+ }
+ val existing = inFlightFetches[cacheKey]
+ if (existing != null) {
+ existing to null
+ } else {
+ inFlightFetches[cacheKey] = newFetch
+ ownsFetch = true
+ newFetch to null
+ }
+ }
+ activeFetch.second?.let {
+ newFetch.cancel()
+ return it
+ }
+ val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch")
+ if (!ownsFetch) newFetch.cancel()
+ if (ownsFetch) deferred.start()
+
+ return try {
+ val result = deferred.await()
+ if (ownsFetch && result.streams.isNotEmpty() && result.error == null) {
+ mutex.withLock {
+ streamCache[cacheKey] = CachedDirectDebridStreams(
+ group = result,
+ createdAtMs = epochMs(),
+ )
+ }
+ }
+ result
+ } finally {
+ if (ownsFetch) {
+ mutex.withLock {
+ if (inFlightFetches[cacheKey] === deferred) {
+ inFlightFetches.remove(cacheKey)
+ }
+ }
+ }
+ }
+ }
+
+ private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? =
+ mutex.withLock { cachedGroupLocked(cacheKey) }
+
+ private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? {
+ val cached = streamCache[cacheKey] ?: return null
+ val age = epochMs() - cached.createdAtMs
+ return if (age in 0..STREAM_CACHE_TTL_MS) {
+ cached.group
+ } else {
+ streamCache.remove(cacheKey)
+ null
+ }
+ }
+
+ private suspend fun fetchProviderStreamsUncached(
+ baseUrl: String,
+ type: String,
+ videoId: String,
+ target: DirectDebridStreamTarget,
+ settings: DebridSettings,
+ ): AddonStreamGroup {
+ 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(it, settings) }
+ .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" }
+ target.errorGroup(error.message)
+ }
+ }
+
+ private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup =
+ AddonStreamGroup(
+ addonName = addonName,
+ addonId = addonId,
+ streams = emptyList(),
+ isLoading = false,
+ )
+
+ private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup =
+ AddonStreamGroup(
+ addonName = addonName,
+ addonId = addonId,
+ streams = emptyList(),
+ isLoading = false,
+ error = message,
+ )
+}
+
+private data class DirectDebridStreamCacheKey(
+ val providerId: String,
+ val type: String,
+ val videoId: String,
+ val baseUrl: String,
+ val settingsFingerprint: String,
+)
+
+private data class CachedDirectDebridStreams(
+ val group: AddonStreamGroup,
+ val createdAtMs: Long,
+)
+
+sealed class DirectDebridStreamFetchResult {
+ data object Disabled : DirectDebridStreamFetchResult()
+ data object Empty : DirectDebridStreamFetchResult()
+ data class Success(val streams: List) : DirectDebridStreamFetchResult()
+ data class Error(val message: String) : DirectDebridStreamFetchResult()
+}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
index 899747c7..378e3a8c 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt
@@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
+import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.components.DetailActionButtons
import com.nuvio.app.features.details.components.CommentDetailSheet
import com.nuvio.app.features.details.components.DetailAdditionalInfoSection
@@ -378,6 +379,16 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
+ val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) {
+ if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) {
+ seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id
+ } else {
+ meta.id
+ }
+ }
+ LaunchedEffect(meta.type, debridPreloadVideoId) {
+ DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId)
+ }
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@@ -1367,3 +1378,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
+
+private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean =
+ hasEpisodes || type.equals("series", ignoreCase = true) ||
+ type.equals("show", ignoreCase = true) ||
+ type.equals("tv", ignoreCase = true)
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 fcfcadd1..a18b195d 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
@@ -857,7 +861,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)
@@ -899,6 +951,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 24ea2129..013460c3 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,8 @@ 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
import com.nuvio.app.features.plugins.pluginContentId
@@ -154,6 +156,10 @@ 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()
PluginRepository.getEnabledScrapersForType(type)
@@ -161,7 +167,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,
@@ -187,7 +193,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,
@@ -210,6 +216,13 @@ object PlayerStreamsRepository {
streams = emptyList(),
isLoading = true,
)
+ } + debridTargets.map { target ->
+ AddonStreamGroup(
+ addonName = target.addonName,
+ addonId = target.addonId,
+ streams = emptyList(),
+ isLoading = true,
+ )
}, installedAddonOrder)
stateFlow.value = StreamsUiState(
groups = initialGroups,
@@ -278,13 +291,24 @@ 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
val completions = Channel(capacity = Channel.BUFFERED)
jobs.forEach { deferred ->
launch {
completions.send(deferred.await())
}
}
+ var debridPreparationLaunched = false
repeat(jobs.size) {
val result = completions.receive()
stateFlow.update { current ->
@@ -305,6 +329,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,
+ ),
+ )
+ }
+ }
+ }
+ }
}
completions.close()
}
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..30d59534
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
@@ -0,0 +1,1295 @@
+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.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListScope
+import androidx.compose.foundation.lazy.items
+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.Checkbox
+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
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+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
+import com.nuvio.app.features.debrid.DebridSettingsRepository
+import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults
+import com.nuvio.app.features.debrid.DebridStreamAudioChannel
+import com.nuvio.app.features.debrid.DebridStreamAudioTag
+import com.nuvio.app.features.debrid.DebridStreamEncode
+import com.nuvio.app.features.debrid.DebridStreamLanguage
+import com.nuvio.app.features.debrid.DebridStreamPreferences
+import com.nuvio.app.features.debrid.DebridStreamQuality
+import com.nuvio.app.features.debrid.DebridStreamResolution
+import com.nuvio.app.features.debrid.DebridStreamSortCriterion
+import com.nuvio.app.features.debrid.DebridStreamSortDirection
+import com.nuvio.app.features.debrid.DebridStreamSortKey
+import com.nuvio.app.features.debrid.DebridStreamVisualTag
+import kotlinx.coroutines.launch
+import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.action_cancel
+import nuvio.composeapp.generated.resources.action_clear
+import nuvio.composeapp.generated.resources.action_reset
+import nuvio.composeapp.generated.resources.action_save
+import nuvio.composeapp.generated.resources.action_saving
+import nuvio.composeapp.generated.resources.settings_debrid_add_key_first
+import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder
+import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle
+import nuvio.composeapp.generated.resources.settings_debrid_dialog_title
+import nuvio.composeapp.generated.resources.settings_debrid_enable
+import nuvio.composeapp.generated.resources.settings_debrid_enable_description
+import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice
+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_formatter_reset_subtitle
+import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title
+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_prepare_stream_count_warning
+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_not_set
+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
+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) {
+ DebridInfoRow(
+ isTablet = isTablet,
+ text = stringResource(Res.string.settings_debrid_experimental_notice),
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_debrid_enable),
+ description = stringResource(Res.string.settings_debrid_enable_description),
+ checked = settings.enabled && settings.hasAnyApiKey,
+ 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 {
+ var showApiKeyDialog by rememberSaveable { mutableStateOf(false) }
+
+ SettingsSection(
+ title = stringResource(Res.string.settings_debrid_section_providers),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = DebridProviders.Torbox.displayName,
+ description = stringResource(Res.string.settings_debrid_provider_torbox_description),
+ value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)),
+ enabled = true,
+ onClick = { showApiKeyDialog = true },
+ )
+ }
+ }
+
+ if (showApiKeyDialog) {
+ DebridApiKeyDialog(
+ providerId = DebridProviders.TORBOX_ID,
+ title = stringResource(Res.string.settings_debrid_dialog_title),
+ subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle),
+ placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder),
+ currentValue = settings.torboxApiKey,
+ onSave = DebridSettingsRepository::setTorboxApiKey,
+ onDismiss = { showApiKeyDialog = false },
+ )
+ }
+ }
+
+ 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 {
+ var activeStreamPicker by rememberSaveable { mutableStateOf(null) }
+ val preferences = settings.streamPreferences
+ val rows = debridRuleRows(preferences)
+
+ SettingsSection(
+ title = "Filters & Sorting",
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = "Max results",
+ description = "Limit how many Direct Debrid sources appear.",
+ value = streamMaxResultsLabel(preferences.maxResults),
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = "Sort streams",
+ description = "Choose how Direct Debrid sources are ordered.",
+ value = sortProfileLabel(preferences.sortCriteria),
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = "Per resolution limit",
+ description = "Cap repeated 2160p, 1080p, 720p results after sorting.",
+ value = streamMaxResultsLabel(preferences.maxPerResolution),
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = "Per quality limit",
+ description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.",
+ value = streamMaxResultsLabel(preferences.maxPerQuality),
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = "Size range",
+ description = "Filter streams by file size.",
+ value = sizeRangeLabel(preferences),
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE },
+ )
+ rows.forEach { row ->
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = row.title,
+ description = row.description,
+ value = row.value,
+ enabled = settings.enabled,
+ onClick = { activeStreamPicker = row.picker },
+ )
+ }
+ }
+ }
+
+ activeStreamPicker?.let { picker ->
+ DebridStreamPreferenceDialog(
+ picker = picker,
+ preferences = preferences,
+ onPreferencesChanged = DebridSettingsRepository::setStreamPreferences,
+ onDismiss = { activeStreamPicker = null },
+ )
+ }
+ }
+
+ item {
+ var activeTemplateField by rememberSaveable { mutableStateOf(null) }
+
+ SettingsSection(
+ title = stringResource(Res.string.settings_debrid_section_formatting),
+ isTablet = isTablet,
+ ) {
+ SettingsGroup(isTablet = isTablet) {
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = stringResource(Res.string.settings_debrid_name_template),
+ description = stringResource(Res.string.settings_debrid_name_template_description),
+ value = templatePreview(settings.streamNameTemplate),
+ enabled = settings.enabled,
+ onClick = { activeTemplateField = DebridTemplateField.NAME },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = stringResource(Res.string.settings_debrid_description_template),
+ description = stringResource(Res.string.settings_debrid_description_template_description),
+ value = templatePreview(settings.streamDescriptionTemplate),
+ enabled = settings.enabled,
+ onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION },
+ )
+ SettingsGroupDivider(isTablet = isTablet)
+ DebridPreferenceRow(
+ isTablet = isTablet,
+ title = stringResource(Res.string.settings_debrid_formatter_reset_title),
+ description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle),
+ value = stringResource(Res.string.action_reset),
+ enabled = settings.enabled,
+ onClick = DebridSettingsRepository::resetStreamTemplates,
+ )
+ }
+ }
+
+ when (activeTemplateField) {
+ DebridTemplateField.NAME -> DebridTemplateDialog(
+ title = stringResource(Res.string.settings_debrid_name_template),
+ description = stringResource(Res.string.settings_debrid_name_template_description),
+ currentValue = settings.streamNameTemplate,
+ defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE,
+ onSave = DebridSettingsRepository::setStreamNameTemplate,
+ onDismiss = { activeTemplateField = null },
+ )
+ DebridTemplateField.DESCRIPTION -> DebridTemplateDialog(
+ title = stringResource(Res.string.settings_debrid_description_template),
+ description = stringResource(Res.string.settings_debrid_description_template_description),
+ currentValue = settings.streamDescriptionTemplate,
+ defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
+ onSave = DebridSettingsRepository::setStreamDescriptionTemplate,
+ onDismiss = { activeTemplateField = null },
+ )
+ null -> Unit
+ }
+ }
+}
+
+private enum class DebridTemplateField {
+ NAME,
+ DESCRIPTION,
+}
+
+private fun templatePreview(value: String): String {
+ val firstLine = value
+ .lineSequence()
+ .map { it.trim() }
+ .firstOrNull { it.isNotBlank() }
+ ?: return ""
+ return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..."
+}
+
+@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,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Text(
+ text = stringResource(Res.string.settings_debrid_prepare_stream_count_warning),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun DebridTemplateDialog(
+ title: String,
+ description: String,
+ currentValue: String,
+ defaultValue: String,
+ onSave: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) }
+
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ DebridDialogSurface(title = title) {
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 140.dp, max = 280.dp),
+ minLines = 5,
+ 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,
+ ),
+ )
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.End,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ TextButton(onClick = { draft = defaultValue }) {
+ Text(
+ text = stringResource(Res.string.action_reset),
+ maxLines = 1,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(
+ text = stringResource(Res.string.action_cancel),
+ maxLines = 1,
+ )
+ }
+ Button(
+ onClick = {
+ onSave(draft)
+ onDismiss()
+ },
+ ) {
+ Text(
+ text = stringResource(Res.string.action_save),
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DebridPreferenceRow(
+ isTablet: Boolean,
+ title: String,
+ description: String,
+ value: String,
+ enabled: Boolean,
+ onClick: () -> Unit,
+) {
+ val horizontalPadding = if (isTablet) 20.dp else 16.dp
+ val verticalPadding = if (isTablet) 16.dp else 14.dp
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(enabled = enabled, onClick = onClick)
+ .padding(horizontal = horizontalPadding, vertical = verticalPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ 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,
+ )
+ }
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Medium,
+ )
+ }
+}
+
+@Composable
+private fun DebridStreamPreferenceDialog(
+ picker: DebridStreamPicker,
+ preferences: DebridStreamPreferences,
+ onPreferencesChanged: (DebridStreamPreferences) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ when (picker) {
+ DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog(
+ title = "Max results",
+ selectedValue = preferences.maxResults,
+ options = listOf(0, 5, 10, 20, 50),
+ label = { streamMaxResultsLabel(it) },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog(
+ title = "Max results",
+ selectedValue = preferences.maxPerResolution,
+ options = listOf(0, 1, 2, 3, 5),
+ label = { streamMaxResultsLabel(it) },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog(
+ title = "Max results",
+ selectedValue = preferences.maxPerQuality,
+ options = listOf(0, 1, 2, 3, 5),
+ label = { streamMaxResultsLabel(it) },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog(
+ title = "Sort streams",
+ selectedValue = sortProfileFor(preferences.sortCriteria),
+ options = listOf(
+ DebridSortProfile.DEFAULT,
+ DebridSortProfile.LARGEST,
+ DebridSortProfile.SMALLEST,
+ DebridSortProfile.AUDIO,
+ DebridSortProfile.LANGUAGE,
+ ),
+ label = { sortProfileLabel(it) },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog(
+ title = "Size range",
+ selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb,
+ options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100),
+ label = { sizeRangeLabel(it.first, it.second) },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog(
+ title = "Preferred resolutions",
+ selectedValues = preferences.preferredResolutions,
+ values = DebridStreamResolution.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog(
+ title = "Required resolutions",
+ selectedValues = preferences.requiredResolutions,
+ values = DebridStreamResolution.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog(
+ title = "Excluded resolutions",
+ selectedValues = preferences.excludedResolutions,
+ values = DebridStreamResolution.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog(
+ title = "Preferred qualities",
+ selectedValues = preferences.preferredQualities,
+ values = DebridStreamQuality.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog(
+ title = "Required qualities",
+ selectedValues = preferences.requiredQualities,
+ values = DebridStreamQuality.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog(
+ title = "Excluded qualities",
+ selectedValues = preferences.excludedQualities,
+ values = DebridStreamQuality.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog(
+ title = "Preferred visual tags",
+ selectedValues = preferences.preferredVisualTags,
+ values = DebridStreamVisualTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog(
+ title = "Required visual tags",
+ selectedValues = preferences.requiredVisualTags,
+ values = DebridStreamVisualTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog(
+ title = "Excluded visual tags",
+ selectedValues = preferences.excludedVisualTags,
+ values = DebridStreamVisualTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog(
+ title = "Preferred audio tags",
+ selectedValues = preferences.preferredAudioTags,
+ values = DebridStreamAudioTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog(
+ title = "Required audio tags",
+ selectedValues = preferences.requiredAudioTags,
+ values = DebridStreamAudioTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog(
+ title = "Excluded audio tags",
+ selectedValues = preferences.excludedAudioTags,
+ values = DebridStreamAudioTag.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
+ title = "Preferred channels",
+ selectedValues = preferences.preferredAudioChannels,
+ values = DebridStreamAudioChannel.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
+ title = "Required channels",
+ selectedValues = preferences.requiredAudioChannels,
+ values = DebridStreamAudioChannel.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
+ title = "Excluded channels",
+ selectedValues = preferences.excludedAudioChannels,
+ values = DebridStreamAudioChannel.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog(
+ title = "Preferred encodes",
+ selectedValues = preferences.preferredEncodes,
+ values = DebridStreamEncode.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog(
+ title = "Required encodes",
+ selectedValues = preferences.requiredEncodes,
+ values = DebridStreamEncode.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog(
+ title = "Excluded encodes",
+ selectedValues = preferences.excludedEncodes,
+ values = DebridStreamEncode.defaultOrder,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog(
+ title = "Preferred languages",
+ selectedValues = preferences.preferredLanguages,
+ values = DebridStreamLanguage.entries,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog(
+ title = "Required languages",
+ selectedValues = preferences.requiredLanguages,
+ values = DebridStreamLanguage.entries,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog(
+ title = "Excluded languages",
+ selectedValues = preferences.excludedLanguages,
+ values = DebridStreamLanguage.entries,
+ label = { it.label },
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog(
+ title = "Required release groups",
+ selectedValues = preferences.requiredReleaseGroups,
+ onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) },
+ onDismiss = onDismiss,
+ )
+ DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog(
+ title = "Excluded release groups",
+ selectedValues = preferences.excludedReleaseGroups,
+ onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) },
+ onDismiss = onDismiss,
+ )
+ }
+}
+
+@Composable
+private fun DebridIntChoiceDialog(
+ title: String,
+ selectedValue: Int,
+ options: List,
+ label: @Composable (Int) -> String,
+ onSelected: (Int) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ DebridSingleChoiceDialog(
+ title = title,
+ selectedValue = selectedValue,
+ options = options,
+ label = label,
+ onSelected = onSelected,
+ onDismiss = onDismiss,
+ )
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun DebridSingleChoiceDialog(
+ title: String,
+ selectedValue: T,
+ options: List,
+ label: @Composable (T) -> String,
+ onSelected: (T) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ DebridDialogSurface(title = title) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 420.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(options) { option ->
+ DebridDialogOptionRow(
+ text = label(option),
+ selected = option == selectedValue,
+ onClick = {
+ onSelected(option)
+ onDismiss()
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun DebridMultiChoiceDialog(
+ title: String,
+ selectedValues: List,
+ values: List,
+ label: @Composable (T) -> String,
+ onSelected: (List) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var draft by remember(selectedValues) { mutableStateOf(selectedValues) }
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ DebridDialogSurface(title = title) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 420.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(values) { option ->
+ val selected = option in draft
+ DebridDialogOptionRow(
+ text = label(option),
+ selected = selected,
+ showCheckbox = true,
+ onClick = {
+ draft = if (selected) {
+ draft - option
+ } else {
+ draft + option
+ }
+ },
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ TextButton(onClick = { draft = emptyList() }) {
+ Text(stringResource(Res.string.action_clear))
+ }
+ Button(
+ onClick = {
+ onSelected(draft)
+ onDismiss()
+ },
+ ) {
+ Text(stringResource(Res.string.action_save))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun DebridTextListDialog(
+ title: String,
+ selectedValues: List,
+ onSelected: (List) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) }
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ DebridDialogSurface(title = title) {
+ Text(
+ text = "Enter one group per line.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 120.dp),
+ minLines = 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(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ TextButton(onClick = { value = "" }) {
+ Text(stringResource(Res.string.action_clear))
+ }
+ Button(
+ onClick = {
+ onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct())
+ onDismiss()
+ },
+ ) {
+ Text(stringResource(Res.string.action_save))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DebridDialogSurface(
+ title: String,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ 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 = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ content()
+ Spacer(modifier = Modifier.height(2.dp))
+ }
+ }
+}
+
+@Composable
+private fun DebridDialogOptionRow(
+ text: String,
+ selected: Boolean,
+ showCheckbox: Boolean = false,
+ onClick: () -> Unit,
+) {
+ val containerColor = if (selected) {
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
+ }
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick),
+ shape = RoundedCornerShape(12.dp),
+ color = containerColor,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ if (showCheckbox) {
+ Checkbox(
+ checked = selected,
+ onCheckedChange = { onClick() },
+ )
+ } else {
+ Box(
+ modifier = Modifier.size(24.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (selected) {
+ Icon(
+ imageVector = Icons.Rounded.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun streamMaxResultsLabel(value: Int): String =
+ if (value <= 0) "All streams" else "$value streams"
+
+private fun sortProfileLabel(value: DebridSortProfile): String =
+ when (value) {
+ DebridSortProfile.DEFAULT -> "Default"
+ DebridSortProfile.LARGEST -> "Largest first"
+ DebridSortProfile.SMALLEST -> "Smallest first"
+ DebridSortProfile.AUDIO -> "Best audio first"
+ DebridSortProfile.LANGUAGE -> "Language first"
+ }
+
+private fun debridRuleRows(preferences: DebridStreamPreferences): List =
+ listOf(
+ DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)),
+ DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)),
+ DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)),
+ DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)),
+ )
+
+private fun selectionCountLabel(values: List<*>): String =
+ if (values.isEmpty()) "Any" else "${values.size} selected"
+
+private fun sizeRangeLabel(preferences: DebridStreamPreferences): String =
+ sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb)
+
+private fun sizeRangeLabel(minGb: Int, maxGb: Int): String =
+ when {
+ minGb <= 0 && maxGb <= 0 -> "Any"
+ minGb <= 0 -> "Up to ${maxGb}GB"
+ maxGb <= 0 -> "${minGb}GB+"
+ else -> "${minGb}-${maxGb}GB"
+ }
+
+private fun sortProfileFor(criteria: List): DebridSortProfile {
+ val normalized = criteria.map { it.key to it.direction }
+ return when {
+ normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST
+ normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST
+ normalized.take(2) == listOf(
+ DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC,
+ DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC,
+ ) -> DebridSortProfile.AUDIO
+ normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE
+ else -> DebridSortProfile.DEFAULT
+ }
+}
+
+private fun sortProfileLabel(criteria: List): String =
+ sortProfileLabel(sortProfileFor(criteria))
+
+private fun sortCriteriaForProfile(profile: DebridSortProfile): List =
+ when (profile) {
+ DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder
+ DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
+ DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
+ DebridSortProfile.AUDIO -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ DebridSortProfile.LANGUAGE -> listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
+ )
+ }
+
+private data class DebridRuleRow(
+ val picker: DebridStreamPicker,
+ val title: String,
+ val description: String,
+ val value: String,
+)
+
+private enum class DebridSortProfile {
+ DEFAULT,
+ LARGEST,
+ SMALLEST,
+ AUDIO,
+ LANGUAGE,
+}
+
+private enum class DebridStreamPicker {
+ MAX_RESULTS,
+ MAX_PER_RESOLUTION,
+ MAX_PER_QUALITY,
+ SORT_MODE,
+ SIZE_RANGE,
+ PREFERRED_RESOLUTIONS,
+ REQUIRED_RESOLUTIONS,
+ EXCLUDED_RESOLUTIONS,
+ PREFERRED_QUALITIES,
+ REQUIRED_QUALITIES,
+ EXCLUDED_QUALITIES,
+ PREFERRED_VISUAL_TAGS,
+ REQUIRED_VISUAL_TAGS,
+ EXCLUDED_VISUAL_TAGS,
+ PREFERRED_AUDIO_TAGS,
+ REQUIRED_AUDIO_TAGS,
+ EXCLUDED_AUDIO_TAGS,
+ PREFERRED_AUDIO_CHANNELS,
+ REQUIRED_AUDIO_CHANNELS,
+ EXCLUDED_AUDIO_CHANNELS,
+ PREFERRED_ENCODES,
+ REQUIRED_ENCODES,
+ EXCLUDED_ENCODES,
+ PREFERRED_LANGUAGES,
+ REQUIRED_LANGUAGES,
+ EXCLUDED_LANGUAGES,
+ REQUIRED_RELEASE_GROUPS,
+ EXCLUDED_RELEASE_GROUPS,
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun DebridApiKeyDialog(
+ providerId: String,
+ title: String,
+ subtitle: String,
+ placeholder: String,
+ currentValue: String,
+ onSave: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) }
+ var isValidating by rememberSaveable(providerId) { mutableStateOf(false) }
+ var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) }
+ val normalizedDraft = draft.trim()
+ val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid)
+ val saveAndDismiss: () -> Unit = {
+ scope.launch {
+ isValidating = true
+ validationMessage = null
+ val valid = normalizedDraft.isNotBlank() && runCatching {
+ DebridCredentialValidator.validateProvider(providerId, normalizedDraft)
+ }.getOrDefault(false)
+ if (valid) {
+ onSave(normalizedDraft)
+ isValidating = false
+ onDismiss()
+ } else {
+ validationMessage = invalidMessage
+ isValidating = false
+ }
+ }
+ }
+
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ DebridDialogSurface(title = title) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = draft,
+ onValueChange = {
+ draft = it
+ validationMessage = null
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ placeholder = { Text(placeholder) },
+ 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,
+ ),
+ )
+ validationMessage?.let { message ->
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(Res.string.action_cancel))
+ }
+ TextButton(
+ onClick = {
+ onSave("")
+ onDismiss()
+ },
+ enabled = !isValidating,
+ ) {
+ Text(stringResource(Res.string.action_clear))
+ }
+ Button(
+ onClick = saveAndDismiss,
+ enabled = normalizedDraft.isNotBlank() && !isValidating,
+ ) {
+ Text(
+ if (isValidating) {
+ stringResource(Res.string.action_saving)
+ } else {
+ stringResource(Res.string.action_save)
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun maskDebridApiKey(key: String, notSetLabel: String): String {
+ val trimmed = key.trim()
+ if (trimmed.isBlank()) return notSetLabel
+ return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}"
+}
+
+@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 08864811..21442208 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
@@ -132,6 +134,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
@@ -251,6 +257,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
+ debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@@ -299,6 +306,7 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
+ debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@@ -357,6 +365,7 @@ private fun MobileSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
+ debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@@ -571,6 +580,7 @@ private fun MobileSettingsScreen(
isTablet = false,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
+ onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
@@ -580,6 +590,10 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = mdbListSettings,
)
+ SettingsPage.Debrid -> debridSettingsContent(
+ isTablet = false,
+ settings = debridSettings,
+ )
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
@@ -665,6 +679,7 @@ private fun TabletSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
+ debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@@ -937,6 +952,7 @@ private fun TabletSettingsScreen(
isTablet = true,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
+ onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
@@ -946,6 +962,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 46d38159..a5e97de5 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
@@ -15,15 +15,19 @@ object StreamAutoPlaySelector {
}
}
- if (installedOrder.isEmpty()) return groups
+ val (directDebridEntries, remainingEntries) = groups.partition { group ->
+ group.addonId.startsWith("debrid:") ||
+ group.streams.any { stream -> stream.isDirectDebridStream }
+ }
+ if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries
- val (addonEntries, pluginEntries) = groups.partition { group ->
+ val (addonEntries, pluginEntries) = remainingEntries.partition { group ->
group.addonName in addonRankByName
}
val orderedAddons = addonEntries.sortedBy { group ->
addonRankByName.getValue(group.addonName)
}
- return orderedAddons + pluginEntries
+ return directDebridEntries + orderedAddons + pluginEntries
}
fun selectAutoPlayStream(
@@ -119,5 +123,5 @@ object StreamAutoPlaySelector {
}
private fun StreamItem.isAutoPlayable(): Boolean =
- directPlaybackUrl != null
+ 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 fe223534..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
@@ -17,6 +17,7 @@ data class StreamItem(
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) }
@@ -27,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 =
@@ -53,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 72a6fc5c..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
@@ -26,8 +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()
- if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
+ // Must have at least one playable source
+ if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
@@ -44,6 +46,7 @@ object StreamParser {
sources = obj.stringList("sources"),
addonName = addonName,
addonId = addonId,
+ clientResolve = clientResolve,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
@@ -80,6 +83,11 @@ object StreamParser {
?.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
@@ -99,4 +107,67 @@ object StreamParser {
)
}
+ 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 56a76194..5441ce47 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,8 @@ 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
import com.nuvio.app.features.plugins.PluginRepository
@@ -148,6 +150,7 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
+ val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@@ -158,7 +161,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,
@@ -187,7 +190,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,
@@ -212,6 +215,13 @@ object StreamsRepository {
streams = emptyList(),
isLoading = true,
)
+ } + debridTargets.map { target ->
+ AddonStreamGroup(
+ addonName = target.addonName,
+ addonId = target.addonId,
+ streams = emptyList(),
+ isLoading = true,
+ )
}, installedAddonOrder)
_uiState.value = StreamsUiState(
requestToken = requestToken,
@@ -230,11 +240,13 @@ object StreamsRepository {
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf()
val totalTasks = streamAddons.size +
- pluginProviderGroups.sumOf { it.scrapers.size }
+ pluginProviderGroups.sumOf { it.scrapers.size } +
+ debridTargets.size
val installedAddonNames = installedAddonOrder.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" }
@@ -410,6 +422,20 @@ object StreamsRepository {
}
}
+ 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 -> {
@@ -472,6 +498,45 @@ object StreamsRepository {
}
}
+ is StreamLoadCompletion.Debrid -> {
+ val result = completion.group
+ _uiState.update { current ->
+ val updated = StreamAutoPlaySelector.orderAddonStreams(
+ groups = current.groups.map { group ->
+ if (group.addonId == result.addonId) result else group
+ },
+ installedOrder = installedAddonOrder,
+ )
+ val anyLoading = updated.any { it.isLoading }
+ current.copy(
+ groups = updated,
+ isAnyLoading = anyLoading,
+ 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,
+ ),
+ )
+ }
+ }
+ }
+ }
+ }
}
// Early match / timeout-elapsed auto-select on each addon response
@@ -612,6 +677,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,
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 41302ad0..ee5b52e0 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
@@ -866,7 +866,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)
}
},
@@ -898,7 +898,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)
}
// ---------------------------------------------------------------------------
@@ -972,7 +972,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..ad4f9eab
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
@@ -0,0 +1,148 @@
+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 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(
+ 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,
+ filename: String? = null,
+ title: String? = null,
+ ): StreamClientResolve =
+ StreamClientResolve(
+ type = "debrid",
+ service = DebridProviders.TORBOX_ID,
+ 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/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt
new file mode 100644
index 00000000..7a670339
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt
@@ -0,0 +1,45 @@
+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)
+ }
+
+ @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)
+ }
+}
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..593fa6af
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt
@@ -0,0 +1,210 @@
+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.StreamClientResolveRaw
+import com.nuvio.app.features.streams.StreamClientResolveStream
+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)
+ }
+
+ @Test
+ fun `sorts and limits streams by quality and size`() {
+ val streams = listOf(
+ stream(resolution = "1080p", size = 20),
+ stream(resolution = "2160p", size = 10),
+ stream(resolution = "2160p", size = 30),
+ stream(resolution = "720p", size = 40),
+ )
+
+ val filtered = DirectDebridStreamFilter.filterInstant(
+ streams,
+ DebridSettings(
+ streamMaxResults = 2,
+ streamSortMode = DebridStreamSortMode.QUALITY_DESC,
+ ),
+ )
+
+ assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size })
+ }
+
+ @Test
+ fun `filters minimum quality dolby vision hdr and codec`() {
+ val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10)
+ val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20)
+ val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30)
+ val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40)
+
+ val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant(
+ listOf(hdrHevc, dvHevc, sdrAvc, hdHevc),
+ DebridSettings(
+ streamMinimumQuality = DebridStreamMinimumQuality.P2160,
+ streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE,
+ streamHdrFilter = DebridStreamFeatureFilter.ONLY,
+ streamCodecFilter = DebridStreamCodecFilter.HEVC,
+ ),
+ )
+
+ assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size })
+
+ val dvOnly = DirectDebridStreamFilter.filterInstant(
+ listOf(hdrHevc, dvHevc, sdrAvc, hdHevc),
+ DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY),
+ )
+
+ assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size })
+ }
+
+ @Test
+ fun `applies stream preference filters and sort criteria`() {
+ val remuxAtmos = stream(
+ resolution = "2160p",
+ quality = "BluRay REMUX",
+ codec = "HEVC",
+ audio = listOf("Atmos", "TrueHD"),
+ channels = listOf("7.1"),
+ languages = listOf("en"),
+ group = "GOOD",
+ size = 40_000_000_000,
+ )
+ val webAac = stream(
+ resolution = "2160p",
+ quality = "WEB-DL",
+ codec = "AVC",
+ audio = listOf("AAC"),
+ channels = listOf("2.0"),
+ languages = listOf("en"),
+ group = "NOPE",
+ size = 4_000_000_000,
+ )
+ val blurayDts = stream(
+ resolution = "1080p",
+ quality = "BluRay",
+ codec = "AVC",
+ audio = listOf("DTS"),
+ channels = listOf("5.1"),
+ languages = listOf("hi"),
+ group = "GOOD",
+ size = 12_000_000_000,
+ )
+
+ val filtered = DirectDebridStreamFilter.filterInstant(
+ listOf(webAac, blurayDts, remuxAtmos),
+ DebridSettings(
+ streamPreferences = DebridStreamPreferences(
+ maxResults = 2,
+ maxPerResolution = 1,
+ sizeMinGb = 5,
+ requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080),
+ excludedQualities = listOf(DebridStreamQuality.WEB_DL),
+ requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1),
+ excludedEncodes = listOf(DebridStreamEncode.UNKNOWN),
+ excludedLanguages = listOf(DebridStreamLanguage.IT),
+ requiredReleaseGroups = listOf("GOOD"),
+ sortCriteria = listOf(
+ DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
+ DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC),
+ ),
+ ),
+ ),
+ )
+
+ assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size })
+ }
+
+ private fun stream(
+ service: String? = DebridProviders.TORBOX_ID,
+ cached: Boolean? = true,
+ type: String = "debrid",
+ infoHash: String = "hash",
+ fileIdx: Int = 1,
+ resolution: String? = null,
+ quality: String? = null,
+ hdr: List = emptyList(),
+ codec: String? = null,
+ audio: List = emptyList(),
+ channels: List = emptyList(),
+ languages: List = emptyList(),
+ group: String? = null,
+ size: Long? = null,
+ ): StreamItem =
+ StreamItem(
+ name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}",
+ description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}",
+ addonName = "Direct Debrid",
+ addonId = "debrid",
+ clientResolve = StreamClientResolve(
+ type = type,
+ service = service,
+ isCached = cached,
+ infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(),
+ fileIdx = fileIdx,
+ filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv",
+ torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}",
+ stream = StreamClientResolveStream(
+ raw = StreamClientResolveRaw(
+ torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}",
+ filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv",
+ size = size,
+ folderSize = size,
+ parsed = StreamClientResolveParsed(
+ resolution = resolution,
+ quality = quality,
+ hdr = hdr,
+ codec = codec,
+ audio = audio,
+ channels = channels,
+ languages = languages,
+ group = group,
+ ),
+ ),
+ ),
+ ),
+ )
+}
+
+private fun Long?.orEmptyHashPart(): String =
+ this?.toString().orEmpty()
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/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 09434fac..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
@@ -120,4 +120,55 @@ class StreamParserTest {
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..dc85c449
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
@@ -0,0 +1,193 @@
+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
+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 instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
+ private const val streamMaxResultsKey = "debrid_stream_max_results"
+ private const val streamSortModeKey = "debrid_stream_sort_mode"
+ private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
+ private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
+ private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
+ private const val streamCodecFilterKey = "debrid_stream_codec_filter"
+ private const val streamPreferencesKey = "debrid_stream_preferences"
+ private const val streamNameTemplateKey = "debrid_stream_name_template"
+ private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
+ private val syncKeys = listOf(
+ enabledKey,
+ torboxApiKeyKey,
+ realDebridApiKeyKey,
+ instantPlaybackPreparationLimitKey,
+ streamMaxResultsKey,
+ streamSortModeKey,
+ streamMinimumQualityKey,
+ streamDolbyVisionFilterKey,
+ streamHdrFilterKey,
+ streamCodecFilterKey,
+ streamPreferencesKey,
+ 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 loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
+
+ actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
+ saveInt(instantPlaybackPreparationLimitKey, limit)
+ }
+
+ actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
+
+ actual fun saveStreamMaxResults(maxResults: Int) {
+ saveInt(streamMaxResultsKey, maxResults)
+ }
+
+ actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
+
+ actual fun saveStreamSortMode(mode: String) {
+ saveString(streamSortModeKey, mode)
+ }
+
+ actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
+
+ actual fun saveStreamMinimumQuality(quality: String) {
+ saveString(streamMinimumQualityKey, quality)
+ }
+
+ actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
+
+ actual fun saveStreamDolbyVisionFilter(filter: String) {
+ saveString(streamDolbyVisionFilterKey, filter)
+ }
+
+ actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
+
+ actual fun saveStreamHdrFilter(filter: String) {
+ saveString(streamHdrFilterKey, filter)
+ }
+
+ actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
+
+ actual fun saveStreamCodecFilter(filter: String) {
+ saveString(streamCodecFilterKey, filter)
+ }
+
+ actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
+
+ actual fun saveStreamPreferences(preferences: String) {
+ saveString(streamPreferencesKey, preferences)
+ }
+
+ 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 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))
+
+ 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)) }
+ loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
+ loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
+ loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
+ loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
+ loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
+ loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
+ loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
+ loadStreamPreferences()?.let { put(streamPreferencesKey, 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.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
+ payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
+ payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
+ payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
+ payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
+ payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
+ payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
+ payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
+ payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
+ payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
+ }
+}