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