From 5d9a78913960a4d43c0eb9452d726c0e10227cf8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 22 May 2026 15:43:50 +0530 Subject: [PATCH] Revert "removal: debrid integration" This reverts commit 4e5a32510b64370284e75217d6e017cbe2d0a218. --- 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, 6176 insertions(+), 19 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5c5811e4..89ebdf46 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/debrid").apply { + mkdirs() + resolve("DebridConfig.kt").writeText( + """ + |package com.nuvio.app.features.debrid + | + |object DebridConfig { + | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 2c8bebbb..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext) + DebridSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt new file mode 100644 index 00000000..d1ff44e5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -0,0 +1,210 @@ +package com.nuvio.app.features.debrid + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +actual object DebridSettingsStorage { + private const val preferencesName = "nuvio_debrid_settings" + private const val enabledKey = "debrid_enabled" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val syncKeys = listOf( + enabledKey, + torboxApiKeyKey, + realDebridApiKeyKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getBoolean(scopedKey, false) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(key), enabled) + ?.apply() + } + + private fun loadInt(key: String): Int? = + preferences?.let { sharedPreferences -> + val scopedKey = ProfileScopedKey.of(key) + if (sharedPreferences.contains(scopedKey)) { + sharedPreferences.getInt(scopedKey, 0) + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + preferences + ?.edit() + ?.putInt(ProfileScopedKey.of(key), value) + ?.apply() + } + + private fun loadString(key: String): String? = + preferences?.getString(ProfileScopedKey.of(key), null) + + private fun saveString(key: String, value: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(key), value) + ?.apply() + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + preferences?.edit()?.apply { + syncKeys.forEach { remove(ProfileScopedKey.of(it)) } + }?.apply() + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 89e10815..06b43610 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -379,6 +379,7 @@ Utseende Innhold & oppdagelse Fortsett å se + Debrid Hjemmeoppsett Integrasjoner Lisenser & attribusjon @@ -587,6 +588,27 @@ Integrasjoner Metadata-berikelse-kontroller Eksterne vurderingsleverandører + Eksperimentelle sky-konto-kilder + Debrid + Debrid-støtte er eksperimentell og kan endres eller fjernes senere. + Aktiver kilder + Vis spillbare resultater fra tilkoblede kontoer. + Legg til en API-nøkkel først. + Konto + Koble til Torbox-kontoen din. + Umiddelbar avspilling + Forbered lenker + Løs første kilder før avspilling starter. + Kilder å forberede + 1 kilde + %1$d kilder + Formatering + Navnemal + Styrer hvordan kildenavn vises. + Beskrivelsesmal + Styrer metadata vist under hver kilde. + API-nøkkel validert. + Kunne ikke validere denne API-nøkkelen. Legg til MDBList API-nøkkel før du skrur på vurderinger. Kreves for å hente vurderinger fra MDBList API-nøkkel @@ -1122,6 +1144,9 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strømtypen støttes ikke + Legg til en Debrid API-nøkkel i Innstillinger. + Dette Debrid-resultatet er utgått. Oppdaterer strømmer. + Kunne ikke løse denne Debrid-strømmen. Kunne ikke åpne ekstern avspiller Velg en ekstern avspiller i innstillinger først Ingen ekstern avspiller er tilgjengelig diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3fa135b3..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -380,6 +380,7 @@ Wygląd Treści i odkrywanie Kontynuuj oglądanie + Debrid Ekran główny Integracje Licencje i atrybucje @@ -588,6 +589,34 @@ INTEGRACJE Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Eksperymentalne źródła z kont chmurowych + Debrid + Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Włącz źródła + Pokaż odtwarzalne wyniki z połączonych kont. + Najpierw dodaj klucz API. + Konto + Połącz swoje konto Torbox. + Klucz API Torbox + Wprowadź swój klucz API Torbox. + Wprowadź klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. + Źródła do przygotowania + Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. + 1 źródło + %1$d źródeł + Formatowanie + Szablon nazwy + Kontroluje sposób wyświetlania nazw źródeł. + Szablon opisu + Kontroluje metadane wyświetlane pod każdym źródłem. + Resetuj formatowanie + Przywróć domyślne formatowanie źródeł. + Klucz API zweryfikowany. + Nie udało się zweryfikować tego klucza API. Dodaj klucz API MDBList poniżej przed włączeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -1125,6 +1154,9 @@ Wznów od %1$s ROZMIAR %1$s Ten typ strumienia nie jest obsługiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasł. Odświeżanie strumieni. + Nie udało się rozwiązać tego strumienia Debrid. Nie udało się otworzyć zewnętrznego odtwarzacza Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach Brak dostępnego zewnętrznego odtwarzacza diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 8f585ada..3267280d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -380,6 +380,7 @@ Layout Content & Discovery Continue Watching + Debrid Home Layout Integrations Licenses & Attribution @@ -588,6 +589,34 @@ Integrations Metadata enrichment controls External ratings providers + Experimental cloud account sources + Debrid + Debrid support is experimental and may be kept, changed, or removed later. + Enable sources + Show playable results from connected accounts. + Add an API key first. + Account + Connect your Torbox account. + Torbox API Key + Enter your Torbox API key. + Enter Torbox API key + Not set + Instant Playback + Prepare links + Resolve the first sources before playback starts. + Sources to prepare + Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + 1 source + %1$d sources + Formatting + Name template + Controls how source names appear. + Description template + Controls the metadata shown under each source. + Reset formatting + Restore default source formatting. + API key validated. + Could not validate this API key. Add your MDBList API key below before turning ratings on. Required to fetch ratings from MDBList API Key @@ -1126,6 +1155,9 @@ Resume from %1$s SIZE %1$s This stream type is not supported + Add a Debrid API key in Settings. + This Debrid result expired. Refreshing streams. + Could not resolve this Debrid stream. Couldn't open external player Choose an external player in settings first No external player is available diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 10f6361d..e486476d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,9 @@ import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.downloads.DownloadsScreen import com.nuvio.app.features.details.MetaDetailsRepository @@ -1357,6 +1360,8 @@ private fun MainAppContent( return@composable } val pauseDescription = launch.pauseDescription + val streamRouteScope = rememberCoroutineScope() + var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } val lifecycleOwner = backStackEntry DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> @@ -1506,7 +1511,31 @@ private fun MainAppContent( if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect - val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val stream = when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + StreamsRepository.consumeAutoPlay() + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect + } + } val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { @@ -1584,6 +1613,41 @@ private fun MainAppContent( forceExternal: Boolean, forceInternal: Boolean, ) { + if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) { + if (resolvingDebridStream) return + streamRouteScope.launch { + resolvingDebridStream = true + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + resolvingDebridStream = false + when (resolved) { + is DirectDebridPlayableResult.Success -> openSelectedStream( + stream = resolved.stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = forceExternal, + forceInternal = forceInternal, + ) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + } + } + } + return + } val sourceUrl = stream.directPlaybackUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1687,6 +1751,26 @@ private fun MainAppContent( }, modifier = Modifier.fillMaxSize(), ) + if (resolvingDebridStream) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.82f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = Color.White) + Text( + text = stringResource(Res.string.streams_finding_source), + color = Color.White.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } composable( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 58df719e..aacb5336 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionMobileSettingsStorage +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -157,6 +159,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, + DebridSettingsRepository.uiState.map { "debrid" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, @@ -202,6 +205,7 @@ object ProfileSettingsSync { themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), @@ -226,6 +230,9 @@ object ProfileSettingsSync { PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() + DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings) + DebridSettingsRepository.onProfileChanged() + TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings) TmdbSettingsRepository.onProfileChanged() @@ -255,6 +262,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() + DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() @@ -277,6 +285,7 @@ object ProfileSettingsSync { "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", + "debrid=${DebridSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", @@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt new file mode 100644 index 00000000..cc89019a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -0,0 +1,244 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +internal data class DebridApiResponse( + val status: Int, + val body: T?, + val rawBody: String, +) { + val isSuccessful: Boolean + get() = status in 200..299 +} + +internal object DebridApiJson { + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } +} + +internal object TorboxApiClient { + private const val BASE_URL = "https://api.torbox.app" + + suspend fun validateApiKey(apiKey: String): Boolean = + getUser(apiKey.trim()).status in 200..299 + + private suspend fun getUser(apiKey: String): RawHttpResponse = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/v1/api/user/me", + headers = authHeaders(apiKey), + body = "", + ) + + suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse> { + val boundary = "NuvioDebrid${magnet.hashCode().toUInt()}" + val body = multipartFormBody( + boundary = boundary, + "magnet" to magnet, + "add_only_if_cached" to "true", + "allow_zip" to "false", + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/createtorrent", + apiKey = apiKey, + body = body, + contentType = "multipart/form-data; boundary=$boundary", + ) + } + + suspend fun getTorrent(apiKey: String, id: Int): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist?${ + queryString( + "id" to id.toString(), + "bypass_cache" to "true", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestDownloadLink( + apiKey: String, + torrentId: Int, + fileId: Int?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/requestdl?${ + queryString( + "token" to apiKey, + "torrent_id" to torrentId.toString(), + "file_id" to fileId?.toString(), + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +internal object RealDebridApiClient { + private const val BASE_URL = "https://api.real-debrid.com/rest/1.0" + + suspend fun validateApiKey(apiKey: String): Boolean = + httpRequestRaw( + method = "GET", + url = "$BASE_URL/user", + headers = authHeaders(apiKey.trim()), + body = "", + ).status in 200..299 + + suspend fun addMagnet(apiKey: String, magnet: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/addMagnet", + apiKey = apiKey, + fields = listOf("magnet" to magnet), + ) + + suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/torrents/info/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/torrents/selectFiles/${encodePathSegment(id)}", + apiKey = apiKey, + fields = listOf("files" to files), + ) + + suspend fun unrestrictLink(apiKey: String, link: String): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/unrestrict/link", + apiKey = apiKey, + fields = listOf("link" to link), + ) + + suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse = + request( + method = "DELETE", + url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}", + apiKey = apiKey, + ) + + private suspend inline fun formRequest( + method: String, + url: String, + apiKey: String, + fields: List>, + ): DebridApiResponse { + val body = fields.joinToString("&") { (key, value) -> + "${encodeFormValue(key)}=${encodeFormValue(value)}" + } + return request( + method = method, + url = url, + apiKey = apiKey, + body = body, + contentType = "application/x-www-form-urlencoded", + ) + } + + private suspend inline fun request( + method: String, + url: String, + apiKey: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = authHeaders(apiKey) + listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ) + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") +} + +object DebridCredentialValidator { + suspend fun validateProvider(providerId: String, apiKey: String): Boolean { + val normalized = apiKey.trim() + if (normalized.isBlank()) return false + return when (DebridProviders.byId(providerId)?.id) { + DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized) + DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized) + else -> false + } + } +} + +private inline fun RawHttpResponse.decodeBody(): T? { + if (body.isBlank() || T::class == Unit::class) return null + return try { + DebridApiJson.json.decodeFromString(body) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } +} + +private fun multipartFormBody(boundary: String, vararg fields: Pair): String = + buildString { + fields.forEach { (name, value) -> + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n") + append(value).append("\r\n") + } + append("--").append(boundary).append("--\r\n") + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt new file mode 100644 index 00000000..50a89fde --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class TorboxEnvelopeDto( + val success: Boolean? = null, + val data: T? = null, + val error: String? = null, + val detail: String? = null, +) + +@Serializable +internal data class TorboxCreateTorrentDataDto( + @SerialName("torrent_id") val torrentId: Int? = null, + val id: Int? = null, + val hash: String? = null, + @SerialName("auth_id") val authId: String? = null, +) { + fun resolvedTorrentId(): Int? = torrentId ?: id +} + +@Serializable +internal data class TorboxTorrentDataDto( + val id: Int? = null, + val hash: String? = null, + val name: String? = null, + val files: List? = null, +) + +@Serializable +internal data class TorboxTorrentFileDto( + val id: Int? = null, + val name: String? = null, + @SerialName("short_name") val shortName: String? = null, + @SerialName("absolute_path") val absolutePath: String? = null, + @SerialName("mimetype") val mimeType: String? = null, + val size: Long? = null, +) { + fun displayName(): String = + listOfNotNull(name, shortName, absolutePath) + .firstOrNull { it.isNotBlank() } + .orEmpty() +} + +@Serializable +internal data class RealDebridAddTorrentDto( + val id: String? = null, + val uri: String? = null, +) + +@Serializable +internal data class RealDebridTorrentInfoDto( + val id: String? = null, + val filename: String? = null, + @SerialName("original_filename") val originalFilename: String? = null, + val hash: String? = null, + val bytes: Long? = null, + @SerialName("original_bytes") val originalBytes: Long? = null, + val host: String? = null, + val split: Int? = null, + val progress: Int? = null, + val status: String? = null, + val files: List? = null, + val links: List? = null, +) + +@Serializable +internal data class RealDebridTorrentFileDto( + val id: Int? = null, + val path: String? = null, + val bytes: Long? = null, + val selected: Int? = null, +) { + fun displayName(): String = + path.orEmpty().substringAfterLast('/').ifBlank { path.orEmpty() } +} + +@Serializable +internal data class RealDebridUnrestrictLinkDto( + val id: String? = null, + val filename: String? = null, + val mimeType: String? = null, + val filesize: Long? = null, + val link: String? = null, + val host: String? = null, + val chunks: Int? = null, + val crc: Int? = null, + val download: String? = null, + val streamable: Int? = null, + val type: String? = null, +) + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt new file mode 100644 index 00000000..0718df7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve + +internal class TorboxFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): TorboxTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } + } + + return playable.maxByOrNull { it.size ?: 0L } + } + + private fun TorboxTorrentFileDto.isPlayableVideo(): Boolean { + val mime = mimeType.orEmpty().lowercase() + if (mime.startsWith("video/")) return true + return displayName().lowercase().hasVideoExtension() + } +} + +internal class RealDebridFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): RealDebridTorrentFileDto? { + val playable = files.filter { it.isPlayableVideo() } + if (playable.isEmpty()) return null + + val episodePatterns = buildEpisodePatterns( + season = season ?: resolve.season, + episode = episode ?: resolve.episode, + ) + val names = resolve.specificFileNames(episodePatterns) + if (names.isNotEmpty()) { + playable.firstNameMatch(names) { it.displayName() }?.let { + return it + } + } + + if (episodePatterns.isNotEmpty()) { + playable.firstOrNull { file -> + val fileName = file.displayName().lowercase() + episodePatterns.any { pattern -> fileName.contains(pattern) } + }?.let { + return it + } + } + + resolve.fileIdx?.let { fileIdx -> + files.getOrNull(fileIdx)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + if (fileIdx > 0) { + files.getOrNull(fileIdx - 1)?.takeIf { it.isPlayableVideo() }?.let { + return it + } + } + playable.firstOrNull { it.id == fileIdx }?.let { + return it + } + } + + return playable.maxByOrNull { it.bytes ?: 0L } + } + + private fun RealDebridTorrentFileDto.isPlayableVideo(): Boolean = + displayName().lowercase().hasVideoExtension() +} + +private fun String.normalizedName(): String = + substringAfterLast('/') + .substringBeforeLast('.') + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + +private fun StreamClientResolve.specificFileNames(episodePatterns: List): List { + val raw = stream?.raw + return listOfNotNull( + filename, + raw?.filename, + raw?.parsed?.rawTitle?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + torrentName?.takeIf { it.looksSpecificForSelection(episodePatterns) }, + ) + .map { it.normalizedName() } + .filter { it.isNotBlank() } + .distinct() +} + +private fun String.looksSpecificForSelection(episodePatterns: List): Boolean { + val lower = lowercase() + return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) } +} + +private fun List.firstNameMatch( + names: List, + displayName: (T) -> String, +): T? = + firstOrNull { item -> + val fileName = displayName(item).normalizedName() + names.any { name -> fileName.contains(name) || name.contains(fileName) } + } + +private fun buildEpisodePatterns(season: Int?, episode: Int?): List { + if (season == null || episode == null) return emptyList() + val seasonTwo = season.toString().padStart(2, '0') + val episodeTwo = episode.toString().padStart(2, '0') + return listOf( + "s${seasonTwo}e$episodeTwo", + "${season}x$episodeTwo", + "${season}x$episode", + ) +} + +private fun String.hasVideoExtension(): Boolean = + videoExtensions.any { endsWith(it) } + +private val videoExtensions = setOf( + ".mp4", + ".mkv", + ".webm", + ".avi", + ".mov", + ".m4v", + ".ts", + ".m2ts", + ".wmv", + ".flv", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt new file mode 100644 index 00000000..c37e584d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -0,0 +1,83 @@ +package com.nuvio.app.features.debrid + +data class DebridProvider( + val id: String, + val displayName: String, + val shortName: String, + val visibleInUi: Boolean = true, +) + +data class DebridServiceCredential( + val provider: DebridProvider, + val apiKey: String, +) + +object DebridProviders { + const val TORBOX_ID = "torbox" + const val REAL_DEBRID_ID = "realdebrid" + + val Torbox = DebridProvider( + id = TORBOX_ID, + displayName = "Torbox", + shortName = "TB", + ) + + val RealDebrid = DebridProvider( + id = REAL_DEBRID_ID, + displayName = "Real-Debrid", + shortName = "RD", + visibleInUi = false, + ) + + private val registered = listOf(Torbox, RealDebrid) + + fun all(): List = registered + + fun visible(): List = registered.filter { it.visibleInUi } + + fun byId(id: String?): DebridProvider? { + val normalized = id?.trim()?.takeIf { it.isNotBlank() } ?: return null + return registered.firstOrNull { it.id.equals(normalized, ignoreCase = true) } + } + + fun isSupported(id: String?): Boolean = byId(id) != null + + fun isVisible(id: String?): Boolean = byId(id)?.visibleInUi == true + + fun instantName(id: String?): String = "${displayName(id)} Instant" + + fun addonId(id: String?): String = + "debrid:${byId(id)?.id ?: id?.trim().orEmpty().ifBlank { "unknown" }}" + + fun displayName(id: String?): String = + byId(id)?.displayName ?: id.toFallbackDisplayName() + + fun shortName(id: String?): String = + byId(id)?.shortName ?: id?.trim()?.takeIf { it.isNotBlank() }?.uppercase().orEmpty() + + fun configuredServices(settings: DebridSettings): List = + buildList { + settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey -> + add(DebridServiceCredential(Torbox, apiKey)) + } + settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey -> + add(DebridServiceCredential(RealDebrid, apiKey)) + } + } + + fun configuredSourceNames(settings: DebridSettings): List = + configuredServices(settings).map { instantName(it.provider.id) } + + private fun String?.toFallbackDisplayName(): String { + val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return "Debrid" + return value + .replace('-', ' ') + .replace('_', ' ') + .split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { part -> + part.lowercase().replaceFirstChar { it.titlecase() } + } + .ifBlank { "Debrid" } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt new file mode 100644 index 00000000..6e48cc07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -0,0 +1,256 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.Serializable + +data class DebridSettings( + val enabled: Boolean = false, + val torboxApiKey: String = "", + val realDebridApiKey: String = "", + val instantPlaybackPreparationLimit: Int = 0, + val streamMaxResults: Int = 0, + val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT, + val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY, + val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY, + val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY, + val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), + val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, + val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, +) { + val hasAnyApiKey: Boolean + get() = DebridProviders.configuredServices(this).isNotEmpty() +} + +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 + +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = + value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt new file mode 100644 index 00000000..d8c7625b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -0,0 +1,419 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +object DebridSettingsRepository { + private val _uiState = MutableStateFlow(DebridSettings()) + val uiState: StateFlow = _uiState.asStateFlow() + + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + private var hasLoaded = false + private var enabled = false + private var torboxApiKey = "" + private var realDebridApiKey = "" + private var instantPlaybackPreparationLimit = 0 + private var streamMaxResults = 0 + private var streamSortMode = DebridStreamSortMode.DEFAULT + private var streamMinimumQuality = DebridStreamMinimumQuality.ANY + private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY + private var streamHdrFilter = DebridStreamFeatureFilter.ANY + private var streamCodecFilter = DebridStreamCodecFilter.ANY + private var streamPreferences = DebridStreamPreferences() + private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE + private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun snapshot(): DebridSettings { + ensureLoaded() + return _uiState.value + } + + fun setEnabled(value: Boolean) { + ensureLoaded() + if (value && !hasVisibleApiKey()) return + if (enabled == value) return + enabled = value + publish() + DebridSettingsStorage.saveEnabled(value) + } + + fun setTorboxApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (torboxApiKey == normalized) return + torboxApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveTorboxApiKey(normalized) + } + + fun setRealDebridApiKey(value: String) { + ensureLoaded() + val normalized = value.trim() + if (realDebridApiKey == normalized) return + realDebridApiKey = normalized + disableIfNoKeys() + publish() + DebridSettingsStorage.saveRealDebridApiKey(normalized) + } + + fun setInstantPlaybackPreparationLimit(value: Int) { + ensureLoaded() + val normalized = normalizeDebridInstantPlaybackPreparationLimit(value) + if (instantPlaybackPreparationLimit == normalized) return + instantPlaybackPreparationLimit = normalized + publish() + DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized) + } + + fun setStreamMaxResults(value: Int) { + ensureLoaded() + val normalized = normalizeDebridStreamMaxResults(value) + if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return + streamMaxResults = normalized + streamPreferences = streamPreferences.copy(maxResults = normalized).normalized() + publish() + DebridSettingsStorage.saveStreamMaxResults(normalized) + saveStreamPreferences() + } + + fun setStreamSortMode(value: DebridStreamSortMode) { + ensureLoaded() + if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return + streamSortMode = value + streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized() + publish() + DebridSettingsStorage.saveStreamSortMode(value.name) + saveStreamPreferences() + } + + fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) { + ensureLoaded() + if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return + streamMinimumQuality = value + streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized() + publish() + DebridSettingsStorage.saveStreamMinimumQuality(value.name) + saveStreamPreferences() + } + + fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamDolbyVisionFilter == value) return + streamDolbyVisionFilter = value + streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name) + saveStreamPreferences() + } + + fun setStreamHdrFilter(value: DebridStreamFeatureFilter) { + ensureLoaded() + if (streamHdrFilter == value) return + streamHdrFilter = value + streamPreferences = streamPreferences.applyHdrFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamHdrFilter(value.name) + saveStreamPreferences() + } + + fun setStreamCodecFilter(value: DebridStreamCodecFilter) { + ensureLoaded() + if (streamCodecFilter == value) return + streamCodecFilter = value + streamPreferences = streamPreferences.applyCodecFilter(value).normalized() + publish() + DebridSettingsStorage.saveStreamCodecFilter(value.name) + saveStreamPreferences() + } + + fun setStreamPreferences(value: DebridStreamPreferences) { + ensureLoaded() + val normalized = value.normalized() + if (streamPreferences == normalized) return + streamPreferences = normalized + streamMaxResults = normalized.maxResults + publish() + DebridSettingsStorage.saveStreamMaxResults(streamMaxResults) + saveStreamPreferences() + } + + fun setStreamNameTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + if (streamNameTemplate == normalized) return + streamNameTemplate = normalized + publish() + DebridSettingsStorage.saveStreamNameTemplate(normalized) + } + + fun setStreamDescriptionTemplate(value: String) { + ensureLoaded() + val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + if (streamDescriptionTemplate == normalized) return + streamDescriptionTemplate = normalized + publish() + DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) + } + + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { + ensureLoaded() + streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + publish() + DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) + DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) + } + + fun resetStreamTemplates() { + setStreamTemplates( + nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE, + descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ) + } + + private fun disableIfNoKeys() { + if (!hasVisibleApiKey()) { + enabled = false + DebridSettingsStorage.saveEnabled(false) + } + } + + private fun hasVisibleApiKey(): Boolean = + (DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) || + (DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank()) + + private fun loadFromDisk() { + hasLoaded = true + torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty() + realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty() + enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey() + instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit( + DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0, + ) + streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0) + streamSortMode = enumValueOrDefault( + DebridSettingsStorage.loadStreamSortMode(), + DebridStreamSortMode.DEFAULT, + ) + streamMinimumQuality = enumValueOrDefault( + DebridSettingsStorage.loadStreamMinimumQuality(), + DebridStreamMinimumQuality.ANY, + ) + streamDolbyVisionFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamDolbyVisionFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamHdrFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamHdrFilter(), + DebridStreamFeatureFilter.ANY, + ) + streamCodecFilter = enumValueOrDefault( + DebridSettingsStorage.loadStreamCodecFilter(), + DebridStreamCodecFilter.ANY, + ) + streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences()) + ?: legacyStreamPreferences( + maxResults = streamMaxResults, + sortMode = streamSortMode, + minimumQuality = streamMinimumQuality, + dolbyVisionFilter = streamDolbyVisionFilter, + hdrFilter = streamHdrFilter, + codecFilter = streamCodecFilter, + ) + streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.NAME_TEMPLATE + streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate() + ?.takeIf { it.isNotBlank() } + ?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + publish() + } + + private fun publish() { + _uiState.value = DebridSettings( + enabled = enabled, + torboxApiKey = torboxApiKey, + realDebridApiKey = realDebridApiKey, + instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, + streamMaxResults = streamMaxResults, + streamSortMode = streamSortMode, + streamMinimumQuality = streamMinimumQuality, + streamDolbyVisionFilter = streamDolbyVisionFilter, + streamHdrFilter = streamHdrFilter, + streamCodecFilter = streamCodecFilter, + streamPreferences = streamPreferences, + streamNameTemplate = streamNameTemplate, + streamDescriptionTemplate = streamDescriptionTemplate, + ) + } + + private fun saveStreamPreferences() { + DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) + } + + private inline fun > enumValueOrDefault(value: String?, default: T): T = + runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) + + private fun parseStreamPreferences(value: String?): DebridStreamPreferences? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } +} + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt new file mode 100644 index 00000000..62fddac4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.json.JsonObject + +internal expect object DebridSettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) + fun loadTorboxApiKey(): String? + fun saveTorboxApiKey(apiKey: String) + fun loadRealDebridApiKey(): String? + fun saveRealDebridApiKey(apiKey: String) + fun loadInstantPlaybackPreparationLimit(): Int? + fun saveInstantPlaybackPreparationLimit(limit: Int) + fun loadStreamMaxResults(): Int? + fun saveStreamMaxResults(maxResults: Int) + fun loadStreamSortMode(): String? + fun saveStreamSortMode(mode: String) + fun loadStreamMinimumQuality(): String? + fun saveStreamMinimumQuality(quality: String) + fun loadStreamDolbyVisionFilter(): String? + fun saveStreamDolbyVisionFilter(filter: String) + fun loadStreamHdrFilter(): String? + fun saveStreamHdrFilter(filter: String) + fun loadStreamCodecFilter(): String? + fun saveStreamCodecFilter(filter: String) + fun loadStreamPreferences(): String? + fun saveStreamPreferences(preferences: String) + fun loadStreamNameTemplate(): String? + fun saveStreamNameTemplate(template: String) + fun loadStreamDescriptionTemplate(): String? + fun saveStreamDescriptionTemplate(template: String) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt new file mode 100644 index 00000000..dd73d303 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -0,0 +1,143 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamItem + +class DebridStreamFormatter( + private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), +) { + fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + if (!stream.isDirectDebridStream) return stream + val values = buildValues(stream) + val formattedName = engine.render(settings.streamNameTemplate, values) + .lineSequence() + .joinToString(" ") { it.trim() } + .replace(Regex("\\s+"), " ") + .trim() + val formattedDescription = engine.render(settings.streamDescriptionTemplate, values) + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + .trim() + + return stream.copy( + name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) }, + description = formattedDescription.ifBlank { stream.description ?: stream.title }, + ) + } + + private fun buildValues(stream: StreamItem): Map { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val seasons = parsed?.seasons.orEmpty() + val episodes = parsed?.episodes.orEmpty() + val season = resolve?.season ?: seasons.singleOrFirstOrNull() + val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() + val visualTags = buildList { + addAll(parsed?.hdr.orEmpty()) + parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) } + } + val edition = parsed?.edition ?: buildEdition(parsed) + + return linkedMapOf( + "stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title), + "stream.year" to parsed?.year, + "stream.season" to season, + "stream.episode" to episode, + "stream.seasons" to seasons, + "stream.episodes" to episodes, + "stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes), + "stream.formattedEpisodes" to formatEpisodes(episodes), + "stream.formattedSeasons" to formatSeasons(seasons), + "stream.resolution" to parsed?.resolution, + "stream.library" to false, + "stream.quality" to parsed?.quality, + "stream.visualTags" to visualTags, + "stream.audioTags" to parsed?.audio.orEmpty(), + "stream.audioChannels" to parsed?.channels.orEmpty(), + "stream.languages" to parsed?.languages.orEmpty(), + "stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) }, + "stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes), + "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), + "stream.encode" to parsed?.codec?.uppercase(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker), + "stream.network" to (parsed?.network ?: raw?.network), + "stream.releaseGroup" to parsed?.group, + "stream.duration" to parsed?.duration, + "stream.edition" to edition, + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename), + "stream.regexMatched" to null, + "stream.type" to streamType(resolve), + "service.cached" to resolve?.isCached, + "service.shortName" to serviceShortName(resolve), + "service.name" to serviceName(resolve), + "addon.name" to "Nuvio Direct Debrid", + ) + } + + private fun streamType(resolve: StreamClientResolve?): String = + when { + resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" + resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" + else -> resolve?.type.orEmpty() + } + + private fun serviceShortName(resolve: StreamClientResolve?): String = + resolve?.serviceExtension?.takeIf { it.isNotBlank() } + ?: DebridProviders.shortName(resolve?.service) + + private fun serviceName(resolve: StreamClientResolve?): String = + DebridProviders.displayName(resolve?.service) + + private fun buildEdition(parsed: StreamClientResolveParsed?): String? { + if (parsed == null) return null + return buildList { + if (parsed.extended == true) add("extended") + if (parsed.theatrical == true) add("theatrical") + if (parsed.remastered == true) add("remastered") + if (parsed.unrated == true) add("unrated") + }.joinToString(" ").takeIf { it.isNotBlank() } + } + + private fun buildSeasonEpisodeList( + season: Int?, + episode: Int?, + seasons: List, + episodes: List, + ): List { + if (season != null && episode != null) return listOf("S${season.twoDigits()}E${episode.twoDigits()}") + if (seasons.isEmpty() || episodes.isEmpty()) return emptyList() + return seasons.flatMap { s -> episodes.map { e -> "S${s.twoDigits()}E${e.twoDigits()}" } } + } + + private fun formatEpisodes(episodes: List): String = + episodes.joinToString(" | ") { "E${it.twoDigits()}" } + + private fun formatSeasons(seasons: List): String = + seasons.joinToString(" | ") { "S${it.twoDigits()}" } + + private fun List.singleOrFirstOrNull(): Int? = + singleOrNull() ?: firstOrNull() + + private fun Int.twoDigits(): String = toString().padStart(2, '0') + + private fun languageEmoji(language: String): String = + when (language.lowercase()) { + "en", "eng", "english" -> "GB" + "hi", "hin", "hindi" -> "IN" + "ml", "mal", "malayalam" -> "IN" + "ta", "tam", "tamil" -> "IN" + "te", "tel", "telugu" -> "IN" + "ja", "jpn", "japanese" -> "JP" + "ko", "kor", "korean" -> "KR" + "fr", "fre", "fra", "french" -> "FR" + "es", "spa", "spanish" -> "ES" + "de", "ger", "deu", "german" -> "DE" + "it", "ita", "italian" -> "IT" + "multi" -> "Multi" + else -> language + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt new file mode 100644 index 00000000..bb5d25b3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.debrid + +object DebridStreamFormatterDefaults { + const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant" + + const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}" +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt new file mode 100644 index 00000000..23e635e9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -0,0 +1,394 @@ +package com.nuvio.app.features.debrid + +import kotlin.math.abs +import kotlin.math.roundToLong + +internal data class DebridTemplateBytes(val value: Long) + +class DebridStreamTemplateEngine { + fun render(template: String, values: Map): String { + if (template.isEmpty()) return "" + val out = StringBuilder() + var index = 0 + while (index < template.length) { + val start = template.indexOf('{', index) + if (start < 0) { + out.append(template.substring(index)) + break + } + out.append(template.substring(index, start)) + val end = findPlaceholderEnd(template, start + 1) + if (end < 0) { + out.append(template.substring(start)) + break + } + val expression = template.substring(start + 1, end) + out.append(renderExpression(expression, values)) + index = end + 1 + } + return out.toString() + } + + private fun renderExpression(expression: String, values: Map): String { + val bracket = findTopLevelChar(expression, '[') + if (bracket >= 0 && expression.endsWith("]")) { + val condition = expression.substring(0, bracket) + val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1)) + val selected = if (evaluateCondition(condition, values)) branches.first else branches.second + return render(selected, values) + } + + val tokens = splitOps(expression) + if (tokens.isEmpty()) return "" + var value: Any? = values[tokens.first()] + tokens.drop(1).forEach { op -> + value = applyTransform(value, op) + } + return valueToText(value) + } + + private fun evaluateCondition(expression: String, values: Map): Boolean { + val tokens = splitOps(expression).filter { it.isNotBlank() } + if (tokens.isEmpty()) return false + val groups = mutableListOf>() + var currentGroup = mutableListOf() + var index = 0 + while (index < tokens.size) { + when (tokens[index]) { + "or" -> { + groups += currentGroup + currentGroup = mutableListOf() + index++ + } + "and" -> index++ + else -> { + val field = tokens[index] + index++ + val ops = mutableListOf() + while ( + index < tokens.size && + tokens[index] != "and" && + tokens[index] != "or" && + !tokens[index].isFieldPath() + ) { + ops += tokens[index] + index++ + } + currentGroup += evaluateSingleCondition(values[field], ops) + } + } + } + groups += currentGroup + return groups.any { group -> group.isNotEmpty() && group.all { it } } + } + + private fun evaluateSingleCondition(value: Any?, ops: List): Boolean { + if (ops.isEmpty()) return isTruthy(value) + var result = false + var hasResult = false + ops.forEach { op -> + when { + op == "exists" -> { + result = exists(value) + hasResult = true + } + op == "istrue" -> { + result = if (hasResult) result else asBoolean(value) == true + hasResult = true + } + op == "isfalse" -> { + result = if (hasResult) !result else asBoolean(value) == false + hasResult = true + } + op.startsWith("~=") -> { + result = containsText(value, op.drop(2).trim()) + hasResult = true + } + op.startsWith("~") -> { + result = containsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith("=") -> { + result = equalsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith(">=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left >= right } + hasResult = true + } + op.startsWith("<=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left <= right } + hasResult = true + } + op.startsWith(">") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left > right } + hasResult = true + } + op.startsWith("<") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left < right } + hasResult = true + } + } + } + return result + } + + private fun applyTransform(value: Any?, op: String): Any? = + when { + op == "title" -> valueToText(value).titleCased() + op == "lower" -> valueToText(value).lowercase() + op == "upper" -> valueToText(value).uppercase() + op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty() + op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty() + op.startsWith("join(") -> { + val separator = parseArgs(op).firstOrNull() ?: ", " + when (value) { + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(separator) + else -> valueToText(value) + } + } + op.startsWith("replace(") -> { + val args = parseArgs(op) + if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1]) + } + else -> value + } + + private fun findPlaceholderEnd(text: String, start: Int): Int { + var quote: Char? = null + var index = start + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + } else { + when (char) { + '\'', '"' -> quote = char + '}' -> return index + } + } + index++ + } + return -1 + } + + private fun findTopLevelChar(text: String, target: Char): Int { + var quote: Char? = null + var parenDepth = 0 + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + target -> if (parenDepth == 0) return index + } + } + return -1 + } + + private fun splitOps(text: String): List { + val tokens = mutableListOf() + var quote: Char? = null + var parenDepth = 0 + var start = 0 + var index = 0 + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + index++ + continue + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ':' -> { + if (parenDepth == 0 && text.getOrNull(index + 1) == ':') { + tokens += text.substring(start, index).trim() + index += 2 + start = index + continue + } + } + } + index++ + } + tokens += text.substring(start).trim() + return tokens.filter { it.isNotEmpty() } + } + + private fun parseBranches(text: String): Pair { + val split = findBranchSeparator(text) + if (split < 0) return parseQuoted(text) to "" + return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2)) + } + + private fun findBranchSeparator(text: String): Int { + var quote: Char? = null + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '|' -> if (text.getOrNull(index + 1) == '|') return index + } + } + return -1 + } + + private fun parseArgs(op: String): List { + val start = op.indexOf('(') + val end = op.lastIndexOf(')') + if (start < 0 || end <= start) return emptyList() + val body = op.substring(start + 1, end) + val args = mutableListOf() + var quote: Char? = null + var argStart = 0 + body.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && body.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + ',' -> { + args += parseQuoted(body.substring(argStart, index)) + argStart = index + 1 + } + } + } + args += parseQuoted(body.substring(argStart)) + return args + } + + private fun parseQuoted(raw: String): String { + val trimmed = raw.trim() + val unquoted = if ( + trimmed.length >= 2 && + ((trimmed.first() == '"' && trimmed.last() == '"') || + (trimmed.first() == '\'' && trimmed.last() == '\'')) + ) { + trimmed.substring(1, trimmed.length - 1) + } else { + trimmed + } + return unquoted + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\'", "'") + .replace("\\\\", "\\") + } + + private fun String.isFieldPath(): Boolean = + startsWith("stream.") || startsWith("service.") || startsWith("addon.") + + private fun exists(value: Any?): Boolean = + when (value) { + null -> false + is String -> value.isNotBlank() + is Iterable<*> -> value.any() + is Array<*> -> value.isNotEmpty() + else -> true + } + + private fun isTruthy(value: Any?): Boolean = + when (value) { + is Boolean -> value + is DebridTemplateBytes -> value.value != 0L + is Number -> value.toDouble() != 0.0 + else -> exists(value) + } + + private fun asBoolean(value: Any?): Boolean? = + when (value) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() + else -> null + } + + private fun asNumber(value: Any?): Double? = + when (value) { + is Number -> value.toDouble() + is DebridTemplateBytes -> value.value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + + private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean { + val left = asNumber(value) ?: return false + val right = rawTarget.trim().toDoubleOrNull() ?: return false + return compare(left, right) + } + + private fun equalsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) } + else -> valueToText(value).trim().equals(target, ignoreCase = true) + } + + private fun containsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) } + else -> valueToText(value).contains(target, ignoreCase = true) + } + + private fun valueToText(value: Any?): String = + when (value) { + null -> "" + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } }.joinToString(", ") + is DebridTemplateBytes -> formatBytes(value.value.toDouble()) + is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() + is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() + else -> value.toString() + } + + private fun String.titleCased(): String = + split(Regex("\\s+")) + .joinToString(" ") { word -> + if (word.isBlank()) { + word + } else { + word.lowercase().replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + private fun formatBytes(value: Double): String { + val bytes = abs(value) + if (bytes < 1024.0) return "${value.toLong()} B" + val units = listOf("KB", "MB", "GB", "TB") + var current = bytes + var unitIndex = -1 + while (current >= 1024.0 && unitIndex < units.lastIndex) { + current /= 1024.0 + unitIndex++ + } + val signed = if (value < 0) -current else current + return if (signed >= 10 || signed % 1.0 == 0.0) { + "${signed.toLong()} ${units[unitIndex]}" + } else { + val tenths = (signed * 10.0).roundToLong() + "${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}" + } + } + + private fun formatTime(value: Double): String { + val seconds = value.toLong() + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m ${remainingSeconds}s" + else -> "${remainingSeconds}s" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt new file mode 100644 index 00000000..2d9465d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.debrid + +internal fun encodePathSegment(value: String): String = + percentEncode(value, spaceAsPlus = false) + +internal fun encodeFormValue(value: String): String = + percentEncode(value, spaceAsPlus = true) + +internal fun queryString(vararg pairs: Pair): String = + pairs + .mapNotNull { (key, value) -> + value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" } + } + .joinToString("&") + +private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString { + val hex = "0123456789ABCDEF" + value.encodeToByteArray().forEach { byte -> + val code = byte.toInt() and 0xFF + val isUnreserved = (code in 'A'.code..'Z'.code) || + (code in 'a'.code..'z'.code) || + (code in '0'.code..'9'.code) || + code == '-'.code || + code == '.'.code || + code == '_'.code || + code == '~'.code + when { + isUnreserved -> append(code.toChar()) + spaceAsPlus && code == 0x20 -> append('+') + else -> { + append('%') + append(hex[code shr 4]) + append(hex[code and 0x0F]) + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt new file mode 100644 index 00000000..855e9124 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoder.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.debrid + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +class DirectDebridConfigEncoder { + @OptIn(ExperimentalEncodingApi::class) + fun encode(service: DebridServiceCredential): String { + val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}""" + val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}""" + return Base64.Default.encode(json.encodeToByteArray()) + } + + fun encodeTorbox(apiKey: String): String = + encode(DebridServiceCredential(DebridProviders.Torbox, apiKey)) +} + +private fun String.jsonEscaped(): String = buildString { + this@jsonEscaped.forEach { char -> + when (char) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (char.code < 0x20) { + append("\\u") + append(char.code.toString(16).padStart(4, '0')) + } else { + append(char) + } + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt new file mode 100644 index 00000000..6b8e3425 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -0,0 +1,375 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamBehaviorHints +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.debrid_missing_api_key +import nuvio.composeapp.generated.resources.debrid_resolve_failed +import nuvio.composeapp.generated.resources.debrid_stream_stale +import org.jetbrains.compose.resources.getString + +object DirectDebridPlaybackResolver { + private val torboxResolver = TorboxDirectDebridResolver() + private val realDebridResolver = RealDebridDirectDebridResolver() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val resolvedCache = mutableMapOf() + private val inFlightResolves = mutableMapOf>() + + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) + if (cacheKey == null) { + return resolveUncached(stream, season, episode) + } + getCachedResult(cacheKey)?.let { + return it + } + + var ownsResolve = false + val newResolve = scope.async(start = CoroutineStart.LAZY) { + resolveUncached(stream, season, episode) + } + val activeResolve = mutex.withLock { + getCachedResultLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightResolves[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightResolves[cacheKey] = newResolve + ownsResolve = true + newResolve to null + } + } + activeResolve.second?.let { + newResolve.cancel() + return it + } + val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error + if (!ownsResolve) newResolve.cancel() + if (ownsResolve) deferred.start() + + return try { + val result = deferred.await() + if (ownsResolve && result is DirectDebridResolveResult.Success) { + mutex.withLock { + resolvedCache[cacheKey] = CachedDirectDebridResolve( + result = result, + cachedAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsResolve) { + mutex.withLock { + if (inFlightResolves[cacheKey] === deferred) { + inFlightResolves.remove(cacheKey) + } + } + } + } + } + + suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? { + val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null + return getCachedResult(cacheKey) + ?.let { result -> stream.withResolvedDebridUrl(result) } + } + + private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? = + mutex.withLock { getCachedResultLocked(cacheKey) } + + private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? { + val cached = resolvedCache[cacheKey] ?: return null + val age = epochMs() - cached.cachedAtMs + return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) { + cached.result + } else { + resolvedCache.remove(cacheKey) + null + } + } + + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult = + when (DebridProviders.byId(stream.clientResolve?.service)?.id) { + DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode) + DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode) + else -> DirectDebridResolveResult.Error + } + + suspend fun resolveToPlayableStream( + stream: StreamItem, + season: Int?, + episode: Int?, + ): DirectDebridPlayableResult { + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) { + return DirectDebridPlayableResult.Success(stream) + } + return when (val result = resolve(stream, season, episode)) { + is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result)) + DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey + DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale + DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error + } + } +} + +private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L + +private data class CachedDirectDebridResolve( + val result: DirectDebridResolveResult.Success, + val cachedAtMs: Long, +) + +sealed class DirectDebridPlayableResult { + data class Success(val stream: StreamItem) : DirectDebridPlayableResult() + data object MissingApiKey : DirectDebridPlayableResult() + data object Stale : DirectDebridPlayableResult() + data object Error : DirectDebridPlayableResult() +} + +sealed class DirectDebridResolveResult { + data class Success( + val url: String, + val filename: String?, + val videoSize: Long?, + ) : DirectDebridResolveResult() + + data object MissingApiKey : DirectDebridResolveResult() + data object Stale : DirectDebridResolveResult() + data object Error : DirectDebridResolveResult() +} + +fun DirectDebridPlayableResult.toastMessage(): String? = + when (this) { + is DirectDebridPlayableResult.Success -> null + DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) } + DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } + DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } + } + +private class TorboxDirectDebridResolver( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim() + if (apiKey.isBlank()) { + return DirectDebridResolveResult.MissingApiKey + } + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: run { + return DirectDebridResolveResult.Stale + } + + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: run { + return DirectDebridResolveResult.Stale + } + val fileId = file.id + ?: run { + return DirectDebridResolveResult.Stale + } + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: run { + return DirectDebridResolveResult.Stale + } + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() }, + videoSize = file.size, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } + + private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } +} + +private class RealDebridDirectDebridResolver( + private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim() + if (apiKey.isBlank()) { + return DirectDebridResolveResult.MissingApiKey + } + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: run { + return DirectDebridResolveResult.Stale + } + + return try { + val add = RealDebridApiClient.addMagnet(apiKey, magnet) + val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() } + ?: return add.toFailureForAdd() + var resolved = false + try { + val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoBefore.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val filesBefore = infoBefore.body?.files.orEmpty() + val file = fileSelector.selectFile( + files = filesBefore, + resolve = resolve, + season = season, + episode = episode, + ) + ?: run { + return DirectDebridResolveResult.Stale + } + val fileId = file.id + ?: run { + return DirectDebridResolveResult.Stale + } + val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) + if (!select.isSuccessful && select.status != 202) { + return DirectDebridResolveResult.Stale + } + + val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoAfter.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val link = infoAfter.body?.firstDownloadLink() + ?: run { + return DirectDebridResolveResult.Stale + } + val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) + if (!unrestrict.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } + ?: run { + return DirectDebridResolveResult.Stale + } + resolved = true + DirectDebridResolveResult.Success( + url = url, + filename = unrestrict.body.filename?.takeIf { it.isNotBlank() } + ?: file.displayName().takeIf { it.isNotBlank() }, + videoSize = unrestrict.body.filesize ?: file.bytes, + ) + } finally { + if (!resolved) { + runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) } + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } + + private fun DebridApiResponse.toFailureForAdd(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } + + private fun RealDebridTorrentInfoDto.firstDownloadLink(): String? { + if (!status.equals("downloaded", ignoreCase = true)) return null + return links.orEmpty().firstOrNull { it.isNotBlank() } + } +} + +private fun buildMagnetUri(resolve: StreamClientResolve): String? { + val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + resolve.sources + .filter { it.isNotBlank() } + .forEach { source -> + append("&tr=") + append(encodePathSegment(source)) + } + } +} + +private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? { + val resolve = clientResolve ?: return null + val providerId = DebridProviders.byId(resolve.service)?.id ?: return null + val apiKey = when (providerId) { + DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey + DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey + else -> "" + }.trim().takeIf { it.isNotBlank() } ?: return null + val identity = resolve.infoHash + ?: resolve.magnetUri + ?: resolve.torrentName + ?: resolve.filename + ?: return null + + return listOf( + providerId, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + (resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(), + (season ?: resolve.season)?.toString().orEmpty(), + (episode ?: resolve.episode)?.toString().orEmpty(), + ).joinToString("|") +} + +private fun String.stableFingerprint(): String { + val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code } + return hash.toULong().toString(16) +} + +private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem = + copy( + url = result.url, + externalUrl = null, + behaviorHints = behaviorHints.mergeResolvedDebridHints(result), + ) + +private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints = + copy( + filename = result.filename ?: filename, + videoSize = result.videoSize ?: videoSize, + ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt new file mode 100644 index 00000000..6647d607 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -0,0 +1,425 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +object DirectDebridStreamFilter { + const val FALLBACK_SOURCE_NAME = "Direct Debrid" + + fun filterInstant(streams: List, settings: DebridSettings? = null): List { + val instantStreams = streams + .filter(::isInstantCandidate) + .map { stream -> + val providerId = stream.clientResolve?.service + val sourceName = DebridProviders.instantName(providerId) + stream.copy( + name = stream.name ?: sourceName, + addonName = sourceName, + addonId = DebridProviders.addonId(providerId), + sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME, + ) + } + .distinctBy { stream -> + listOf( + stream.clientResolve?.infoHash?.lowercase(), + stream.clientResolve?.fileIdx?.toString(), + stream.clientResolve?.filename, + stream.name, + stream.title, + ).joinToString("|") + } + return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + } + + fun isInstantCandidate(stream: StreamItem): Boolean { + val resolve = stream.clientResolve ?: return false + return resolve.type.equals("debrid", ignoreCase = true) && + DebridProviders.isSupported(resolve.service) && + resolve.isCached == true + } + + fun isDirectDebridSourceName(addonName: String): Boolean = + DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } + + private fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = effectivePreferences(settings) + return streams.map { it to streamFacts(it, preferences) } + .filter { (_, facts) -> facts.matchesFilters(preferences) } + .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } + .let { sorted -> applyLimits(sorted, preferences) } + .map { it.first } + } + + private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + val default = DebridStreamPreferences() + if (settings.streamPreferences != default) return settings.streamPreferences.normalized() + if ( + settings.streamMaxResults == 0 && + settings.streamSortMode == DebridStreamSortMode.DEFAULT && + settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY && + settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY && + settings.streamHdrFilter == DebridStreamFeatureFilter.ANY && + settings.streamCodecFilter == DebridStreamCodecFilter.ANY + ) { + return default + } + var preferences = default.copy( + maxResults = settings.streamMaxResults, + sortCriteria = when (settings.streamSortMode) { + DebridStreamSortMode.DEFAULT -> default.sortCriteria + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + }, + requiredResolutions = DebridStreamResolution.defaultOrder.filter { + it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN + }, + ) + preferences = when (settings.streamDolbyVisionFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + preferences = when (settings.streamHdrFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + return when (settings.streamCodecFilter) { + DebridStreamCodecFilter.ANY -> preferences + DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC)) + DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC)) + DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1)) + }.normalized() + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: StreamFacts, + right: StreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: StreamFacts, + right: StreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } + + private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + val parsed = stream.clientResolve?.stream?.raw?.parsed + val searchText = streamSearchText(stream) + val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) + val quality = streamQuality(parsed?.quality, searchText) + val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText) + val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText) + val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText) + val encode = streamEncode(parsed?.codec, searchText) + val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty { + DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } + } + val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) + return StreamFacts( + resolution = resolution, + quality = quality, + visualTags = visualTags, + audioTags = audioTags, + audioChannels = audioChannels, + encode = encode, + languages = languages, + releaseGroup = releaseGroup, + size = streamSize(stream), + resolutionRank = rank(resolution, preferences.preferredResolutions), + qualityRank = rank(quality, preferences.preferredQualities), + visualRank = rankAny(visualTags, preferences.preferredVisualTags), + audioRank = rankAny(audioTags, preferences.preferredAudioTags), + channelRank = rankAny(audioChannels, preferences.preferredAudioChannels), + encodeRank = rank(encode, preferences.preferredEncodes), + languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) }, + ) + } + + private fun streamResolution(vararg values: String?): DebridStreamResolution = + values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN + + private fun resolutionValue(value: String?): DebridStreamResolution? { + val normalized = value?.lowercase().orEmpty() + return when { + normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160 + normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440 + normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080 + normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720 + normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576 + normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480 + normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360 + else -> null + } + } + + private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality { + val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase() + return when { + text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX + text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY + text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL + text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP + text.contains("hdrip") -> DebridStreamQuality.HDRIP + text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP + text.contains("dvdrip") -> DebridStreamQuality.DVDRIP + text.contains("hdtv") -> DebridStreamQuality.HDTV + text.hasToken("cam") -> DebridStreamQuality.CAM + text.hasToken("ts") -> DebridStreamQuality.TS + text.hasToken("tc") -> DebridStreamQuality.TC + text.hasToken("scr") -> DebridStreamQuality.SCR + else -> DebridStreamQuality.UNKNOWN + } + } + + private fun streamVisualTags(parsedHdr: List, searchText: String): List { + val text = (parsedHdr + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + val hasHdr = parsedHdr.any { it.isHdrToken() } || + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV + if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY + if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY + if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS + if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10 + if (hasDv) tags += DebridStreamVisualTag.DV + if (hasHdr) tags += DebridStreamVisualTag.HDR + if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG + if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT + if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D + if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX + if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI + if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR + if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU + if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS + return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) } + } + + private fun streamAudioTags(parsedAudio: List, searchText: String): List { + val text = (parsedAudio + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS + if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS + if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD + if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X + if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA + if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD + if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES + if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS + if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD + if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS + if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC + if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC + return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) } + } + + private fun streamAudioChannels(parsedChannels: List, searchText: String): List { + val text = (parsedChannels + searchText).joinToString(" ").lowercase() + val channels = mutableListOf() + if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1 + if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1 + if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1 + if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0 + return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) } + } + + private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode { + val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase() + return when { + text.hasToken("av1") -> DebridStreamEncode.AV1 + text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC + text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC + text.hasToken("xvid") -> DebridStreamEncode.XVID + text.hasToken("divx") -> DebridStreamEncode.DIVX + else -> DebridStreamEncode.UNKNOWN + } + } + + private fun languageFor(value: String): DebridStreamLanguage? { + val normalized = value.lowercase() + return DebridStreamLanguage.entries.firstOrNull { + normalized == it.code || normalized == it.label.lowercase() + } + } + + private fun releaseGroupFromText(text: String): String = + Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE) + .find(text) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + + private fun rank(value: T, preferred: List): Int { + val index = preferred.indexOf(value) + return if (index >= 0) index else Int.MAX_VALUE + } + + private fun rankAny(values: List, preferred: List): Int = + values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE + + private fun String.hasResolutionToken(vararg tokens: String): Boolean = + Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this) + + private fun String.hasToken(token: String): Boolean = + Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase()) + + private fun String.isDolbyVisionToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9]"), "") + return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision" + } + + private fun String.isHdrToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "") + return normalized == "hdr" || + normalized == "hdr10" || + normalized == "hdr10+" || + normalized == "hdr10plus" || + normalized == "hlg" + } + + private fun streamSize(stream: StreamItem): Long? = + stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + + private fun streamSearchText(stream: StreamItem): String { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + return listOfNotNull( + stream.name, + stream.title, + stream.description, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } + + private fun Int.gigabytes(): Long = this * 1_000_000_000L + + private data class StreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt new file mode 100644 index 00000000..61952674 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -0,0 +1,196 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +object DirectDebridStreamPreparer { + private val log = Logger.withTag("DirectDebridPreparer") + private val budgetMutex = Mutex() + private val minuteStarts = ArrayDeque() + private val hourStarts = ArrayDeque() + + suspend fun prepare( + streams: List, + season: Int?, + episode: Int?, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + onPrepared: (original: StreamItem, prepared: StreamItem) -> Unit, + ) { + val settings = DebridSettingsRepository.snapshot() + val limit = settings.instantPlaybackPreparationLimit + if (!settings.enabled || limit <= 0 || !settings.hasAnyApiKey) return + + val candidates = prioritizeCandidates( + streams = streams, + limit = limit, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) + for (stream in candidates) { + DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached -> + onPrepared(stream, cached) + continue + } + + if (!consumeBackgroundBudget()) { + log.d { "Skipping instant playback preparation; local Torbox budget reached" } + return + } + + try { + when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { + is DirectDebridPlayableResult.Success -> { + if (result.stream.directPlaybackUrl != null) { + onPrepared(stream, result.stream) + } + } + else -> Unit + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + log.d(error) { "Instant playback preparation failed" } + } + } + } + + internal fun prioritizeCandidates( + streams: List, + limit: Int, + playerSettings: PlayerSettingsUiState, + installedAddonNames: Set, + ): List { + if (limit <= 0) return emptyList() + val candidates = streams + .filter { it.isDirectDebridStream && it.directPlaybackUrl == null } + .distinctBy { it.preparationKey() } + if (candidates.isEmpty()) return emptyList() + + val prioritized = mutableListOf() + val autoPlaySelection = StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = playerSettings.streamAutoPlayMode, + regexPattern = playerSettings.streamAutoPlayRegex, + source = playerSettings.streamAutoPlaySource, + installedAddonNames = installedAddonNames, + selectedAddons = playerSettings.streamAutoPlaySelectedAddons, + selectedPlugins = playerSettings.streamAutoPlaySelectedPlugins, + ) + if (autoPlaySelection?.isDirectDebridStream == true) { + candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() } + ?.let(prioritized::add) + } + + if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { + val regex = runCatching { + Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE) + }.getOrNull() + if (regex != null) { + candidates + .filter { candidate -> + prioritized.none { it.preparationKey() == candidate.preparationKey() } && + regex.containsMatchIn(candidate.searchableText()) + } + .forEach(prioritized::add) + } + } + + candidates + .filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } } + .forEach(prioritized::add) + + return prioritized.take(limit) + } + + fun replacePreparedStream( + groups: List, + original: StreamItem, + prepared: StreamItem, + ): List { + val key = original.preparationKey() + return groups.map { group -> + var changed = false + val updatedStreams = group.streams.map { stream -> + if (stream.preparationKey() == key) { + changed = true + prepared.copy( + addonName = stream.addonName, + addonId = stream.addonId, + sourceName = stream.sourceName, + ) + } else { + stream + } + } + if (changed) group.copy(streams = updatedStreams) else group + } + } + + private suspend fun consumeBackgroundBudget(): Boolean { + val now = epochMs() + return budgetMutex.withLock { + minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS) + hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS) + if ( + minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE || + hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR + ) { + false + } else { + minuteStarts.addLast(now) + hourStarts.addLast(now) + true + } + } + } +} + +private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6 +private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30 +private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L +private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L + +private fun ArrayDeque.removeOlderThan(cutoffMs: Long) { + while (firstOrNull()?.let { it < cutoffMs } == true) { + removeFirst() + } +} + +private fun StreamItem.preparationKey(): String { + val resolve = clientResolve + if (resolve != null) { + return listOf( + resolve.service.orEmpty().lowercase(), + resolve.infoHash.orEmpty().lowercase(), + resolve.fileIdx?.toString().orEmpty(), + resolve.filename.orEmpty().lowercase(), + resolve.torrentName.orEmpty().lowercase(), + resolve.magnetUri.orEmpty().lowercase(), + ).joinToString("|") + } + + return listOf( + addonId.lowercase(), + directPlaybackUrl.orEmpty().lowercase(), + name.orEmpty().lowercase(), + title.orEmpty().lowercase(), + ).joinToString("|") +} + +private fun StreamItem.searchableText(): String = + buildString { + append(addonName).append(' ') + append(name.orEmpty()).append(' ') + append(title.orEmpty()).append(' ') + append(description.orEmpty()).append(' ') + append(directPlaybackUrl.orEmpty()) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt new file mode 100644 index 00000000..6cd5573d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -0,0 +1,253 @@ +package com.nuvio.app.features.debrid + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamParser +import com.nuvio.app.features.streams.epochMs +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val DIRECT_DEBRID_TAG = "DirectDebridStreams" +private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L + +data class DirectDebridStreamTarget( + val provider: DebridProvider, + val apiKey: String, +) { + val addonId: String = DebridProviders.addonId(provider.id) + val addonName: String = DebridProviders.instantName(provider.id) +} + +object DirectDebridStreamSource { + private val log = Logger.withTag(DIRECT_DEBRID_TAG) + private val encoder = DirectDebridConfigEncoder() + private val formatter = DebridStreamFormatter() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val streamCache = mutableMapOf() + private val inFlightFetches = mutableMapOf>() + + fun configuredTargets(): List { + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList() + return DebridProviders.configuredServices(settings).map { credential -> + DirectDebridStreamTarget( + provider = credential.provider, + apiKey = credential.apiKey, + ) + } + } + + fun sourceNames(): List = + configuredTargets().map { it.addonName } + + fun isEnabled(): Boolean = + sourceNames().isNotEmpty() + + fun placeholders(): List = + configuredTargets().map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) + } + + fun preloadStreams(type: String, videoId: String) { + if (type.isBlank() || videoId.isBlank()) return + configuredTargets().forEach { target -> + scope.launch { + runCatching { fetchProviderStreams(type, videoId, target) } + } + } + } + + suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { + val targets = configuredTargets() + if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled + + val results = mutableListOf() + val errors = mutableListOf() + targets.forEach { target -> + val group = fetchProviderStreams(type, videoId, target) + when { + group.streams.isNotEmpty() -> results += group + !group.error.isNullOrBlank() -> errors += group.error + } + } + + return when { + results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) + errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) + else -> DirectDebridStreamFetchResult.Empty + } + } + + suspend fun fetchProviderStreams( + type: String, + videoId: String, + target: DirectDebridStreamTarget, + ): AddonStreamGroup { + val settings = DebridSettingsRepository.snapshot() + val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/') + if (!settings.enabled || baseUrl.isBlank()) { + return target.emptyGroup() + } + + val cacheKey = DirectDebridStreamCacheKey( + providerId = target.provider.id, + type = type.trim().lowercase(), + videoId = videoId.trim(), + baseUrl = baseUrl, + settingsFingerprint = settings.toString(), + ) + cachedGroup(cacheKey)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchProviderStreamsUncached( + baseUrl = baseUrl, + type = type, + videoId = videoId, + target = target, + settings = settings, + ) + } + val activeFetch = mutex.withLock { + cachedGroupLocked(cacheKey)?.let { cached -> + return@withLock null to cached + } + val existing = inFlightFetches[cacheKey] + if (existing != null) { + existing to null + } else { + inFlightFetches[cacheKey] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch") + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + if (ownsFetch && result.streams.isNotEmpty() && result.error == null) { + mutex.withLock { + streamCache[cacheKey] = CachedDirectDebridStreams( + group = result, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlightFetches[cacheKey] === deferred) { + inFlightFetches.remove(cacheKey) + } + } + } + } + } + + private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? = + mutex.withLock { cachedGroupLocked(cacheKey) } + + private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? { + val cached = streamCache[cacheKey] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..STREAM_CACHE_TTL_MS) { + cached.group + } else { + streamCache.remove(cacheKey) + null + } + } + + private suspend fun fetchProviderStreamsUncached( + baseUrl: String, + type: String, + videoId: String, + target: DirectDebridStreamTarget, + settings: DebridSettings, + ): AddonStreamGroup { + val credential = DebridServiceCredential(target.provider, target.apiKey) + val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json" + return try { + val payload = httpGetText(url) + val streams = StreamParser.parse( + payload = payload, + addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, + addonId = target.addonId, + ) + .let { DirectDebridStreamFilter.filterInstant(it, settings) } + .filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) } + .map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) } + + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } + target.errorGroup(error.message) + } + } + + private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + ) + + private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + error = message, + ) +} + +private data class DirectDebridStreamCacheKey( + val providerId: String, + val type: String, + val videoId: String, + val baseUrl: String, + val settingsFingerprint: String, +) + +private data class CachedDirectDebridStreams( + val group: AddonStreamGroup, + val createdAtMs: Long, +) + +sealed class DirectDebridStreamFetchResult { + data object Disabled : DirectDebridStreamFetchResult() + data object Empty : DirectDebridStreamFetchResult() + data class Success(val streams: List) : DirectDebridStreamFetchResult() + data class Error(val message: String) : DirectDebridStreamFetchResult() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 899747c7..378e3a8c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -62,6 +62,7 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.components.DetailActionButtons import com.nuvio.app.features.details.components.CommentDetailSheet import com.nuvio.app.features.details.components.DetailAdditionalInfoSection @@ -378,6 +379,16 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) { + if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) { + seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id + } else { + meta.id + } + } + LaunchedEffect(meta.type, debridPreloadVideoId) { + DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1367,3 +1378,8 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean = + hasEpisodes || type.equals("series", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 69eb462e..255205cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> EpisodeSourceStreamRow( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fcfcadd1..a18b195d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -38,6 +38,10 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.ManagedAddon @@ -857,7 +861,55 @@ fun PlayerScreen( playerController?.seekTo(targetPositionMs) } + fun resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, + ): Boolean { + if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false + scope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = season, + episode = episode, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + onStale() + } + } + } + } + return true + } + fun switchToSource(stream: StreamItem) { + if ( + resolveDebridForPlayer( + stream = stream, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + onResolved = ::switchToSource, + onStale = { + val type = contentType ?: parentMetaType + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = type, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + ) + ) return val url = stream.directPlaybackUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) @@ -899,6 +951,26 @@ fun PlayerScreen( } fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { + if ( + resolveDebridForPlayer( + stream = stream, + season = episode.season, + episode = episode.episode, + onResolved = { resolvedStream -> + switchToEpisodeStream(resolvedStream, episode) + }, + onStale = { + val type = contentType ?: parentMetaType + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + }, + ) + ) return val url = stream.directPlaybackUrl ?: return showNextEpisodeCard = false showSourcesPanel = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index 9e64a911..d57dd46d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -203,7 +203,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 24ea2129..013460c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId @@ -154,6 +156,10 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() + PlayerSettingsRepository.ensureLoaded() + val playerSettings = PlayerSettingsRepository.uiState.value + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -161,7 +167,7 @@ object PlayerStreamsRepository { emptyList() } - if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -187,7 +193,7 @@ object PlayerStreamsRepository { ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -210,6 +216,13 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) }, installedAddonOrder) stateFlow.value = StreamsUiState( groups = initialGroups, @@ -278,13 +291,24 @@ object PlayerStreamsRepository { } } - val jobs = addonJobs + pluginJobs + val debridJobs = debridTargets.map { target -> + async { + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ) + } + } + + val jobs = addonJobs + pluginJobs + debridJobs val completions = Channel(capacity = Channel.BUFFERED) jobs.forEach { deferred -> launch { completions.send(deferred.await()) } } + var debridPreparationLaunched = false repeat(jobs.size) { val result = completions.receive() stateFlow.update { current -> @@ -305,6 +329,28 @@ object PlayerStreamsRepository { } else null, ) } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + stateFlow.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } } completions.close() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt new file mode 100644 index 00000000..30d59534 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -0,0 +1,1295 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT +import com.nuvio.app.features.debrid.DebridCredentialValidator +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults +import com.nuvio.app.features.debrid.DebridStreamAudioChannel +import com.nuvio.app.features.debrid.DebridStreamAudioTag +import com.nuvio.app.features.debrid.DebridStreamEncode +import com.nuvio.app.features.debrid.DebridStreamLanguage +import com.nuvio.app.features.debrid.DebridStreamPreferences +import com.nuvio.app.features.debrid.DebridStreamQuality +import com.nuvio.app.features.debrid.DebridStreamResolution +import com.nuvio.app.features.debrid.DebridStreamSortCriterion +import com.nuvio.app.features.debrid.DebridStreamSortDirection +import com.nuvio.app.features.debrid.DebridStreamSortKey +import com.nuvio.app.features.debrid.DebridStreamVisualTag +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_clear +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.action_saving +import nuvio.composeapp.generated.resources.settings_debrid_add_key_first +import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder +import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_dialog_title +import nuvio.composeapp.generated.resources.settings_debrid_enable +import nuvio.composeapp.generated.resources.settings_debrid_enable_description +import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_description_template +import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many +import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback +import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count +import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count_warning +import nuvio.composeapp.generated.resources.settings_debrid_key_invalid +import nuvio.composeapp.generated.resources.settings_debrid_name_template +import nuvio.composeapp.generated.resources.settings_debrid_name_template_description +import nuvio.composeapp.generated.resources.settings_debrid_not_set +import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description +import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback +import nuvio.composeapp.generated.resources.settings_debrid_section_formatting +import nuvio.composeapp.generated.resources.settings_debrid_section_providers +import nuvio.composeapp.generated.resources.settings_debrid_section_title +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.debridSettingsContent( + isTablet: Boolean, + settings: DebridSettings, +) { + item { + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_title), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_experimental_notice), + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_enable), + description = stringResource(Res.string.settings_debrid_enable_description), + checked = settings.enabled && settings.hasAnyApiKey, + enabled = settings.hasAnyApiKey, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setEnabled, + ) + if (!settings.hasAnyApiKey) { + SettingsGroupDivider(isTablet = isTablet) + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_add_key_first), + ) + } + } + } + } + + item { + var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_providers), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = DebridProviders.Torbox.displayName, + description = stringResource(Res.string.settings_debrid_provider_torbox_description), + value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)), + enabled = true, + onClick = { showApiKeyDialog = true }, + ) + } + } + + if (showApiKeyDialog) { + DebridApiKeyDialog( + providerId = DebridProviders.TORBOX_ID, + title = stringResource(Res.string.settings_debrid_dialog_title), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder), + currentValue = settings.torboxApiKey, + onSave = DebridSettingsRepository::setTorboxApiKey, + onDismiss = { showApiKeyDialog = false }, + ) + } + } + + item { + var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) } + val prepareLimit = settings.instantPlaybackPreparationLimit + val prepareEnabled = settings.enabled && prepareLimit > 0 + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_instant_playback), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_prepare_instant_playback), + description = stringResource(Res.string.settings_debrid_prepare_instant_playback_description), + checked = prepareEnabled, + enabled = settings.enabled && settings.hasAnyApiKey, + isTablet = isTablet, + onCheckedChange = { enabled -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit( + if (enabled) DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT else 0, + ) + }, + ) + if (prepareEnabled) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_debrid_prepare_stream_count), + description = prepareCountLabel(prepareLimit), + isTablet = isTablet, + onClick = { showPrepareCountDialog = true }, + ) + } + } + } + + if (showPrepareCountDialog) { + DebridPrepareCountDialog( + selectedLimit = prepareLimit, + onLimitSelected = { limit -> + DebridSettingsRepository.setInstantPlaybackPreparationLimit(limit) + showPrepareCountDialog = false + }, + onDismiss = { showPrepareCountDialog = false }, + ) + } + } + + item { + var activeStreamPicker by rememberSaveable { mutableStateOf(null) } + val preferences = settings.streamPreferences + val rows = debridRuleRows(preferences) + + SettingsSection( + title = "Filters & Sorting", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many Direct Debrid sources appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort streams", + description = "Choose how Direct Debrid sources are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per resolution limit", + description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerResolution), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per quality limit", + description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerQuality), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter streams by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, + ) + rows.forEach { row -> + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = row.title, + description = row.description, + value = row.value, + enabled = settings.enabled, + onClick = { activeStreamPicker = row.picker }, + ) + } + } + } + + activeStreamPicker?.let { picker -> + DebridStreamPreferenceDialog( + picker = picker, + preferences = preferences, + onPreferencesChanged = DebridSettingsRepository::setStreamPreferences, + onDismiss = { activeStreamPicker = null }, + ) + } + } + + item { + var activeTemplateField by rememberSaveable { mutableStateOf(null) } + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_formatting), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + value = templatePreview(settings.streamNameTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.NAME }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + value = templatePreview(settings.streamDescriptionTemplate), + enabled = settings.enabled, + onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_formatter_reset_title), + description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle), + value = stringResource(Res.string.action_reset), + enabled = settings.enabled, + onClick = DebridSettingsRepository::resetStreamTemplates, + ) + } + } + + when (activeTemplateField) { + DebridTemplateField.NAME -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_name_template), + description = stringResource(Res.string.settings_debrid_name_template_description), + currentValue = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + onSave = DebridSettingsRepository::setStreamNameTemplate, + onDismiss = { activeTemplateField = null }, + ) + DebridTemplateField.DESCRIPTION -> DebridTemplateDialog( + title = stringResource(Res.string.settings_debrid_description_template), + description = stringResource(Res.string.settings_debrid_description_template_description), + currentValue = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + onSave = DebridSettingsRepository::setStreamDescriptionTemplate, + onDismiss = { activeTemplateField = null }, + ) + null -> Unit + } + } +} + +private enum class DebridTemplateField { + NAME, + DESCRIPTION, +} + +private fun templatePreview(value: String): String { + val firstLine = value + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?: return "" + return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." +} + +@Composable +private fun prepareCountLabel(limit: Int): String = + if (limit == 1) { + stringResource(Res.string.settings_debrid_prepare_count_one) + } else { + stringResource(Res.string.settings_debrid_prepare_count_many, limit) + } + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridPrepareCountDialog( + selectedLimit: Int, + onLimitSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf(1, 2, 3, 5) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { limit -> + val isSelected = limit == selectedLimit + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onLimitSelected(limit) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = prepareCountLabel(limit), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + + Text( + text = stringResource(Res.string.settings_debrid_prepare_stream_count_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTemplateDialog( + title: String, + description: String, + currentValue: String, + defaultValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp, max = 280.dp), + minLines = 5, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = { draft = defaultValue }) { + Text( + text = stringResource(Res.string.action_reset), + maxLines = 1, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(Res.string.action_cancel), + maxLines = 1, + ) + } + Button( + onClick = { + onSave(draft) + onDismiss() + }, + ) { + Text( + text = stringResource(Res.string.action_save), + maxLines = 1, + ) + } + } + } + } + } +} + +@Composable +private fun DebridPreferenceRow( + isTablet: Boolean, + title: String, + description: String, + value: String, + enabled: Boolean, + onClick: () -> Unit, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 16.dp else 14.dp + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun DebridStreamPreferenceDialog( + picker: DebridStreamPicker, + preferences: DebridStreamPreferences, + onPreferencesChanged: (DebridStreamPreferences) -> Unit, + onDismiss: () -> Unit, +) { + when (picker) { + DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxResults, + options = listOf(0, 5, 10, 20, 50), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerResolution, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerQuality, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( + title = "Sort streams", + selectedValue = sortProfileFor(preferences.sortCriteria), + options = listOf( + DebridSortProfile.DEFAULT, + DebridSortProfile.LARGEST, + DebridSortProfile.SMALLEST, + DebridSortProfile.AUDIO, + DebridSortProfile.LANGUAGE, + ), + label = { sortProfileLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( + title = "Size range", + selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, + options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), + label = { sizeRangeLabel(it.first, it.second) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Preferred resolutions", + selectedValues = preferences.preferredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Required resolutions", + selectedValues = preferences.requiredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Excluded resolutions", + selectedValues = preferences.excludedResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Preferred qualities", + selectedValues = preferences.preferredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Required qualities", + selectedValues = preferences.requiredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( + title = "Excluded qualities", + selectedValues = preferences.excludedQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Preferred visual tags", + selectedValues = preferences.preferredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Required visual tags", + selectedValues = preferences.requiredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Excluded visual tags", + selectedValues = preferences.excludedVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Preferred audio tags", + selectedValues = preferences.preferredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Required audio tags", + selectedValues = preferences.requiredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Excluded audio tags", + selectedValues = preferences.excludedAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Preferred channels", + selectedValues = preferences.preferredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Required channels", + selectedValues = preferences.requiredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Excluded channels", + selectedValues = preferences.excludedAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( + title = "Preferred encodes", + selectedValues = preferences.preferredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( + title = "Required encodes", + selectedValues = preferences.requiredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( + title = "Excluded encodes", + selectedValues = preferences.excludedEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Preferred languages", + selectedValues = preferences.preferredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Required languages", + selectedValues = preferences.requiredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Excluded languages", + selectedValues = preferences.excludedLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Required release groups", + selectedValues = preferences.requiredReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Excluded release groups", + selectedValues = preferences.excludedReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun DebridIntChoiceDialog( + title: String, + selectedValue: Int, + options: List, + label: @Composable (Int) -> String, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + DebridSingleChoiceDialog( + title = title, + selectedValue = selectedValue, + options = options, + label = label, + onSelected = onSelected, + onDismiss = onDismiss, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridSingleChoiceDialog( + title: String, + selectedValue: T, + options: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(options) { option -> + DebridDialogOptionRow( + text = label(option), + selected = option == selectedValue, + onClick = { + onSelected(option) + onDismiss() + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridMultiChoiceDialog( + title: String, + selectedValues: List, + values: List, + label: @Composable (T) -> String, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var draft by remember(selectedValues) { mutableStateOf(selectedValues) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(values) { option -> + val selected = option in draft + DebridDialogOptionRow( + text = label(option), + selected = selected, + showCheckbox = true, + onClick = { + draft = if (selected) { + draft - option + } else { + draft + option + } + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { draft = emptyList() }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(draft) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTextListDialog( + title: String, + selectedValues: List, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = "Enter one group per line.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + minLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { value = "" }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct()) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +private fun DebridDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +@Composable +private fun DebridDialogOptionRow( + text: String, + selected: Boolean, + showCheckbox: Boolean = false, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (showCheckbox) { + Checkbox( + checked = selected, + onCheckedChange = { onClick() }, + ) + } else { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +@Composable +private fun streamMaxResultsLabel(value: Int): String = + if (value <= 0) "All streams" else "$value streams" + +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.DEFAULT -> "Default" + DebridSortProfile.LARGEST -> "Largest first" + DebridSortProfile.SMALLEST -> "Smallest first" + DebridSortProfile.AUDIO -> "Best audio first" + DebridSortProfile.LANGUAGE -> "Language first" + } + +private fun debridRuleRows(preferences: DebridStreamPreferences): List = + listOf( + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + ) + +private fun selectionCountLabel(values: List<*>): String = + if (values.isEmpty()) "Any" else "${values.size} selected" + +private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = + sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) + +private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = + when { + minGb <= 0 && maxGb <= 0 -> "Any" + minGb <= 0 -> "Up to ${maxGb}GB" + maxGb <= 0 -> "${minGb}GB+" + else -> "${minGb}-${maxGb}GB" + } + +private fun sortProfileFor(criteria: List): DebridSortProfile { + val normalized = criteria.map { it.key to it.direction } + return when { + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST + normalized.take(2) == listOf( + DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC, + DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, + ) -> DebridSortProfile.AUDIO + normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE + else -> DebridSortProfile.DEFAULT + } +} + +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +private fun sortCriteriaForProfile(profile: DebridSortProfile): List = + when (profile) { + DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridSortProfile.AUDIO -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridSortProfile.LANGUAGE -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } + +private data class DebridRuleRow( + val picker: DebridStreamPicker, + val title: String, + val description: String, + val value: String, +) + +private enum class DebridSortProfile { + DEFAULT, + LARGEST, + SMALLEST, + AUDIO, + LANGUAGE, +} + +private enum class DebridStreamPicker { + MAX_RESULTS, + MAX_PER_RESOLUTION, + MAX_PER_QUALITY, + SORT_MODE, + SIZE_RANGE, + PREFERRED_RESOLUTIONS, + REQUIRED_RESOLUTIONS, + EXCLUDED_RESOLUTIONS, + PREFERRED_QUALITIES, + REQUIRED_QUALITIES, + EXCLUDED_QUALITIES, + PREFERRED_VISUAL_TAGS, + REQUIRED_VISUAL_TAGS, + EXCLUDED_VISUAL_TAGS, + PREFERRED_AUDIO_TAGS, + REQUIRED_AUDIO_TAGS, + EXCLUDED_AUDIO_TAGS, + PREFERRED_AUDIO_CHANNELS, + REQUIRED_AUDIO_CHANNELS, + EXCLUDED_AUDIO_CHANNELS, + PREFERRED_ENCODES, + REQUIRED_ENCODES, + EXCLUDED_ENCODES, + PREFERRED_LANGUAGES, + REQUIRED_LANGUAGES, + EXCLUDED_LANGUAGES, + REQUIRED_RELEASE_GROUPS, + EXCLUDED_RELEASE_GROUPS, +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridApiKeyDialog( + providerId: String, + title: String, + subtitle: String, + placeholder: String, + currentValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } + var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) } + val normalizedDraft = draft.trim() + val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + val saveAndDismiss: () -> Unit = { + scope.launch { + isValidating = true + validationMessage = null + val valid = normalizedDraft.isNotBlank() && runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + if (valid) { + onSave(normalizedDraft) + isValidating = false + onDismiss() + } else { + validationMessage = invalidMessage + isValidating = false + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + onSave("") + onDismiss() + }, + enabled = !isValidating, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = saveAndDismiss, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text( + if (isValidating) { + stringResource(Res.string.action_saving) + } else { + stringResource(Res.string.action_save) + }, + ) + } + } + } + } +} + +private fun maskDebridApiKey(key: String, notSetLabel: String): String { + val trimmed = key.trim() + if (trimmed.isBlank()) return notSetLabel + return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" +} + +@Composable +private fun DebridInfoRow( + isTablet: Boolean, + text: String, +) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index 7602c3e2..a4999c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,10 +1,14 @@ package com.nuvio.app.features.settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudQueue import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_debrid_description import nuvio.composeapp.generated.resources.settings_integrations_section_title import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description import org.jetbrains.compose.resources.stringResource @@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent( isTablet: Boolean, onTmdbClick: () -> Unit, onMdbListClick: () -> Unit, + onDebridClick: () -> Unit, ) { item { SettingsSection( @@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent( isTablet = isTablet, onClick = onMdbListClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_debrid), + description = stringResource(Res.string.settings_integrations_debrid_description), + icon = Icons.Rounded.CloudQueue, + isTablet = isTablet, + onClick = onDebridClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index d030a785..a6eb2a40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_addons import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching import nuvio.composeapp.generated.resources.compose_settings_page_homescreen import nuvio.composeapp.generated.resources.compose_settings_page_integrations @@ -129,6 +130,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Integrations, ), + Debrid( + titleRes = Res.string.compose_settings_page_debrid, + category = SettingsCategory.General, + parentPage = Integrations, + ), TraktAuthentication( titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 08864811..21442208 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -132,6 +134,10 @@ fun SettingsScreen( MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState @@ -251,6 +257,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -299,6 +306,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -357,6 +365,7 @@ private fun MobileSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -571,6 +580,7 @@ private fun MobileSettingsScreen( isTablet = false, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, @@ -580,6 +590,10 @@ private fun MobileSettingsScreen( isTablet = false, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = false, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, @@ -665,6 +679,7 @@ private fun TabletSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -937,6 +952,7 @@ private fun TabletSettingsScreen( isTablet = true, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, @@ -946,6 +962,10 @@ private fun TabletSettingsScreen( isTablet = true, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = true, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 46d38159..a5e97de5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -15,15 +15,19 @@ object StreamAutoPlaySelector { } } - if (installedOrder.isEmpty()) return groups + val (directDebridEntries, remainingEntries) = groups.partition { group -> + group.addonId.startsWith("debrid:") || + group.streams.any { stream -> stream.isDirectDebridStream } + } + if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries - val (addonEntries, pluginEntries) = groups.partition { group -> + val (addonEntries, pluginEntries) = remainingEntries.partition { group -> group.addonName in addonRankByName } val orderedAddons = addonEntries.sortedBy { group -> addonRankByName.getValue(group.addonName) } - return orderedAddons + pluginEntries + return directDebridEntries + orderedAddons + pluginEntries } fun selectAutoPlayStream( @@ -119,5 +123,5 @@ object StreamAutoPlaySelector { } private fun StreamItem.isAutoPlayable(): Boolean = - directPlaybackUrl != null + directPlaybackUrl != null || isDirectDebridStream } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index fe223534..0b3d8b24 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,7 @@ data class StreamItem( val addonName: String, val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), + val clientResolve: StreamClientResolve? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -27,13 +28,18 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val isDirectDebridStream: Boolean + get() = clientResolve?.isDirectDebridCandidate == true + val isTorrentStream: Boolean - get() = !infoHash.isNullOrBlank() || + get() = !isDirectDebridStream && ( + !infoHash.isNullOrBlank() || url.isMagnetLink() || externalUrl.isMagnetLink() + ) val hasPlayableSource: Boolean - get() = url != null || infoHash != null || externalUrl != null + get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } private fun String?.isMagnetLink(): Boolean = @@ -53,6 +59,71 @@ data class StreamProxyHeaders( val response: Map? = null, ) +data class StreamClientResolve( + val type: String? = null, + val infoHash: String? = null, + val fileIdx: Int? = null, + val magnetUri: String? = null, + val sources: List = emptyList(), + val torrentName: String? = null, + val filename: String? = null, + val mediaType: String? = null, + val mediaId: String? = null, + val mediaOnlyId: String? = null, + val title: String? = null, + val season: Int? = null, + val episode: Int? = null, + val service: String? = null, + val serviceIndex: Int? = null, + val serviceExtension: String? = null, + val isCached: Boolean? = null, + val stream: StreamClientResolveStream? = null, +) { + val isDirectDebridCandidate: Boolean + get() = type.equals("debrid", ignoreCase = true) && + !service.isNullOrBlank() && + isCached == true +} + +data class StreamClientResolveStream( + val raw: StreamClientResolveRaw? = null, +) + +data class StreamClientResolveRaw( + val torrentName: String? = null, + val filename: String? = null, + val size: Long? = null, + val folderSize: Long? = null, + val tracker: String? = null, + val indexer: String? = null, + val network: String? = null, + val parsed: StreamClientResolveParsed? = null, +) + +data class StreamClientResolveParsed( + val rawTitle: String? = null, + val parsedTitle: String? = null, + val year: Int? = null, + val resolution: String? = null, + val seasons: List = emptyList(), + val episodes: List = emptyList(), + val quality: String? = null, + val hdr: List = emptyList(), + val codec: String? = null, + val audio: List = emptyList(), + val channels: List = emptyList(), + val languages: List = emptyList(), + val group: String? = null, + val network: String? = null, + val edition: String? = null, + val duration: Long? = null, + val bitDepth: String? = null, + val extended: Boolean? = null, + val theatrical: Boolean? = null, + val remastered: Boolean? = null, + val unrated: Boolean? = null, +) + data class AddonStreamGroup( val addonName: String, val addonId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 72a6fc5c..9a6aa866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -26,8 +26,10 @@ object StreamParser { val url = obj.string("url") val infoHash = obj.string("infoHash") val externalUrl = obj.string("externalUrl") + val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() - if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null + // Must have at least one playable source + if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject val proxyHeaders = hintsObj @@ -44,6 +46,7 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, @@ -80,6 +83,11 @@ object StreamParser { ?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } .orEmpty() + private fun JsonObject.intList(name: String): List = + (this[name] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.intOrNull } + .orEmpty() + private fun JsonObject.stringMap(): Map = entries.mapNotNull { (key, value) -> (value as? JsonPrimitive)?.contentOrNull @@ -99,4 +107,67 @@ object StreamParser { ) } + private fun JsonObject.toClientResolve(): StreamClientResolve = + StreamClientResolve( + type = string("type"), + infoHash = string("infoHash"), + fileIdx = int("fileIdx"), + magnetUri = string("magnetUri"), + sources = stringList("sources"), + torrentName = string("torrentName"), + filename = string("filename"), + mediaType = string("mediaType"), + mediaId = string("mediaId"), + mediaOnlyId = string("mediaOnlyId"), + title = string("title"), + season = int("season"), + episode = int("episode"), + service = string("service"), + serviceIndex = int("serviceIndex"), + serviceExtension = string("serviceExtension"), + isCached = boolean("isCached"), + stream = objectValue("stream")?.toClientResolveStream(), + ) + + private fun JsonObject.toClientResolveStream(): StreamClientResolveStream = + StreamClientResolveStream( + raw = objectValue("raw")?.toClientResolveRaw(), + ) + + private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw = + StreamClientResolveRaw( + torrentName = string("torrentName"), + filename = string("filename"), + size = long("size"), + folderSize = long("folderSize"), + tracker = string("tracker"), + indexer = string("indexer"), + network = string("network"), + parsed = objectValue("parsed")?.toClientResolveParsed(), + ) + + private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed = + StreamClientResolveParsed( + rawTitle = string("raw_title"), + parsedTitle = string("parsed_title"), + year = int("year"), + resolution = string("resolution"), + seasons = intList("seasons"), + episodes = intList("episodes"), + quality = string("quality"), + hdr = stringList("hdr"), + codec = string("codec"), + audio = stringList("audio"), + channels = stringList("channels"), + languages = stringList("languages"), + group = string("group"), + network = string("network"), + edition = string("edition"), + duration = long("duration"), + bitDepth = string("bit_depth"), + extended = boolean("extended"), + theatrical = boolean("theatrical"), + remastered = boolean("remastered"), + unrated = boolean("unrated"), + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 56a76194..5441ce47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -5,6 +5,8 @@ import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.DirectDebridStreamSource import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -148,6 +150,7 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val debridTargets = DirectDebridStreamSource.configuredTargets() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.getEnabledScrapersForType(type) } else { @@ -158,7 +161,7 @@ object StreamsRepository { groupByRepository = pluginUiState.groupStreamsByRepository, ) - if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -187,7 +190,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) { _uiState.value = StreamsUiState( requestToken = requestToken, isAnyLoading = false, @@ -212,6 +215,13 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) + } + debridTargets.map { target -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = true, + ) }, installedAddonOrder) _uiState.value = StreamsUiState( requestToken = requestToken, @@ -230,11 +240,13 @@ object StreamsRepository { .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() val totalTasks = streamAddons.size + - pluginProviderGroups.sumOf { it.scrapers.size } + pluginProviderGroups.sumOf { it.scrapers.size } + + debridTargets.size val installedAddonNames = installedAddonOrder.toSet() var autoSelectTriggered = false var timeoutElapsed = false + var debridPreparationLaunched = false fun publishCompletion(completion: StreamLoadCompletion) { if (completions.trySend(completion).isFailure) { log.d { "Ignoring late stream load completion after channel close" } @@ -410,6 +422,20 @@ object StreamsRepository { } } + debridTargets.forEach { target -> + launch { + publishCompletion( + StreamLoadCompletion.Debrid( + DirectDebridStreamSource.fetchProviderStreams( + type = type, + videoId = videoId, + target = target, + ), + ), + ) + } + } + repeat(totalTasks) { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { @@ -472,6 +498,45 @@ object StreamsRepository { } } + is StreamLoadCompletion.Debrid -> { + val result = completion.group + _uiState.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId == result.addonId) result else group + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) { + debridPreparationLaunched = true + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups.flatMap { it.streams }, + season = season, + episode = episode, + playerSettings = playerSettings, + installedAddonNames = installedAddonNames, + ) { original, prepared -> + _uiState.update { current -> + current.copy( + groups = DirectDebridStreamPreparer.replacePreparedStream( + groups = current.groups, + original = original, + prepared = prepared, + ), + ) + } + } + } + } + } } // Early match / timeout-elapsed auto-select on each addon response @@ -612,6 +677,7 @@ private data class PluginProviderGroup( private sealed interface StreamLoadCompletion { data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion + data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion data class PluginScraper( val addonId: String, val streams: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 41302ad0..ee5b52e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -866,7 +866,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream) { + if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, @@ -898,7 +898,7 @@ internal fun streamCardRenderKey( append(':') append(itemIndex) append(':') - append(stream.url ?: stream.infoHash ?: stream.streamLabel) + append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) } // --------------------------------------------------------------------------- @@ -972,7 +972,7 @@ private fun StreamCard( onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream + val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt new file mode 100644 index 00000000..ad4f9eab --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridFileSelectorTest { + @Test + fun `Torbox selector prefers exact file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1), + TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 8), + season = null, + episode = null, + ) + + assertEquals(8, selected?.id) + } + + @Test + fun `Torbox selector prefers filename match before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto( + id = 85, + name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + size = 5_303_936_915, + ), + TorboxTorrentFileDto( + id = 1, + name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv", + size = 5_859_312_140, + ), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + fileIdx = 1, + season = 1, + episode = 1, + filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector treats fileIdx as source list index before provider file id`() { + val files = listOf( + TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1), + TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500), + TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(fileIdx = 1), + season = null, + episode = null, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector uses episode pattern before broad title`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900), + TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve( + season = 1, + episode = 1, + title = "The Office", + ), + season = 1, + episode = 1, + ) + + assertEquals(85, selected?.id) + } + + @Test + fun `Torbox selector falls back to largest playable video`() { + val files = listOf( + TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999), + TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200), + TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500), + ) + + val selected = TorboxFileSelector().selectFile( + files = files, + resolve = resolve(), + season = null, + episode = null, + ) + + assertEquals(3, selected?.id) + } + + @Test + fun `Real-Debrid selector matches episode pattern before largest file`() { + val files = listOf( + RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000), + RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000), + ) + + val selected = RealDebridFileSelector().selectFile( + files = files, + resolve = resolve(season = 1, episode = 1), + season = null, + episode = null, + ) + + assertEquals(1, selected?.id) + } + + private fun resolve( + fileIdx: Int? = null, + season: Int? = null, + episode: Int? = null, + filename: String? = null, + title: String? = null, + ): StreamClientResolve = + StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = "hash", + fileIdx = fileIdx, + filename = filename, + title = title, + season = season, + episode = episode, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt new file mode 100644 index 00000000..83b127cc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterTest.kt @@ -0,0 +1,122 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamParser +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class DebridStreamFormatterTest { + private val formatter = DebridStreamFormatter() + + @Test + fun `formats real client stream episode fields and behavior size`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertEquals(0, stream.clientResolve?.fileIdx) + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + assertFalse(description.contains("6761331156")) + } + + @Test + fun `formats season episode from parsed fields when top level resolve omits them`() { + val stream = StreamParser.parse( + payload = clientStreamPayload(includeTopLevelSeasonEpisode = false), + addonName = "Torbox Instant", + addonId = "debrid:torbox", + ).single() + + val formatted = formatter.format( + stream = stream, + settings = DebridSettings( + enabled = true, + torboxApiKey = "key", + streamDescriptionTemplate = CLIENT_TEMPLATE, + ), + ) + + val description = formatted.description.orEmpty() + assertContains(description, "S05") + assertContains(description, "E02") + assertContains(description, "6.3 GB") + } + + private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String { + val seasonEpisode = if (includeTopLevelSeasonEpisode) { + """ + "season": 5, + "episode": 2, + """.trimIndent() + } else { + "" + } + return """ + { + "streams": [ + { + "name": "TB 2160p cached", + "description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "clientResolve": { + "type": "debrid", + "service": "torbox", + "isCached": true, + "infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4", + "sources": [], + "fileIdx": "0", + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "title": "The Boys", + "torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + $seasonEpisode + "stream": { + "raw": { + "parsed": { + "resolution": "2160p", + "quality": "WEB-DL", + "codec": "hevc", + "audio": ["Atmos", "Dolby Digital Plus"], + "channels": ["5.1"], + "hdr": ["DV", "HDR10+"], + "group": "Kitsune", + "seasons": [5], + "episodes": [2], + "raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv" + } + } + } + }, + "behaviorHints": { + "filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv", + "videoSize": 6761331156 + } + } + ] + } + """.trimIndent() + } + + private companion object { + private const val CLIENT_TEMPLATE = + "{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" + + "{stream.season::>=0[\"🍂 S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" + + "{stream.quality::exists[\"🎥 {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"📺 {stream.visualTags::join(' | ')} \"||\"\"]}\n" + + "{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"🔊 {stream.audioChannels::join(' | ')}\"||\"\"]}\n" + + "{stream.size::>0[\"📦 {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"📡{stream.indexer}\"||\"\"]}\n" + + "{service.cached::istrue[\"⚡Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}🔍{addon.name}" + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt new file mode 100644 index 00000000..7a670339 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngineTest.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DebridStreamTemplateEngineTest { + private val engine = DebridStreamTemplateEngine() + + @Test + fun `renders nested condition branches and transforms`() { + val rendered = engine.render( + "{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}", + mapOf( + "stream.resolution" to "2160p", + "stream.title" to "sample movie", + "service.shortName" to "RD", + ), + ) + + assertEquals("4K RD Sample Movie", rendered) + } + + @Test + fun `formats bytes and joins list values`() { + val rendered = engine.render( + "{stream.size::bytes} {stream.audioTags::join(' | ')}", + mapOf( + "stream.size" to 1_610_612_736L, + "stream.audioTags" to listOf("DTS", "Atmos"), + ), + ) + + assertEquals("1.5 GB DTS | Atmos", rendered) + } + + @Test + fun `renders Debrid size values as readable text while keeping numeric comparisons`() { + val rendered = engine.render( + "{stream.size::>0[\"{stream.size}\"||\"\"]}", + mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)), + ) + + assertEquals("6.9 GB", rendered) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt new file mode 100644 index 00000000..15fcf1e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridConfigEncoderTest.kt @@ -0,0 +1,27 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridConfigEncoderTest { + @Test + fun `encodes Torbox config exactly like TV`() { + val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key") + + assertEquals( + "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9", + encoded, + ) + } + + @Test + fun `escapes API key before base64 encoding`() { + val encoded = DirectDebridConfigEncoder().encode( + DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"), + ) + + val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0=" + assertEquals(expected, encoded) + } +} + diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt new file mode 100644 index 00000000..593fa6af --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -0,0 +1,210 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamClientResolveRaw +import com.nuvio.app.features.streams.StreamClientResolveStream +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DirectDebridStreamFilterTest { + @Test + fun `keeps only cached supported debrid streams`() { + val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true) + val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false) + val unsupported = stream(service = "other", cached = true) + val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent") + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent)) + + assertEquals(1, filtered.size) + assertEquals("Torbox Instant", filtered.single().addonName) + assertEquals("debrid:torbox", filtered.single().addonId) + } + + @Test + fun `dedupes by hash file and filename identity`() { + val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2) + val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2) + val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3) + + val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile)) + + assertEquals(2, filtered.size) + } + + @Test + fun `direct debrid stream is not treated as unsupported torrent`() { + val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash") + val plainTorrent = StreamItem( + name = "Torrent", + infoHash = "hash", + addonName = "Addon", + addonId = "addon", + ) + + assertTrue(direct.isDirectDebridStream) + assertFalse(direct.isTorrentStream) + assertTrue(plainTorrent.isTorrentStream) + } + + @Test + fun `sorts and limits streams by quality and size`() { + val streams = listOf( + stream(resolution = "1080p", size = 20), + stream(resolution = "2160p", size = 10), + stream(resolution = "2160p", size = 30), + stream(resolution = "720p", size = 40), + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + streams, + DebridSettings( + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + ), + ) + + assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `filters minimum quality dolby vision hdr and codec`() { + val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10) + val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20) + val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30) + val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40) + + val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings( + streamMinimumQuality = DebridStreamMinimumQuality.P2160, + streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE, + streamHdrFilter = DebridStreamFeatureFilter.ONLY, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ) + + assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size }) + + val dvOnly = DirectDebridStreamFilter.filterInstant( + listOf(hdrHevc, dvHevc, sdrAvc, hdHevc), + DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY), + ) + + assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size }) + } + + @Test + fun `applies stream preference filters and sort criteria`() { + val remuxAtmos = stream( + resolution = "2160p", + quality = "BluRay REMUX", + codec = "HEVC", + audio = listOf("Atmos", "TrueHD"), + channels = listOf("7.1"), + languages = listOf("en"), + group = "GOOD", + size = 40_000_000_000, + ) + val webAac = stream( + resolution = "2160p", + quality = "WEB-DL", + codec = "AVC", + audio = listOf("AAC"), + channels = listOf("2.0"), + languages = listOf("en"), + group = "NOPE", + size = 4_000_000_000, + ) + val blurayDts = stream( + resolution = "1080p", + quality = "BluRay", + codec = "AVC", + audio = listOf("DTS"), + channels = listOf("5.1"), + languages = listOf("hi"), + group = "GOOD", + size = 12_000_000_000, + ) + + val filtered = DirectDebridStreamFilter.filterInstant( + listOf(webAac, blurayDts, remuxAtmos), + DebridSettings( + streamPreferences = DebridStreamPreferences( + maxResults = 2, + maxPerResolution = 1, + sizeMinGb = 5, + requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080), + excludedQualities = listOf(DebridStreamQuality.WEB_DL), + requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1), + excludedEncodes = listOf(DebridStreamEncode.UNKNOWN), + excludedLanguages = listOf(DebridStreamLanguage.IT), + requiredReleaseGroups = listOf("GOOD"), + sortCriteria = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC), + ), + ), + ), + ) + + assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size }) + } + + private fun stream( + service: String? = DebridProviders.TORBOX_ID, + cached: Boolean? = true, + type: String = "debrid", + infoHash: String = "hash", + fileIdx: Int = 1, + resolution: String? = null, + quality: String? = null, + hdr: List = emptyList(), + codec: String? = null, + audio: List = emptyList(), + channels: List = emptyList(), + languages: List = emptyList(), + group: String? = null, + size: Long? = null, + ): StreamItem = + StreamItem( + name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}", + addonName = "Direct Debrid", + addonId = "debrid", + clientResolve = StreamClientResolve( + type = type, + service = service, + isCached = cached, + infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), + fileIdx = fileIdx, + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + size = size, + folderSize = size, + parsed = StreamClientResolveParsed( + resolution = resolution, + quality = quality, + hdr = hdr, + codec = codec, + audio = audio, + channels = channels, + languages = languages, + group = group, + ), + ), + ), + ), + ) +} + +private fun Long?.orEmptyHashPart(): String = + this?.toString().orEmpty() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt new file mode 100644 index 00000000..68acd752 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridStreamPreparerTest { + + @Test + fun `prioritizes autoplay direct debrid match before display order`() { + val first = directDebridStream(name = "1080p", infoHash = "hash-1") + val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2") + val remaining = directDebridStream(name = "720p", infoHash = "hash-3") + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(first, autoPlayMatch, remaining), + limit = 2, + playerSettings = PlayerSettingsUiState( + streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH, + streamAutoPlayRegex = "2160p", + ), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(autoPlayMatch, first), selected) + } + + @Test + fun `skips already resolved and duplicate direct debrid candidates`() { + val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1") + val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1") + val alreadyResolved = directDebridStream( + name = "2160p", + infoHash = "hash-2", + url = "https://example.com/ready.mp4", + ) + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(unresolved, duplicate, alreadyResolved), + limit = 5, + playerSettings = PlayerSettingsUiState(), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(unresolved), selected) + } + + private fun directDebridStream( + name: String, + infoHash: String, + url: String? = null, + ): StreamItem = + StreamItem( + name = name, + url = url, + addonName = "Torbox Instant", + addonId = "debrid:torbox", + clientResolve = StreamClientResolve( + type = "debrid", + service = DebridProviders.TORBOX_ID, + isCached = true, + infoHash = infoHash, + fileIdx = 1, + filename = "video.mkv", + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 45fa4740..1ebf6b84 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt @@ -145,16 +145,49 @@ class StreamAutoPlaySelectorTest { assertNull(selected) } + @Test + fun `first stream mode can select direct debrid candidate without resolved URL`() { + val directDebrid = stream( + addonName = "Torbox Instant", + url = null, + name = "TB Instant", + directDebrid = true, + ) + + val selected = StreamAutoPlaySelector.selectAutoPlayStream( + streams = listOf(directDebrid), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = emptySet(), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + ) + + assertEquals(directDebrid, selected) + } + private fun stream( addonName: String, url: String? = null, name: String? = null, bingeGroup: String? = null, + directDebrid: Boolean = false, ): StreamItem = StreamItem( name = name, url = url, addonName = addonName, addonId = addonName, + clientResolve = if (directDebrid) { + StreamClientResolve( + type = "debrid", + service = "torbox", + isCached = true, + infoHash = "hash", + ) + } else { + null + }, behaviorHints = StreamBehaviorHints( bingeGroup = bingeGroup, ), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 09434fac..9260e883 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -120,4 +120,55 @@ class StreamParserTest { assertEquals("ok", responseHeaders["x-test"]) } + @Test + fun `parse keeps client resolve metadata without direct URL`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "name": "Instant", + "clientResolve": { + "type": "debrid", + "infoHash": "abc123", + "fileIdx": 4, + "sources": ["udp://tracker.example"], + "torrentName": "Movie Pack", + "filename": "Movie.2024.2160p.mkv", + "service": "torbox", + "isCached": true, + "stream": { + "raw": { + "size": 1610612736, + "indexer": "Indexer", + "parsed": { + "parsed_title": "Movie", + "year": 2024, + "resolution": "2160p", + "hdr": ["DV"], + "audio": ["Atmos"], + "episodes": [1, 2], + "bit_depth": "10bit" + } + } + } + } + } + ] + } + """.trimIndent(), + addonName = "Direct Debrid", + addonId = "debrid:torbox", + ) + + val stream = streams.single() + assertTrue(stream.isDirectDebridStream) + assertFalse(stream.isTorrentStream) + assertEquals("abc123", stream.clientResolve?.infoHash) + assertEquals(4, stream.clientResolve?.fileIdx) + assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single()) + assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) + assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt new file mode 100644 index 00000000..dc85c449 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -0,0 +1,193 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import platform.Foundation.NSUserDefaults + +actual object DebridSettingsStorage { + private const val enabledKey = "debrid_enabled" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamMaxResultsKey = "debrid_stream_max_results" + private const val streamSortModeKey = "debrid_stream_sort_mode" + private const val streamMinimumQualityKey = "debrid_stream_minimum_quality" + private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter" + private const val streamHdrFilterKey = "debrid_stream_hdr_filter" + private const val streamCodecFilterKey = "debrid_stream_codec_filter" + private const val streamPreferencesKey = "debrid_stream_preferences" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val syncKeys = listOf( + enabledKey, + torboxApiKeyKey, + realDebridApiKeyKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + + actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey) + + actual fun saveStreamMaxResults(maxResults: Int) { + saveInt(streamMaxResultsKey, maxResults) + } + + actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey) + + actual fun saveStreamSortMode(mode: String) { + saveString(streamSortModeKey, mode) + } + + actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey) + + actual fun saveStreamMinimumQuality(quality: String) { + saveString(streamMinimumQualityKey, quality) + } + + actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey) + + actual fun saveStreamDolbyVisionFilter(filter: String) { + saveString(streamDolbyVisionFilterKey, filter) + } + + actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey) + + actual fun saveStreamHdrFilter(filter: String) { + saveString(streamHdrFilterKey, filter) + } + + actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey) + + actual fun saveStreamCodecFilter(filter: String) { + saveString(streamCodecFilterKey, filter) + } + + actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey) + + actual fun saveStreamPreferences(preferences: String) { + saveString(streamPreferencesKey, preferences) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.boolForKey(scopedKey) + } else { + null + } + } + + private fun saveBoolean(key: String, enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key)) + } + + private fun loadInt(key: String): Int? { + val defaults = NSUserDefaults.standardUserDefaults + val scopedKey = ProfileScopedKey.of(key) + return if (defaults.objectForKey(scopedKey) != null) { + defaults.integerForKey(scopedKey).toInt() + } else { + null + } + } + + private fun saveInt(key: String, value: Int) { + NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key)) + } + + private fun loadString(key: String): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key)) + + private fun saveString(key: String, value: String) { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key)) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) } + loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) } + loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) } + loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) } + loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) } + loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) } + loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +}