From 4e5a32510b64370284e75217d6e017cbe2d0a218 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 20 May 2026 21:44:27 +0530
Subject: [PATCH] removal: debrid integration
---
composeApp/build.gradle.kts | 13 -
.../kotlin/com/nuvio/app/MainActivity.kt | 2 -
.../debrid/DebridSettingsStorage.android.kt | 210 ---
.../composeResources/values-no/strings.xml | 25 -
.../composeResources/values-pl/strings.xml | 32 -
.../composeResources/values/strings.xml | 32 -
.../commonMain/kotlin/com/nuvio/app/App.kt | 86 +-
.../app/core/sync/ProfileSettingsSync.kt | 10 -
.../app/features/debrid/DebridApiClients.kt | 244 ----
.../app/features/debrid/DebridApiModels.kt | 94 --
.../features/debrid/DebridFileSelectors.kt | 169 ---
.../app/features/debrid/DebridProvider.kt | 83 --
.../app/features/debrid/DebridSettings.kt | 256 ----
.../debrid/DebridSettingsRepository.kt | 419 ------
.../features/debrid/DebridSettingsStorage.kt | 34 -
.../features/debrid/DebridStreamFormatter.kt | 143 --
.../debrid/DebridStreamFormatterDefaults.kt | 8 -
.../debrid/DebridStreamTemplateEngine.kt | 394 -----
.../app/features/debrid/DebridUrlEncoding.kt | 38 -
.../debrid/DirectDebridConfigEncoder.kt | 39 -
.../features/debrid/DirectDebridResolver.kt | 375 -----
.../debrid/DirectDebridStreamFilter.kt | 425 ------
.../debrid/DirectDebridStreamPreparer.kt | 196 ---
.../debrid/DirectDebridStreamSource.kt | 253 ----
.../app/features/details/MetaDetailsScreen.kt | 16 -
.../features/player/PlayerEpisodesPanel.kt | 2 +-
.../nuvio/app/features/player/PlayerScreen.kt | 72 -
.../app/features/player/PlayerSourcesPanel.kt | 2 +-
.../player/PlayerStreamsRepository.kt | 52 +-
.../features/settings/DebridSettingsPage.kt | 1295 -----------------
.../settings/IntegrationsSettingsPage.kt | 13 -
.../app/features/settings/SettingsModels.kt | 6 -
.../app/features/settings/SettingsScreen.kt | 20 -
.../streams/StreamAutoPlaySelector.kt | 12 +-
.../app/features/streams/StreamModels.kt | 75 +-
.../app/features/streams/StreamParser.kt | 73 +-
.../app/features/streams/StreamsRepository.kt | 72 +-
.../app/features/streams/StreamsScreen.kt | 6 +-
.../features/debrid/DebridFileSelectorTest.kt | 148 --
.../debrid/DebridStreamFormatterTest.kt | 122 --
.../debrid/DebridStreamTemplateEngineTest.kt | 45 -
.../debrid/DirectDebridConfigEncoderTest.kt | 27 -
.../debrid/DirectDebridStreamFilterTest.kt | 210 ---
.../debrid/DirectDebridStreamPreparerTest.kt | 70 -
.../streams/StreamAutoPlaySelectorTest.kt | 33 -
.../app/features/streams/StreamParserTest.kt | 51 -
.../debrid/DebridSettingsStorage.ios.kt | 193 ---
47 files changed, 19 insertions(+), 6176 deletions(-)
delete mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
delete mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt
delete mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt
delete mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index 89ebdf46..5c5811e4 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -90,19 +90,6 @@ 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 94036653..2c8bebbb 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -15,7 +15,6 @@ 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
@@ -75,7 +74,6 @@ 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
deleted file mode 100644
index d1ff44e5..00000000
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-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 06b43610..89e10815 100644
--- a/composeApp/src/commonMain/composeResources/values-no/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml
@@ -379,7 +379,6 @@
Utseende
Innhold & oppdagelse
Fortsett å se
- Debrid
Hjemmeoppsett
Integrasjoner
Lisenser & attribusjon
@@ -588,27 +587,6 @@
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
@@ -1144,9 +1122,6 @@
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 330c2cd6..3fa135b3 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -380,7 +380,6 @@
Wygląd
Treści i odkrywanie
Kontynuuj oglądanie
- Debrid
Ekran główny
Integracje
Licencje i atrybucje
@@ -589,34 +588,6 @@
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
@@ -1154,9 +1125,6 @@
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 601a432c..cc326794 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -380,7 +380,6 @@
Layout
Content & Discovery
Continue Watching
- Debrid
Home Layout
Integrations
Licenses & Attribution
@@ -589,34 +588,6 @@
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
@@ -1154,9 +1125,6 @@
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 e486476d..10f6361d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -106,9 +106,6 @@ 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
@@ -1360,8 +1357,6 @@ 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 ->
@@ -1511,31 +1506,7 @@ private fun MainAppContent(
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) 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 stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
@@ -1613,41 +1584,6 @@ 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(
@@ -1751,26 +1687,6 @@ 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 aacb5336..58df719e 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,8 +6,6 @@ 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
@@ -159,7 +157,6 @@ 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" },
@@ -205,7 +202,6 @@ 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(),
@@ -230,9 +226,6 @@ object ProfileSettingsSync {
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
- DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
- DebridSettingsRepository.onProfileChanged()
-
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
TmdbSettingsRepository.onProfileChanged()
@@ -262,7 +255,6 @@ object ProfileSettingsSync {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded()
- DebridSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
@@ -285,7 +277,6 @@ 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}",
@@ -308,7 +299,6 @@ 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
deleted file mode 100644
index cc89019a..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt
+++ /dev/null
@@ -1,244 +0,0 @@
-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
deleted file mode 100644
index 50a89fde..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-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
deleted file mode 100644
index 0718df7a..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt
+++ /dev/null
@@ -1,169 +0,0 @@
-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
deleted file mode 100644
index c37e584d..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-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
deleted file mode 100644
index 6e48cc07..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt
+++ /dev/null
@@ -1,256 +0,0 @@
-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
deleted file mode 100644
index d8c7625b..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt
+++ /dev/null
@@ -1,419 +0,0 @@
-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
deleted file mode 100644
index 62fddac4..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-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
deleted file mode 100644
index dd73d303..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt
+++ /dev/null
@@ -1,143 +0,0 @@
-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
deleted file mode 100644
index bb5d25b3..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-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
deleted file mode 100644
index 23e635e9..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt
+++ /dev/null
@@ -1,394 +0,0 @@
-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
deleted file mode 100644
index 2d9465d1..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-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
deleted file mode 100644
index 855e9124..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-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
deleted file mode 100644
index 6b8e3425..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt
+++ /dev/null
@@ -1,375 +0,0 @@
-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
deleted file mode 100644
index 6647d607..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt
+++ /dev/null
@@ -1,425 +0,0 @@
-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
deleted file mode 100644
index 61952674..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt
+++ /dev/null
@@ -1,196 +0,0 @@
-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
deleted file mode 100644
index 6cd5573d..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt
+++ /dev/null
@@ -1,253 +0,0 @@
-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 99493f36..d8bfbf27 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,7 +62,6 @@ 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
@@ -373,16 +372,6 @@ 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()
}
@@ -1270,8 +1259,3 @@ 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 255205cd..69eb462e 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.clientResolve?.infoHash ?: stream.name}" },
+ key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.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 a18b195d..fcfcadd1 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,10 +38,6 @@ 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
@@ -861,55 +857,7 @@ 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)
@@ -951,26 +899,6 @@ 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 d57dd46d..9e64a911 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.clientResolve?.infoHash ?: stream.name}" },
+ key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.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 013460c3..24ea2129 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,8 +5,6 @@ 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
@@ -156,10 +154,6 @@ 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)
@@ -167,7 +161,7 @@ object PlayerStreamsRepository {
emptyList()
}
- if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
+ if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@@ -193,7 +187,7 @@ object PlayerStreamsRepository {
)
}
- if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
+ if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@@ -216,13 +210,6 @@ 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,
@@ -291,24 +278,13 @@ object PlayerStreamsRepository {
}
}
- val debridJobs = debridTargets.map { target ->
- async {
- DirectDebridStreamSource.fetchProviderStreams(
- type = type,
- videoId = videoId,
- target = target,
- )
- }
- }
-
- val jobs = addonJobs + pluginJobs + debridJobs
+ val jobs = addonJobs + pluginJobs
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 ->
@@ -329,28 +305,6 @@ 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
deleted file mode 100644
index 30d59534..00000000
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt
+++ /dev/null
@@ -1,1295 +0,0 @@
-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 a4999c89..7602c3e2 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,14 +1,10 @@
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
@@ -17,7 +13,6 @@ internal fun LazyListScope.integrationsContent(
isTablet: Boolean,
onTmdbClick: () -> Unit,
onMdbListClick: () -> Unit,
- onDebridClick: () -> Unit,
) {
item {
SettingsSection(
@@ -40,14 +35,6 @@ 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 a6eb2a40..d030a785 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,7 +13,6 @@ 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
@@ -130,11 +129,6 @@ 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 21442208..08864811 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,8 +59,6 @@ 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
@@ -134,10 +132,6 @@ fun SettingsScreen(
MdbListSettingsRepository.ensureLoaded()
MdbListSettingsRepository.uiState
}.collectAsStateWithLifecycle()
- val debridSettings by remember {
- DebridSettingsRepository.ensureLoaded()
- DebridSettingsRepository.uiState
- }.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
@@ -257,7 +251,6 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
- debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@@ -306,7 +299,6 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
- debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@@ -365,7 +357,6 @@ private fun MobileSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
- debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@@ -580,7 +571,6 @@ private fun MobileSettingsScreen(
isTablet = false,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
- onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
@@ -590,10 +580,6 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = mdbListSettings,
)
- SettingsPage.Debrid -> debridSettingsContent(
- isTablet = false,
- settings = debridSettings,
- )
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
@@ -679,7 +665,6 @@ private fun TabletSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
- debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@@ -952,7 +937,6 @@ private fun TabletSettingsScreen(
isTablet = true,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
- onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
@@ -962,10 +946,6 @@ 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 a5e97de5..46d38159 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,19 +15,15 @@ object StreamAutoPlaySelector {
}
}
- val (directDebridEntries, remainingEntries) = groups.partition { group ->
- group.addonId.startsWith("debrid:") ||
- group.streams.any { stream -> stream.isDirectDebridStream }
- }
- if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries
+ if (installedOrder.isEmpty()) return groups
- val (addonEntries, pluginEntries) = remainingEntries.partition { group ->
+ val (addonEntries, pluginEntries) = groups.partition { group ->
group.addonName in addonRankByName
}
val orderedAddons = addonEntries.sortedBy { group ->
addonRankByName.getValue(group.addonName)
}
- return directDebridEntries + orderedAddons + pluginEntries
+ return orderedAddons + pluginEntries
}
fun selectAutoPlayStream(
@@ -123,5 +119,5 @@ object StreamAutoPlaySelector {
}
private fun StreamItem.isAutoPlayable(): Boolean =
- directPlaybackUrl != null || isDirectDebridStream
+ directPlaybackUrl != null
}
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 0b3d8b24..fe223534 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,7 +17,6 @@ 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) }
@@ -28,18 +27,13 @@ data class StreamItem(
val directPlaybackUrl: String?
get() = url ?: externalUrl
- val isDirectDebridStream: Boolean
- get() = clientResolve?.isDirectDebridCandidate == true
-
val isTorrentStream: Boolean
- get() = !isDirectDebridStream && (
- !infoHash.isNullOrBlank() ||
+ get() = !infoHash.isNullOrBlank() ||
url.isMagnetLink() ||
externalUrl.isMagnetLink()
- )
val hasPlayableSource: Boolean
- get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
+ get() = url != null || infoHash != null || externalUrl != null
}
private fun String?.isMagnetLink(): Boolean =
@@ -59,71 +53,6 @@ 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 9a6aa866..72a6fc5c 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,10 +26,8 @@ object StreamParser {
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
- val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
- // Must have at least one playable source
- if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
+ if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
@@ -46,7 +44,6 @@ 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,
@@ -83,11 +80,6 @@ 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
@@ -107,67 +99,4 @@ 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 5441ce47..56a76194 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,8 +5,6 @@ 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
@@ -150,7 +148,6 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
- val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@@ -161,7 +158,7 @@ object StreamsRepository {
groupByRepository = pluginUiState.groupStreamsByRepository,
)
- if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
+ if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@@ -190,7 +187,7 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
- if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
+ if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@@ -215,13 +212,6 @@ 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,
@@ -240,13 +230,11 @@ object StreamsRepository {
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf()
val totalTasks = streamAddons.size +
- pluginProviderGroups.sumOf { it.scrapers.size } +
- debridTargets.size
+ pluginProviderGroups.sumOf { it.scrapers.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" }
@@ -422,20 +410,6 @@ 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 -> {
@@ -498,45 +472,6 @@ 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
@@ -677,7 +612,6 @@ 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 ee5b52e0..41302ad0 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 || stream.isDirectDebridStream) {
+ if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
}
},
@@ -898,7 +898,7 @@ internal fun streamCardRenderKey(
append(':')
append(itemIndex)
append(':')
- append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
+ append(stream.url ?: stream.infoHash ?: stream.streamLabel)
}
// ---------------------------------------------------------------------------
@@ -972,7 +972,7 @@ private fun StreamCard(
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
- val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream
+ val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
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
deleted file mode 100644
index ad4f9eab..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-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
deleted file mode 100644
index 83b127cc..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-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
deleted file mode 100644
index 7a670339..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-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
deleted file mode 100644
index 15fcf1e2..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index 593fa6af..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt
+++ /dev/null
@@ -1,210 +0,0 @@
-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
deleted file mode 100644
index 68acd752..00000000
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt
+++ /dev/null
@@ -1,70 +0,0 @@
-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 1ebf6b84..45fa4740 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,49 +145,16 @@ 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 9260e883..09434fac 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,55 +120,4 @@ 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
deleted file mode 100644
index dc85c449..00000000
--- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt
+++ /dev/null
@@ -1,193 +0,0 @@
-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)
- }
-}