diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 5c5811e4..98455633 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -90,6 +90,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/debrid").apply { + mkdirs() + resolve("PremiumizeConfig.kt").writeText( + """ + |package com.nuvio.app.features.debrid + | + |object PremiumizeConfig { + | const val CLIENT_ID = "${props.getProperty("PREMIUMIZE_CLIENT_ID", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 2c8bebbb..94036653 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -15,6 +15,7 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner import com.nuvio.app.features.addons.AddonStorage import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.collection.CollectionStorage +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage @@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() { SearchHistoryStorage.initialize(applicationContext) SeasonViewModeStorage.initialize(applicationContext) PosterCardStyleStorage.initialize(applicationContext) + DebridSettingsStorage.initialize(applicationContext) TmdbSettingsStorage.initialize(applicationContext) MdbListSettingsStorage.initialize(applicationContext) TraktAuthStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt new file mode 100644 index 00000000..de8e76b1 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -0,0 +1,252 @@ +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 cloudLibraryEnabledKey = "debrid_cloud_library_enabled" + private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" + 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 fun syncKeys(): List = + listOf( + enabledKey, + cloudLibraryEnabledKey, + preferredResolverProviderIdKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + DebridProviders.all().map { providerApiKeyKey(it.id) } + + 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 loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) + + actual fun saveCloudLibraryEnabled(enabled: Boolean) { + saveBoolean(cloudLibraryEnabledKey, enabled) + } + + actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) + + actual fun savePreferredResolverProviderId(providerId: String) { + saveString(preferredResolverProviderIdKey, providerId) + } + + actual fun loadProviderApiKey(providerId: String): String? = + loadString(providerApiKeyKey(providerId)) + + actual fun saveProviderApiKey(providerId: String, apiKey: String) { + saveString(providerApiKeyKey(providerId), apiKey) + } + + actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) + + actual fun saveTorboxApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, 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)) } + loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } + loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } + DebridProviders.all().forEach { provider -> + loadProviderApiKey(provider.id)?.let { + put(providerApiKeyKey(provider.id), 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.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) + payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) + DebridProviders.all().forEach { provider -> + payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> + saveProviderApiKey(provider.id, apiKey) + } + } + 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) + } + + private fun providerApiKeyKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return when (normalized) { + DebridProviders.TORBOX_ID -> torboxApiKeyKey + DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey + else -> "debrid_${normalized}_api_key" + } + } +} diff --git a/composeApp/src/commonMain/composeResources/values-no/strings.xml b/composeApp/src/commonMain/composeResources/values-no/strings.xml index 89e10815..15e482d2 100644 --- a/composeApp/src/commonMain/composeResources/values-no/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-no/strings.xml @@ -379,6 +379,7 @@ Utseende Innhold & oppdagelse Fortsett å se + Tilkoblede tjenester Hjemmeoppsett Integrasjoner Lisenser & attribusjon @@ -587,6 +588,48 @@ Integrasjoner Metadata-berikelse-kontroller Eksterne vurderingsleverandører + Koble til kontoer for lenker og bibliotektilgang + Tilkoblede tjenester + Disse integrasjonene er eksperimentelle og kan endres eller fjernes senere. + Skybibliotek + Bla gjennom og spill filer som allerede finnes i tilkoblede kontoer. + Løs spillbare lenker + Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten. + Løs med + Velg hvilken tilkoblet konto som håndterer spillbare lenker. + Koble til en konto først. + Kontoer + Koble til %1$s-kontoen din. + Koble til %1$s-kontoen din i nettleseren. + %1$s API-nøkkel + Skriv inn API-nøkkelen din for %1$s. + Skriv inn %1$s API-nøkkel + Tilkoblet + Koble til %1$s + Koble fra %1$s + Koble fra + %1$s er koblet til på denne enheten. + Starter sikker innlogging... + Åpne lenken og skriv inn denne koden for å godkjenne Nuvio. + Kode kopiert. + Åpne lenke + Venter på godkjenning... + Kunne ikke starte innlogging. + Denne innloggingsmetoden er ikke konfigurert i denne versjonen. + Denne koden er utløpt. Prøv igjen. + Lenkeforberedelse + Forbered lenker + Løs spillbare lenker før avspilling starter. + Lenker å forberede + 1 lenke + %1$d lenker + Formatering + Navnemal + Styrer hvordan resultatnavn vises. + Beskrivelsesmal + Styrer metadata vist under hvert resultat. + API-nøkkel validert. + Kunne ikke validere denne API-nøkkelen. Legg til MDBList API-nøkkel før du skrur på vurderinger. Kreves for å hente vurderinger fra MDBList API-nøkkel @@ -1122,6 +1165,9 @@ Gjenoppta fra %1$s STØRRELSE %1$s Denne strømtypen støttes ikke + Koble til en konto i Innstillinger. + Denne lenken er utgått. Oppdaterer resultater. + Kunne ikke åpne denne lenken. Kunne ikke åpne ekstern avspiller Velg en ekstern avspiller i innstillinger først Ingen ekstern avspiller er tilgjengelig diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 3fa135b3..330c2cd6 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -380,6 +380,7 @@ Wygląd Treści i odkrywanie Kontynuuj oglądanie + Debrid Ekran główny Integracje Licencje i atrybucje @@ -588,6 +589,34 @@ INTEGRACJE Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Eksperymentalne źródła z kont chmurowych + Debrid + Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości. + Włącz źródła + Pokaż odtwarzalne wyniki z połączonych kont. + Najpierw dodaj klucz API. + Konto + Połącz swoje konto Torbox. + Klucz API Torbox + Wprowadź swój klucz API Torbox. + Wprowadź klucz API Torbox + Nie ustawiono + Natychmiastowe odtwarzanie + Przygotuj linki + Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania. + Źródła do przygotowania + Używaj niższej liczby, gdy to możliwe. Usługi Debrid mogą ograniczać liczbę linków rozwiązywanych w danym okresie. Otwarcie filmu lub odcinka może się wliczać do tych limitów, nawet jeśli nie naciśniesz Odtwórz, ponieważ linki są przygotowywane z wyprzedzeniem. + 1 źródło + %1$d źródeł + Formatowanie + Szablon nazwy + Kontroluje sposób wyświetlania nazw źródeł. + Szablon opisu + Kontroluje metadane wyświetlane pod każdym źródłem. + Resetuj formatowanie + Przywróć domyślne formatowanie źródeł. + Klucz API zweryfikowany. + Nie udało się zweryfikować tego klucza API. Dodaj klucz API MDBList poniżej przed włączeniem ocen. Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. Klucz API @@ -1125,6 +1154,9 @@ Wznów od %1$s ROZMIAR %1$s Ten typ strumienia nie jest obsługiwany + Dodaj klucz API Debrid w Ustawieniach. + Ten wynik Debrid wygasł. Odświeżanie strumieni. + Nie udało się rozwiązać tego strumienia Debrid. Nie udało się otworzyć zewnętrznego odtwarzacza Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach Brak dostępnego zewnętrznego odtwarzacza diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 8f585ada..4d057fd2 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -380,6 +380,7 @@ Layout Content & Discovery Continue Watching + Connected Services Home Layout Integrations Licenses & Attribution @@ -588,6 +589,52 @@ Integrations Metadata enrichment controls External ratings providers + Connect accounts for links and library access + Connected Services + These integrations are experimental and may be kept, changed, or removed later. + Cloud library + Browse and play files already in your connected accounts. + Resolve playable links + Ask a connected service for playable links when a result needs it. This may add the item to that service. + Resolve with + Choose which connected account handles playable links. + Connect an account first. + Accounts + Connect your %1$s account. + Link your %1$s account in the browser. + %1$s API Key + Enter your %1$s API key. + Enter %1$s API key + Not set + Connected + Connect %1$s + Disconnect %1$s + Disconnect + %1$s is connected on this device. + Starting secure sign-in... + Open the link and enter this code to approve Nuvio. + Code copied. + Open link + Waiting for approval... + Could not start sign-in. + This sign-in method is not configured in this build. + This code expired. Try again. + Link Preparation + Prepare links + Resolve playable links before playback starts. + Links to prepare + Use a lower count when possible. Connected 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 link + %1$d links + Formatting + Name template + Controls how result names appear. + Description template + Controls the metadata shown under each result. + Reset formatting + Restore default result formatting. + API key validated. + Could not validate this API key. Add your MDBList API key below before turning ratings on. Required to fetch ratings from MDBList API Key @@ -1126,6 +1173,10 @@ Resume from %1$s SIZE %1$s This stream type is not supported + Connect an account in Settings. + Not cached on Torbox. + This link expired. Refreshing results. + Could not open this link. Couldn't open external player Choose an external player in settings first No external player is available @@ -1277,11 +1328,42 @@ Your library is empty Couldn't load library Other + Cloud + Saved Library Connect Trakt and save titles to your watchlist or personal lists. Your Trakt library is empty Couldn't load Trakt library Trakt Library + Connect account + Connect an account in Connected Services settings to browse playable files from your cloud library. + No cloud account connected + Open Connected Services + Turn on Cloud library in Connected Services settings to browse files from connected accounts. + Cloud library is off + No playable cloud files match the current filters. + Nothing here yet + Choose a file to play + Couldn't load %1$s cloud library + This item does not expose a playable video file. + No playable files + No playable files + Cloud library is off. + Couldn't play this cloud file. + Play file + Cloud service is not connected. + %1$s is not connected. + %1$d playable files + All + Refresh cloud library + Select provider + Select type + Ready to play + All + Torrents + Usenet + Web + Files Anime Channels Movies diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 10f6361d..2f33f3dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -106,6 +106,17 @@ 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.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult +import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult +import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.cloud.providerPosterUrl +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 @@ -177,6 +188,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepositor import com.nuvio.app.features.watchprogress.ResumePromptRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.Flow @@ -562,6 +574,7 @@ private fun MainAppContent( var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterActionTarget by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } + var requestedSettingsPageName by rememberSaveable { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } var pickerTitle by remember { mutableStateOf("") } @@ -598,6 +611,9 @@ private fun MainAppContent( val externalPlayerNotConfiguredText = stringResource(Res.string.external_player_not_configured) val externalPlayerUnavailableText = stringResource(Res.string.external_player_unavailable) val externalPlayerFailedText = stringResource(Res.string.external_player_failed) + val cloudLibraryPlayFailedText = stringResource(Res.string.cloud_library_play_failed) + val cloudLibraryPlayDisabledText = stringResource(Res.string.cloud_library_play_disabled) + val cloudLibraryPlayNotConnectedText = stringResource(Res.string.cloud_library_play_not_connected) val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } @@ -833,6 +849,52 @@ private fun MainAppContent( } } + suspend fun launchCloudLibraryFile( + item: CloudLibraryItem, + file: CloudLibraryFile, + resumePositionMs: Long? = null, + resumeProgressFraction: Float? = null, + startFromBeginning: Boolean = false, + ): Boolean { + return when ( + val resolved = CloudLibraryRepository.resolvePlayback( + item = item, + file = file, + ) + ) { + is CloudLibraryPlaybackResult.Success -> { + val playbackTitle = resolved.filename + ?.takeIf { it.isNotBlank() } + ?: file.name.ifBlank { item.name } + val playerLaunch = PlayerLaunch( + title = playbackTitle, + sourceUrl = resolved.url, + streamTitle = playbackTitle, + streamSubtitle = item.name.takeIf { it != playbackTitle }, + providerName = item.providerName, + providerAddonId = "cloud:${item.providerId}", + poster = item.providerPosterUrl(), + contentType = CloudLibraryContentType, + videoId = item.playbackVideoId(file), + parentMetaId = item.stableKey, + parentMetaType = CloudLibraryContentType, + initialPositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L), + initialProgressFraction = if (startFromBeginning) null else resumeProgressFraction, + ) + if (playerSettingsUiState.externalPlayerEnabled) { + openExternalPlayback(playerLaunch) + true + } else { + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) + true + } + } + + else -> false + } + } + fun launchPlaybackWithDownloadPreference( type: String, videoId: String, @@ -1003,25 +1065,67 @@ private fun MainAppContent( } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> - launchPlaybackWithDownloadPreference( - type = item.parentMetaType, - videoId = item.videoId, - parentMetaId = item.parentMetaId, - parentMetaType = item.parentMetaType, - title = item.title, - logo = item.logo, - poster = item.poster, - background = item.background, - seasonNumber = item.seasonNumber, - episodeNumber = item.episodeNumber, - episodeTitle = item.episodeTitle, - episodeThumbnail = item.episodeThumbnail, - pauseDescription = item.pauseDescription, - resumePositionMs = item.resumePositionMs, - resumeProgressFraction = item.resumeProgressFraction, - manualSelection = manualSelection, - startFromBeginning = startFromBeginning, - ) + if (item.isCloudLibraryContinueWatchingItem()) { + coroutineScope.launch { + when ( + val lookup = CloudLibraryRepository.findPlaybackTargetForProgressResult( + contentId = item.parentMetaId, + videoId = item.videoId, + ) + ) { + is CloudLibraryPlaybackTargetLookupResult.Found -> { + val launched = launchCloudLibraryFile( + item = lookup.target.item, + file = lookup.target.file, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + startFromBeginning = startFromBeginning, + ) + if (!launched) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + + CloudLibraryPlaybackTargetLookupResult.Disabled -> { + NuvioToastController.show(cloudLibraryPlayDisabledText) + } + + is CloudLibraryPlaybackTargetLookupResult.NotConnected -> { + val providerName = lookup.providerName?.takeIf { it.isNotBlank() } + NuvioToastController.show( + providerName?.let { name -> + getString(Res.string.cloud_library_play_provider_not_connected, name) + } + ?: cloudLibraryPlayNotConnectedText, + ) + } + + CloudLibraryPlaybackTargetLookupResult.NotFound -> { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + } + } else { + launchPlaybackWithDownloadPreference( + type = item.parentMetaType, + videoId = item.videoId, + parentMetaId = item.parentMetaId, + parentMetaType = item.parentMetaType, + title = item.title, + logo = item.logo, + poster = item.poster, + background = item.background, + seasonNumber = item.seasonNumber, + episodeNumber = item.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + resumePositionMs = item.resumePositionMs, + resumeProgressFraction = item.resumeProgressFraction, + manualSelection = manualSelection, + startFromBeginning = startFromBeginning, + ) + } } val onContinueWatchingClick: (ContinueWatchingItem) -> Unit = { item -> @@ -1154,6 +1258,28 @@ private fun MainAppContent( ) }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, + onCloudFilePlay = { item, file -> + coroutineScope.launch { + val resumeItem = WatchProgressRepository + .progressForVideo(item.playbackVideoId(file)) + ?.takeIf { it.isResumable } + ?.toContinueWatchingItem() + if ( + !launchCloudLibraryFile( + item = item, + file = file, + resumePositionMs = resumeItem?.resumePositionMs, + resumeProgressFraction = resumeItem?.resumeProgressFraction, + ) + ) { + NuvioToastController.show(cloudLibraryPlayFailedText) + } + } + }, + onConnectCloudClick = { + requestedSettingsPageName = "Debrid" + selectedTab = AppScreenTab.Settings + }, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, onSwitchProfile = onSwitchProfile, @@ -1188,6 +1314,10 @@ private fun MainAppContent( onFolderClick = { collectionId, folderId -> navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) }, + requestedSettingsPageName = requestedSettingsPageName, + onRequestedSettingsPageConsumed = { + requestedSettingsPageName = null + }, onInitialHomeContentRendered = { initialHomeReady = true }, ) } @@ -1357,6 +1487,8 @@ private fun MainAppContent( return@composable } val pauseDescription = launch.pauseDescription + val streamRouteScope = rememberCoroutineScope() + var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) } val lifecycleOwner = backStackEntry DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> @@ -1506,8 +1638,42 @@ private fun MainAppContent( if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect - val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect - val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect + val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect + val stream = if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(selectedStream)) { + when ( + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = selectedStream, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) + ) { + is DirectDebridPlayableResult.Success -> resolved.stream + else -> { + val hasNextCandidate = StreamsRepository.skipAutoPlayStream(selectedStream) + if (!hasNextCandidate) { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + } + if (!hasNextCandidate && resolved == DirectDebridPlayableResult.Stale) { + StreamsRepository.reload( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) + } + return@LaunchedEffect + } + } + } else { + selectedStream + } + val sourceUrl = stream.playableDirectUrl + if (sourceUrl == null) { + StreamsRepository.skipAutoPlayStream(selectedStream) + return@LaunchedEffect + } autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -1584,7 +1750,42 @@ private fun MainAppContent( forceExternal: Boolean, forceInternal: Boolean, ) { - val sourceUrl = stream.directPlaybackUrl ?: return + if (DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) { + 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.playableDirectUrl ?: return if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, @@ -1687,6 +1888,26 @@ private fun MainAppContent( }, modifier = Modifier.fillMaxSize(), ) + if (resolvingDebridStream) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.82f)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator(color = Color.White) + Text( + text = stringResource(Res.string.streams_finding_source), + color = Color.White.copy(alpha = 0.82f), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } composable( @@ -2020,6 +2241,7 @@ private fun MainAppContent( NuvioContinueWatchingActionSheet( item = selectedContinueWatchingForActions, showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState), + showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true, onDismiss = { selectedContinueWatchingForActions = null }, onOpenDetails = { selectedContinueWatchingForActions?.let { item -> @@ -2196,6 +2418,8 @@ private fun AppTabHost( onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, + onConnectCloudClick: (() -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null, @@ -2211,6 +2435,8 @@ private fun AppTabHost( onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, + requestedSettingsPageName: String? = null, + onRequestedSettingsPageConsumed: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {}, ) { val tabStateHolder = rememberSaveableStateHolder() @@ -2250,6 +2476,8 @@ private fun AppTabHost( onPosterClick = onLibraryPosterClick, onPosterLongClick = onLibraryPosterLongClick, onSectionViewAllClick = onLibrarySectionViewAllClick, + onCloudFilePlay = onCloudFilePlay, + onConnectCloudClick = onConnectCloudClick, ) } @@ -2257,6 +2485,8 @@ private fun AppTabHost( SettingsScreen( modifier = Modifier.fillMaxSize(), rootActionRequests = settingsRootActionRequests, + requestedPageName = requestedSettingsPageName, + onRequestedPageConsumed = onRequestedSettingsPageConsumed, rootActionsEnabled = rootActionsEnabled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, @@ -2391,6 +2621,9 @@ private fun TabletFloatingTopBar( } } +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + @Composable private fun TabletTopPillItem( label: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index 58df719e..aacb5336 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -6,6 +6,8 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionMobileSettingsStorage +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -157,6 +159,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, + DebridSettingsRepository.uiState.map { "debrid" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, @@ -202,6 +205,7 @@ object ProfileSettingsSync { themeSettings = ThemeSettingsStorage.exportToSyncPayload(), posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(), playerSettings = PlayerSettingsStorage.exportToSyncPayload(), + debridSettings = DebridSettingsStorage.exportToSyncPayload(), tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), @@ -226,6 +230,9 @@ object ProfileSettingsSync { PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings) PlayerSettingsRepository.onProfileChanged() + DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings) + DebridSettingsRepository.onProfileChanged() + TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings) TmdbSettingsRepository.onProfileChanged() @@ -255,6 +262,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.ensureLoaded() PosterCardStyleRepository.ensureLoaded() PlayerSettingsRepository.ensureLoaded() + DebridSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() @@ -277,6 +285,7 @@ object ProfileSettingsSync { "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", + "debrid=${DebridSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", @@ -299,6 +308,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()), @SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "", @SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()), + @SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()), @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt index 8c122e2c..f99e90b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.ui import androidx.compose.runtime.Composable +import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.watchprogress.ContinueWatchingItem import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -18,6 +19,8 @@ fun localizedContinueWatchingSubtitle(item: ContinueWatchingItem): String { stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index b85173d3..693017d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.watchprogress.ContinueWatchingItem import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res @@ -42,6 +44,7 @@ import org.jetbrains.compose.resources.stringResource fun NuvioContinueWatchingActionSheet( item: ContinueWatchingItem?, showManualPlayOption: Boolean, + showDetailsOption: Boolean = true, onDismiss: () -> Unit, onOpenDetails: () -> Unit, onStartFromBeginning: (() -> Unit)? = null, @@ -73,12 +76,14 @@ fun NuvioContinueWatchingActionSheet( .padding(bottom = nuvioSafeBottomPadding(16.dp)), ) { ContinueWatchingSheetHeader(item = item) - NuvioBottomSheetDivider() - NuvioBottomSheetActionRow( - icon = Icons.Default.Info, - title = stringResource(Res.string.cw_action_go_to_details), - onClick = { dismissAfter(onOpenDetails) }, - ) + if (showDetailsOption) { + NuvioBottomSheetDivider() + NuvioBottomSheetActionRow( + icon = Icons.Default.Info, + title = stringResource(Res.string.cw_action_go_to_details), + onClick = { dismissAfter(onOpenDetails) }, + ) + } if (showManualPlayOption && onPlayManually != null) { NuvioBottomSheetDivider() NuvioBottomSheetActionRow( @@ -128,10 +133,10 @@ private fun ContinueWatchingSheetHeader( val artwork = item.poster ?: item.imageUrl if (artwork != null) { AsyncImage( - model = artwork, + model = cloudLibraryDisplayArtworkUrl(artwork), contentDescription = item.title, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } else { Text( @@ -167,3 +172,6 @@ private fun ContinueWatchingSheetHeader( } } } + +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt new file mode 100644 index 00000000..85ea9052 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +data class NuvioDropdownOption( + val key: String, + val label: String, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NuvioDropdownChip( + title: String, + label: String, + selectedKey: String?, + options: List, + enabled: Boolean = true, + onSelected: (NuvioDropdownOption) -> Unit, + modifier: Modifier = Modifier, +) { + var isSheetVisible by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + Row( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .then( + if (enabled) { + Modifier.clickable { isSheetVisible = true } + } else { + Modifier + }, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, + ) + } + + if (isSheetVisible) { + NuvioDropdownOptionsSheet( + title = title, + options = options, + selectedKey = selectedKey, + sheetState = sheetState, + onDismiss = { + coroutineScope.launch { + dismissNuvioBottomSheet( + sheetState = sheetState, + onDismiss = { isSheetVisible = false }, + ) + } + }, + onSelected = { option -> + onSelected(option) + coroutineScope.launch { + dismissNuvioBottomSheet( + sheetState = sheetState, + onDismiss = { isSheetVisible = false }, + ) + } + }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NuvioDropdownOptionsSheet( + title: String, + options: List, + selectedKey: String?, + sheetState: SheetState, + onDismiss: () -> Unit, + onSelected: (NuvioDropdownOption) -> Unit, +) { + NuvioModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = nuvioSafeBottomPadding(16.dp)), + ) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + NuvioBottomSheetDivider() + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + ) { + itemsIndexed(options) { index, option -> + NuvioBottomSheetActionRow( + title = option.label, + onClick = { onSelected(option) }, + trailingContent = { + if (option.key == selectedKey) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + }, + ) + if (index < options.lastIndex) { + NuvioBottomSheetDivider() + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt new file mode 100644 index 00000000..751c0608 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryModels.kt @@ -0,0 +1,122 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider + +enum class CloudLibraryItemType { + Torrent, + Usenet, + WebDownload, + File, +} + +data class CloudLibraryFile( + val id: String?, + val name: String, + val sizeBytes: Long? = null, + val mimeType: String? = null, + val playable: Boolean = true, + val playbackUrl: String? = null, +) { + val stableKey: String + get() = id ?: name +} + +data class CloudLibraryItem( + val providerId: String, + val providerName: String, + val id: String, + val type: CloudLibraryItemType, + val name: String, + val status: String? = null, + val sizeBytes: Long? = null, + val progressFraction: Float? = null, + val files: List = emptyList(), +) { + val stableKey: String + get() = "$providerId:${type.name}:$id" + + val playableFiles: List + get() = files.filter { it.playable } +} + +data class CloudLibraryPlaybackTarget( + val item: CloudLibraryItem, + val file: CloudLibraryFile, +) + +sealed interface CloudLibraryPlaybackTargetLookupResult { + data class Found(val target: CloudLibraryPlaybackTarget) : CloudLibraryPlaybackTargetLookupResult + data object Disabled : CloudLibraryPlaybackTargetLookupResult + data class NotConnected(val providerName: String? = null) : CloudLibraryPlaybackTargetLookupResult + data object NotFound : CloudLibraryPlaybackTargetLookupResult +} + +const val CloudLibraryContentType = "cloud" +const val TorboxCloudLibraryPosterUrl = "https://torbox.app/assets/logo-bb7a9579.svg" +const val PremiumizeCloudLibraryPosterUrl = "https://www.premiumize.me/icon_normal.svg" +private const val TorboxCloudLibraryPosterDataUrl = + "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjM2NyAzMDggNzY2IDg4NCI+PHBvbHlnb24gZmlsbD0iIzAwNDQ0RCIgcG9pbnRzPSI3NDkuOTksNzQ5Ljk5IDc0OS45OSwxMTkxLjk2IDM2Ny4yNSw5NzAuOTcgMzY3LjI1LDUyOS4wMSIvPjxwb2x5Z29uIGZpbGw9IiMzNEJBOTAiIHBvaW50cz0iMTEzMi43NSw1MjkuMDEgMTEzMi43NSw5NzAuOTcgNzQ5Ljk5LDExOTEuOTYgNzQ5Ljk5LDc0OS45OSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYiLz48cG9seWdvbiBmaWxsPSIjNTJBMTUzIiBwb2ludHM9IjExMzIuNzUsNTI5LjAxIDc0OS45OSw3NDkuOTkgMzY3LjI1LDUyOS4wMSA3NDkuOTksMzA4LjA0Ii8+PHBvbHlnb24gZmlsbD0iI0ZGRkZGRiIgcG9pbnRzPSIxMDQzLjA0LDczOS4zNiA5NTguNjYsMTA1Ny4wOCA5NTIuNCw4NTEuODQgODM5LjcxLDkxNS4zOSA4NzIuODcsNjc5LjA1IDk1Ni43MSw2MzAuNjYgOTMxLjgxLDc5OS4yMSIvPjwvc3ZnPg==" + +fun CloudLibraryItem.playbackVideoId(file: CloudLibraryFile): String = + "$stableKey:${file.stableKey}" + +fun CloudLibraryItem.providerPosterUrl(): String? = + cloudLibraryProviderPosterUrl(providerId) + +fun cloudLibraryProviderPosterUrl(providerIdOrContentId: String?): String? = + when (cloudLibraryProviderId(providerIdOrContentId)) { + "torbox" -> TorboxCloudLibraryPosterUrl + "premiumize" -> PremiumizeCloudLibraryPosterUrl + else -> null + } + +fun cloudLibraryDisplayArtworkUrl(url: String?): String? = + when (url?.trim()) { + TorboxCloudLibraryPosterUrl -> TorboxCloudLibraryPosterDataUrl + else -> url?.trim() + } + +fun cloudLibraryProviderId(providerIdOrContentId: String?): String = + providerIdOrContentId.orEmpty() + .trim() + .removePrefix("$CloudLibraryContentType:") + .substringBefore(':') + .lowercase() + +data class CloudLibraryProviderState( + val provider: DebridProvider, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val items: List = emptyList(), +) { + val providerId: String + get() = provider.id + + val providerName: String + get() = provider.displayName +} + +data class CloudLibraryUiState( + val isLoaded: Boolean = false, + val isEnabled: Boolean = true, + val isRefreshing: Boolean = false, + val providers: List = emptyList(), +) { + val items: List + get() = providers.flatMap { it.items } + + val hasConnectedProvider: Boolean + get() = providers.isNotEmpty() +} + +sealed interface CloudLibraryPlaybackResult { + data class Success( + val url: String, + val filename: String? = null, + val videoSizeBytes: Long? = null, + ) : CloudLibraryPlaybackResult + + data object MissingCredentials : CloudLibraryPlaybackResult + data object NotPlayable : CloudLibraryPlaybackResult + data class Failed(val message: String? = null) : CloudLibraryPlaybackResult +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt new file mode 100644 index 00000000..d9e98970 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryProviderApi.kt @@ -0,0 +1,30 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviders + +internal interface CloudLibraryProviderApi { + val provider: DebridProvider + + suspend fun listItems(apiKey: String): Result> + + suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult +} + +internal object CloudLibraryProviderApis { + private val registered = listOf( + TorboxCloudLibraryProviderApi(), + PremiumizeCloudLibraryProviderApi(), + ) + + fun all(): List = registered + + fun apiFor(providerId: String?): CloudLibraryProviderApi? { + val normalized = DebridProviders.byId(providerId)?.id ?: return null + return registered.firstOrNull { it.provider.id == normalized } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt new file mode 100644 index 00000000..492b70a3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/CloudLibraryRepository.kt @@ -0,0 +1,308 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviderCapability +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridServiceCredential +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.supports +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal class CloudLibraryStore( + private val credentialsProvider: suspend () -> List, + private val providerApis: List, +) { + suspend fun refresh(): CloudLibraryUiState { + val credentials = credentialsProvider() + .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + val providerStates = credentials.map { credential -> + val api = providerApis.firstOrNull { it.provider.id == credential.provider.id } + if (api == null) { + return@map CloudLibraryProviderState( + provider = credential.provider, + errorMessage = "Cloud library is not available for ${credential.provider.displayName}.", + ) + } + + api.listItems(credential.apiKey) + .fold( + onSuccess = { items -> + CloudLibraryProviderState( + provider = credential.provider, + items = items, + ) + }, + onFailure = { error -> + CloudLibraryProviderState( + provider = credential.provider, + errorMessage = error.message, + ) + }, + ) + } + + return CloudLibraryUiState( + isLoaded = true, + isRefreshing = false, + providers = providerStates, + ) + } + + suspend fun resolvePlayback( + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + val credential = credentialsProvider() + .firstOrNull { credential -> credential.provider.id == item.providerId } + ?: return CloudLibraryPlaybackResult.MissingCredentials + val api = providerApis.firstOrNull { it.provider.id == item.providerId } + ?: return CloudLibraryPlaybackResult.Failed() + file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url -> + return CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } + return api.resolvePlayback( + apiKey = credential.apiKey, + item = item, + file = file, + ) + } +} + +object CloudLibraryRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val store = CloudLibraryStore( + credentialsProvider = { + DebridSettingsRepository.ensureLoaded() + DebridProviders.configuredServices(DebridSettingsRepository.snapshot()) + }, + providerApis = CloudLibraryProviderApis.all(), + ) + private val _uiState = MutableStateFlow(CloudLibraryUiState()) + private var loadedConnectionKeys: List = emptyList() + val uiState = _uiState.asStateFlow() + + fun ensureLoaded() { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return + } + val current = _uiState.value + if (current.isRefreshing) return + val connectedKeys = connectedCloudConnectionKeys() + if (!current.isLoaded || connectedKeys != loadedConnectionKeys) { + refresh() + } + } + + fun refresh() { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return + } + _uiState.update { current -> + current.copy( + isEnabled = true, + isRefreshing = true, + providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, + ) + } + scope.launch { + val refreshed = store.refresh() + loadedConnectionKeys = connectedCloudConnectionKeys() + _uiState.value = refreshed + } + } + + suspend fun findPlaybackTargetForProgress( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTarget? = + when (val result = findPlaybackTargetForProgressResult(contentId = contentId, videoId = videoId)) { + is CloudLibraryPlaybackTargetLookupResult.Found -> result.target + CloudLibraryPlaybackTargetLookupResult.Disabled, + is CloudLibraryPlaybackTargetLookupResult.NotConnected, + CloudLibraryPlaybackTargetLookupResult.NotFound, + -> null + } + + suspend fun findPlaybackTargetForProgressResult( + contentId: String, + videoId: String, + ): CloudLibraryPlaybackTargetLookupResult { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + loadedConnectionKeys = emptyList() + _uiState.value = CloudLibraryUiState(isLoaded = true, isEnabled = false) + return CloudLibraryPlaybackTargetLookupResult.Disabled + } + + val providerId = cloudLibraryProviderId(contentId) + .ifBlank { cloudLibraryProviderId(videoId) } + val connectedCredentials = connectedCloudCredentials() + if (connectedCredentials.isEmpty()) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = providerId.takeIf { it.isNotBlank() }?.let(DebridProviders::displayName), + ) + } + if ( + providerId.isNotBlank() && + connectedCredentials.none { credential -> credential.provider.id.equals(providerId, ignoreCase = true) } + ) { + return CloudLibraryPlaybackTargetLookupResult.NotConnected( + providerName = DebridProviders.displayName(providerId), + ) + } + + _uiState.value.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + )?.let { target -> return CloudLibraryPlaybackTargetLookupResult.Found(target) } + + val refreshed = refreshNow() + val refreshedTarget = refreshed.findPlaybackTargetForProgress( + contentId = contentId, + videoId = videoId, + ) + return if (refreshedTarget != null) { + CloudLibraryPlaybackTargetLookupResult.Found(refreshedTarget) + } else { + CloudLibraryPlaybackTargetLookupResult.NotFound + } + } + + suspend fun resolvePlayback( + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + DebridSettingsRepository.ensureLoaded() + if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) { + return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.") + } + val result = store.resolvePlayback(item, file) + if (result is CloudLibraryPlaybackResult.Success) { + rememberResolvedPlaybackUrl(item = item, file = file, url = result.url) + } + return result + } + + private fun rememberResolvedPlaybackUrl( + item: CloudLibraryItem, + file: CloudLibraryFile, + url: String, + ) { + if (url.isBlank()) return + _uiState.update { current -> + current.withResolvedPlaybackUrl( + item = item, + file = file, + url = url, + ) + } + } + + private fun connectedCloudCredentials(): List = + DebridSettingsRepository.snapshot() + .takeIf { settings -> settings.cloudLibraryEnabled } + ?.let(DebridProviders::configuredServices) + .orEmpty() + .filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + private fun connectedCloudConnectionKeys(): List = + connectedCloudCredentials().map { credential -> + CloudConnectionKey( + providerId = credential.provider.id, + apiKeyHash = credential.apiKey.hashCode(), + ) + }.sortedBy { it.providerId } + + private suspend fun refreshNow(): CloudLibraryUiState { + _uiState.update { current -> + current.copy( + isEnabled = true, + isRefreshing = true, + providers = current.providers.map { it.copy(isLoading = true, errorMessage = null) }, + ) + } + val refreshed = store.refresh() + loadedConnectionKeys = connectedCloudConnectionKeys() + _uiState.value = refreshed + return refreshed + } + + private data class CloudConnectionKey( + val providerId: String, + val apiKeyHash: Int, + ) +} + +internal fun CloudLibraryUiState.findPlaybackTargetForProgress( + contentId: String, + videoId: String, +): CloudLibraryPlaybackTarget? { + val normalizedContentId = contentId.trim() + val normalizedVideoId = videoId.trim() + if (normalizedContentId.isBlank()) return null + + val matchingItems = items.filter { item -> item.stableKey == normalizedContentId } + if (matchingItems.isEmpty()) return null + + for (item in matchingItems) { + val exactFile = item.playableFiles.firstOrNull { file -> + item.playbackVideoId(file) == normalizedVideoId + } + if (exactFile != null) { + return CloudLibraryPlaybackTarget(item = item, file = exactFile) + } + } + + val singleItem = matchingItems.singleOrNull() ?: return null + val singleFile = singleItem.playableFiles.singleOrNull() ?: return null + return CloudLibraryPlaybackTarget(item = singleItem, file = singleFile) +} + +internal fun CloudLibraryUiState.withResolvedPlaybackUrl( + item: CloudLibraryItem, + file: CloudLibraryFile, + url: String, +): CloudLibraryUiState { + val normalizedUrl = url.trim().takeIf { it.isNotBlank() } ?: return this + val targetItemKey = item.stableKey + val targetFileKey = file.stableKey + var didUpdate = false + val updatedProviders = providers.map { providerState -> + if (providerState.providerId != item.providerId) return@map providerState + val updatedItems = providerState.items.map { candidateItem -> + if (candidateItem.stableKey != targetItemKey) return@map candidateItem + val updatedFiles = candidateItem.files.map { candidateFile -> + if (candidateFile.stableKey != targetFileKey) { + candidateFile + } else { + didUpdate = true + candidateFile.copy(playbackUrl = normalizedUrl) + } + } + candidateItem.copy(files = updatedFiles) + } + providerState.copy(items = updatedItems) + } + return if (didUpdate) { + copy(providers = updatedProviders) + } else { + this + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt new file mode 100644 index 00000000..b6802543 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApi.kt @@ -0,0 +1,164 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.PremiumizeApiClient +import com.nuvio.app.features.debrid.PremiumizeCloudFileDto +import kotlinx.coroutines.CancellationException + +internal class PremiumizeCloudLibraryProviderApi : CloudLibraryProviderApi { + override val provider = DebridProviders.Premiumize + + override suspend fun listItems(apiKey: String): Result> = + runCatching { + val response = PremiumizeApiClient.listAllItems(apiKey) + if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) { + throw IllegalStateException(response.body?.message ?: response.body?.code ?: response.rawBody.takeIf { it.isNotBlank() }) + } + premiumizeCloudItemsFromFiles( + files = response.body?.files.orEmpty(), + providerId = provider.id, + providerName = provider.displayName, + ) + } + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + file.playbackUrl?.takeIf { it.isNotBlank() }?.let { url -> + return CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } + + val fileId = file.id?.takeIf { it.isNotBlank() } ?: return CloudLibraryPlaybackResult.Failed() + return try { + val response = PremiumizeApiClient.itemDetails(apiKey = apiKey, itemId = fileId) + if (!response.isSuccessful || response.body?.status.equals("error", ignoreCase = true)) { + return CloudLibraryPlaybackResult.Failed(response.body?.message ?: response.body?.code) + } + val url = response.body?.link?.takeIf { it.isNotBlank() } + ?: return CloudLibraryPlaybackResult.Failed() + CloudLibraryPlaybackResult.Success( + url = url, + filename = response.body.name?.takeIf { it.isNotBlank() } ?: file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = response.body.size ?: file.sizeBytes, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + CloudLibraryPlaybackResult.Failed(error.message) + } + } +} + +internal fun premiumizeCloudItemsFromFiles( + files: List, + providerId: String, + providerName: String, +): List { + val mappedFiles = files.mapNotNull { it.toPremiumizeCloudFile() } + val groups = mappedFiles.groupBy { file -> + file.groupKey + } + return groups.values + .mapNotNull { group -> + val first = group.firstOrNull() ?: return@mapNotNull null + val cloudFiles = group + .map { it.file } + .sortedWith(compareBy { !it.playable }.thenBy { it.name.lowercase() }) + val size = cloudFiles + .mapNotNull { it.sizeBytes } + .takeIf { it.isNotEmpty() } + ?.sum() + CloudLibraryItem( + providerId = providerId, + providerName = providerName, + id = first.itemId, + type = CloudLibraryItemType.File, + name = first.itemName, + status = "Ready", + sizeBytes = size, + files = cloudFiles, + ) + } + .sortedBy { it.name.lowercase() } +} + +private data class PremiumizeMappedCloudFile( + val groupKey: String, + val itemId: String, + val itemName: String, + val file: CloudLibraryFile, +) + +private fun PremiumizeCloudFileDto.toPremiumizeCloudFile(): PremiumizeMappedCloudFile? { + val normalizedPath = path?.trim()?.trim('/')?.takeIf { it.isNotBlank() } + val fileName = name?.trim()?.takeIf { it.isNotBlank() } + ?: normalizedPath?.pathBasename()?.takeIf { it.isNotBlank() } + ?: return null + val fileId = id?.trim()?.takeIf { it.isNotBlank() } + val playable = isPlayablePremiumizeCloudFile(name = fileName, mimeType = mimeType) + val segments = normalizedPath + ?.split('/') + ?.map { it.trim() } + ?.filter { it.isNotBlank() } + .orEmpty() + val topLevel = segments.firstOrNull() + val isRootFile = segments.size <= 1 + val itemName = if (isRootFile) fileName else topLevel ?: fileName + val itemId = if (isRootFile) { + "file:${fileId ?: normalizedPath ?: fileName}" + } else { + "folder:${topLevel ?: itemName}" + } + val groupKey = if (isRootFile) itemId else "folder:${topLevel ?: itemName}" + return PremiumizeMappedCloudFile( + groupKey = groupKey, + itemId = itemId, + itemName = itemName, + file = CloudLibraryFile( + id = fileId, + name = fileName, + sizeBytes = size, + mimeType = mimeType, + playable = playable, + playbackUrl = link?.takeIf { playable && it.isNotBlank() }, + ), + ) +} + +private fun String.pathBasename(): String = + substringAfterLast('/').substringAfterLast('\\') + +private fun isPlayablePremiumizeCloudFile(name: String, mimeType: String?): Boolean { + val normalizedMime = mimeType?.lowercase().orEmpty() + if (normalizedMime.startsWith("video/")) return true + val extension = name.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension in premiumizePlayableVideoExtensions +} + +private val premiumizePlayableVideoExtensions = setOf( + "3g2", + "3gp", + "avi", + "divx", + "flv", + "m2ts", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "mts", + "ogm", + "ogv", + "ts", + "webm", + "wmv", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt new file mode 100644 index 00000000..ab098ffd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -0,0 +1,224 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.TorboxApiClient +import com.nuvio.app.features.debrid.TorboxCloudFileDto +import com.nuvio.app.features.debrid.TorboxCloudItemDto +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonPrimitive + +internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { + override val provider = DebridProviders.Torbox + + override suspend fun listItems(apiKey: String): Result> = + runCatching { + val torrents = TorboxApiClient.listCloudTorrents(apiKey).itemsOrThrow(CloudLibraryItemType.Torrent) + val usenet = TorboxApiClient.listCloudUsenet(apiKey).itemsOrThrow(CloudLibraryItemType.Usenet) + val web = TorboxApiClient.listCloudWebDownloads(apiKey).itemsOrThrow(CloudLibraryItemType.WebDownload) + torrents + usenet + web + } + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + if (!file.playable) return CloudLibraryPlaybackResult.NotPlayable + + return try { + val response = when (item.type) { + CloudLibraryItemType.Torrent -> TorboxApiClient.requestCloudTorrentDownloadLink( + apiKey = apiKey, + torrentId = item.id, + fileId = file.id, + ) + CloudLibraryItemType.Usenet -> TorboxApiClient.requestCloudUsenetDownloadLink( + apiKey = apiKey, + usenetId = item.id, + fileId = file.id, + ) + CloudLibraryItemType.WebDownload -> TorboxApiClient.requestCloudWebDownloadLink( + apiKey = apiKey, + webId = item.id, + fileId = file.id, + ) + CloudLibraryItemType.File -> return CloudLibraryPlaybackResult.Failed() + } + if (!response.isSuccessful || response.body?.success == false) { + return CloudLibraryPlaybackResult.Failed(response.body?.detail ?: response.body?.error) + } + val url = response.body?.data?.takeIf { it.isNotBlank() } + ?: return CloudLibraryPlaybackResult.Failed() + CloudLibraryPlaybackResult.Success( + url = url, + filename = file.name.takeIf { it.isNotBlank() }, + videoSizeBytes = file.sizeBytes, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + CloudLibraryPlaybackResult.Failed(error.message) + } + } + + private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow( + type: CloudLibraryItemType, + ): List { + if (!isSuccessful || body?.success == false) { + throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + } + return body?.data.orEmpty().mapNotNull { dto -> + dto.toCloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + type = type, + ) + } + } +} + +internal fun TorboxCloudItemDto.toCloudLibraryItem( + providerId: String, + providerName: String, + type: CloudLibraryItemType, +): CloudLibraryItem? { + val itemId = id.scalarString() + ?: hash?.trim()?.takeIf { it.isNotBlank() } + ?: return null + val itemName = name?.trim()?.takeIf { it.isNotBlank() } ?: itemId + val mappedFiles = files.orEmpty().mapNotNull { file -> + file.toCloudLibraryFile(parentName = itemName) + } + val filesSize = mappedFiles + .mapNotNull { it.sizeBytes } + .takeIf { it.isNotEmpty() } + ?.sum() + return CloudLibraryItem( + providerId = providerId, + providerName = providerName, + id = itemId, + type = type, + name = itemName, + status = listOf(status, downloadState, state) + .firstNonBlank(), + sizeBytes = size ?: totalSize ?: filesSize, + progressFraction = listOfNotNull(progress, downloadProgress).firstOrNull()?.toProgressFraction(), + files = mappedFiles, + ) +} + +internal fun TorboxCloudFileDto.toCloudLibraryFile(parentName: String? = null): CloudLibraryFile? { + val name = bestCloudFileName(parentName = parentName) + ?: return null + val fileId = id.scalarString() + val mime = listOf(mimeType, mimeTypeAlt).firstNonBlank() + return CloudLibraryFile( + id = fileId, + name = name, + sizeBytes = size, + mimeType = mime, + playable = fileId != null && isPlayableCloudFile(name = name, mimeType = mime), + ) +} + +private fun TorboxCloudFileDto.bestCloudFileName(parentName: String?): String? { + val rawName = name?.trim()?.takeIf { it.isNotBlank() } + val short = shortName?.trim()?.takeIf { it.isNotBlank() } + val pathName = absolutePath + ?.trim() + ?.pathBasename() + ?.takeIf { it.isNotBlank() } + val parent = parentName?.trim()?.takeIf { it.isNotBlank() } + val rawNameIsPath = rawName?.isPathLike() == true + val rawNameBasename = rawName + ?.takeIf { rawNameIsPath } + ?.pathBasename() + ?.takeIf { it.isNotBlank() } + val candidates = listOf( + short, + rawNameBasename, + rawName?.takeUnless { rawNameIsPath }, + pathName, + rawName, + absolutePath?.trim()?.takeIf { it.isNotBlank() }, + ) + return candidates.firstOrNull { candidate -> + candidate?.isUsableCloudFileName(parentName = parent, pathName = pathName) == true + } ?: candidates.firstNonBlank() +} + +internal fun torboxRequestIdParameterName(type: CloudLibraryItemType): String = + when (type) { + CloudLibraryItemType.Torrent -> "torrent_id" + CloudLibraryItemType.Usenet -> "usenet_id" + CloudLibraryItemType.WebDownload -> "web_id" + CloudLibraryItemType.File -> "file_id" + } + +private fun List.firstNonBlank(): String? = + firstOrNull { !it.isNullOrBlank() }?.trim() + +private fun String.sameDisplayName(other: String?): Boolean { + val normalized = normalizeDisplayName() + return normalized.isNotBlank() && normalized == other?.normalizeDisplayName() +} + +private fun String.isUsableCloudFileName(parentName: String?, pathName: String?): Boolean { + if (isBlank() || sameDisplayName(parentName)) return false + val pathNameWithoutExtension = pathName?.substringBeforeLast('.', pathName) + if (!contains('.') && sameDisplayName(pathNameWithoutExtension)) return false + return true +} + +private fun String.isPathLike(): Boolean = + contains('/') || contains('\\') + +private fun String.pathBasename(): String = + substringAfterLast('/').substringAfterLast('\\') + +private fun String.normalizeDisplayName(): String = + trim() + .substringAfterLast('/') + .substringAfterLast('\\') + .substringBeforeLast('.', this) + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + +private fun kotlinx.serialization.json.JsonElement?.scalarString(): String? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.content.trim().takeIf { it.isNotBlank() } +} + +private fun Double.toProgressFraction(): Float { + val normalized = if (this > 1.0) this / 100.0 else this + return normalized.toFloat().coerceIn(0f, 1f) +} + +private fun isPlayableCloudFile(name: String, mimeType: String?): Boolean { + val normalizedMime = mimeType?.lowercase().orEmpty() + if (normalizedMime.startsWith("video/")) return true + val extension = name.substringAfterLast('.', missingDelimiterValue = "") + .lowercase() + return extension in playableVideoExtensions +} + +private val playableVideoExtensions = setOf( + "3g2", + "3gp", + "avi", + "divx", + "flv", + "m2ts", + "m4v", + "mkv", + "mov", + "mp4", + "mpeg", + "mpg", + "mts", + "ogm", + "ogv", + "ts", + "webm", + "wmv", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt new file mode 100644 index 00000000..f27870be --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -0,0 +1,566 @@ +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.encodeToString +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 startDeviceAuthorization( + appName: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "GET", + url = "$BASE_URL/v1/api/user/auth/device/start?${ + queryString("app" to appName) + }", + ) + + suspend fun redeemDeviceAuthorization( + deviceCode: String, + ): DebridApiResponse> = + requestWithoutAuth( + method = "POST", + url = "$BASE_URL/v1/api/user/auth/device/token", + body = DebridApiJson.json.encodeToString(TorboxDeviceTokenRequestDto(deviceCode = deviceCode)), + contentType = "application/json", + ) + + 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 checkCached( + apiKey: String, + hashes: List, + ): DebridApiResponse>> { + val normalizedHashes = hashes + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .distinct() + if (normalizedHashes.isEmpty()) { + return DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto(success = true, data = emptyMap()), + rawBody = "", + ) + } + val body = DebridApiJson.json.encodeToString( + TorboxCheckCachedRequestDto(hashes = normalizedHashes), + ) + return request( + method = "POST", + url = "$BASE_URL/v1/api/torrents/checkcached?format=object", + apiKey = apiKey, + body = body, + contentType = "application/json", + ) + } + + 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 listCloudTorrents(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/usenet/mylist", + apiKey = apiKey, + ) + + suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = + request( + method = "GET", + url = "$BASE_URL/v1/api/webdl/mylist", + 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, + ) + + suspend fun requestCloudTorrentDownloadLink( + apiKey: String, + torrentId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/torrents/requestdl?${ + queryString( + "token" to apiKey, + "torrent_id" to torrentId, + "file_id" to fileId, + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestCloudUsenetDownloadLink( + apiKey: String, + usenetId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/usenet/requestdl?${ + queryString( + "token" to apiKey, + "usenet_id" to usenetId, + "file_id" to fileId, + "zip_link" to "false", + "redirect" to "false", + "append_name" to "false", + ) + }", + apiKey = apiKey, + ) + + suspend fun requestCloudWebDownloadLink( + apiKey: String, + webId: String, + fileId: String?, + ): DebridApiResponse> = + request( + method = "GET", + url = "$BASE_URL/v1/api/webdl/requestdl?${ + queryString( + "token" to apiKey, + "web_id" to webId, + "file_id" to fileId, + "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 suspend inline fun requestWithoutAuth( + method: String, + url: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ).toMap() + 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") +} + +internal object PremiumizeApiClient { + private const val BASE_URL = "https://www.premiumize.me" + + suspend fun validateApiKey(apiKey: String): Boolean { + val response = accountInfo(apiKey.trim()) + return response.isSuccessful && response.body?.isSuccess == true + } + + suspend fun startDeviceAuthorization( + clientId: String, + ): DebridApiResponse = + formRequestWithoutAuth( + method = "POST", + url = "$BASE_URL/token", + fields = listOf( + "response_type" to "device_code", + "client_id" to clientId, + ), + ) + + suspend fun redeemDeviceAuthorization( + clientId: String, + deviceCode: String, + ): DebridApiResponse = + formRequestWithoutAuth( + method = "POST", + url = "$BASE_URL/token", + fields = listOf( + "grant_type" to "device_code", + "code" to deviceCode, + "client_id" to clientId, + ), + ) + + suspend fun accountInfo(apiKey: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/account/info", + apiKey = apiKey, + ) + + suspend fun listAllItems(apiKey: String): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/item/listall", + apiKey = apiKey, + ) + + suspend fun itemDetails( + apiKey: String, + itemId: String, + ): DebridApiResponse = + request( + method = "GET", + url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}", + apiKey = apiKey, + ) + + suspend fun directDownload( + apiKey: String, + source: String, + ): DebridApiResponse = + formRequest( + method = "POST", + url = "$BASE_URL/api/transfer/directdl", + apiKey = apiKey, + fields = listOf("src" to source), + ) + + suspend fun checkCache( + apiKey: String, + items: List, + ): DebridApiResponse { + val normalizedItems = items.map { it.trim() }.filter { it.isNotBlank() } + if (normalizedItems.isEmpty()) { + return DebridApiResponse( + status = 200, + body = PremiumizeCacheCheckDto(status = "success", response = emptyList()), + rawBody = "", + ) + } + return formRequest( + method = "POST", + url = "$BASE_URL/api/cache/check", + apiKey = apiKey, + fields = normalizedItems.map { "items[]" to it }, + ) + } + + private suspend inline fun formRequestWithoutAuth( + method: String, + url: String, + fields: List>, + ): DebridApiResponse = + requestWithoutAuth( + method = method, + url = url, + body = formBody(fields), + contentType = "application/x-www-form-urlencoded", + ) + + private suspend inline fun formRequest( + method: String, + url: String, + apiKey: String, + fields: List>, + ): DebridApiResponse = + request( + method = method, + url = url, + apiKey = apiKey, + body = formBody(fields), + contentType = "application/x-www-form-urlencoded", + ) + + private suspend inline fun requestWithoutAuth( + method: String, + url: String, + body: String = "", + contentType: String? = null, + ): DebridApiResponse { + val headers = listOfNotNull( + contentType?.let { "Content-Type" to it }, + "Accept" to "application/json", + ).toMap() + val response = httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + return DebridApiResponse( + status = response.status, + body = response.decodeBody(), + rawBody = response.body, + ) + } + + 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 formBody(fields: List>): String = + fields.joinToString("&") { (key, value) -> + "${encodeFormValue(key)}=${encodeFormValue(value)}" + } + + private fun authHeaders(apiKey: String): Map = + mapOf("Authorization" to "Bearer $apiKey") + + private val PremiumizeAccountInfoDto.isSuccess: Boolean + get() = status.equals("success", ignoreCase = true) +} + +object DebridCredentialValidator { + suspend fun validateProvider(providerId: String, apiKey: String): Boolean { + val normalized = apiKey.trim() + if (normalized.isBlank()) return false + return DebridProviderApis.apiFor(providerId)?.validateApiKey(normalized) == true + } +} + +private inline fun RawHttpResponse.decodeBody(): T? { + if (body.isBlank() || T::class == Unit::class) return null + return try { + DebridApiJson.json.decodeFromString(body) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } +} + +private fun multipartFormBody(boundary: String, vararg fields: Pair): String = + buildString { + fields.forEach { (name, value) -> + append("--").append(boundary).append("\r\n") + append("Content-Disposition: form-data; name=\"").append(name).append("\"\r\n\r\n") + append(value).append("\r\n") + } + append("--").append(boundary).append("--\r\n") + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt new file mode 100644 index 00000000..4c484212 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiModels.kt @@ -0,0 +1,252 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +@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 TorboxCloudItemDto( + val id: JsonElement? = null, + val hash: String? = null, + val name: String? = null, + val status: String? = null, + val state: String? = null, + @SerialName("download_state") val downloadState: String? = null, + val progress: Double? = null, + @SerialName("download_progress") val downloadProgress: Double? = null, + val size: Long? = null, + @SerialName("total_size") val totalSize: Long? = null, + val files: List? = null, +) + +@Serializable +internal data class TorboxCloudFileDto( + val id: JsonElement? = 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, + @SerialName("mime_type") val mimeTypeAlt: String? = null, + val size: Long? = null, +) + +@Serializable +internal data class TorboxCheckCachedRequestDto( + val hashes: List, +) + +@Serializable +internal data class TorboxDeviceAuthorizationDto( + @SerialName("device_code") val deviceCode: String? = null, + val code: String? = null, + @SerialName("verification_url") val verificationUrl: String? = null, + @SerialName("friendly_verification_url") val friendlyVerificationUrl: String? = null, + val interval: Int? = null, + @SerialName("expires_at") val expiresAt: String? = null, +) + +@Serializable +internal data class TorboxDeviceTokenRequestDto( + @SerialName("device_code") val deviceCode: String, +) + +@Serializable +internal data class TorboxDeviceTokenDto( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, +) + +@Serializable +internal data class TorboxCachedItemDto( + val name: String? = null, + val size: Long? = null, + val hash: String? = null, +) + +@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, +) + +@Serializable +internal data class PremiumizeDeviceAuthorizationDto( + @SerialName("device_code") val deviceCode: String? = null, + @SerialName("user_code") val userCode: String? = null, + @SerialName("verification_uri") val verificationUri: String? = null, + @SerialName("verification_uri_complete") val verificationUriComplete: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, + val interval: Int? = null, + val error: String? = null, + @SerialName("error_description") val errorDescription: String? = null, +) + +@Serializable +internal data class PremiumizeDeviceTokenDto( + @SerialName("access_token") val accessToken: String? = null, + @SerialName("token_type") val tokenType: String? = null, + @SerialName("expires_in") val expiresIn: Int? = null, + val scope: String? = null, + val error: String? = null, + @SerialName("error_description") val errorDescription: String? = null, +) + +@Serializable +internal data class PremiumizeApiEnvelopeDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, +) + +@Serializable +internal data class PremiumizeAccountInfoDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + @SerialName("customer_id") val customerId: String? = null, + @SerialName("premium_until") val premiumUntil: Long? = null, + @SerialName("limit_used") val limitUsed: Double? = null, + @SerialName("booster_points") val boosterPoints: Int? = null, +) + +@Serializable +internal data class PremiumizeDirectDownloadDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val content: List? = null, +) + +@Serializable +internal data class PremiumizeDirectDownloadFileDto( + val path: String? = null, + val size: Long? = null, + val link: String? = null, +) + +@Serializable +internal data class PremiumizeCacheCheckDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val response: List? = null, + val filename: List? = null, + val filesize: List? = null, +) + +@Serializable +internal data class PremiumizeItemListAllDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val files: List? = null, +) + +@Serializable +internal data class PremiumizeCloudFileDto( + val id: String? = null, + val name: String? = null, + val path: String? = null, + val type: String? = null, + val size: Long? = null, + @SerialName("created_at") val createdAt: Long? = null, + @SerialName("mime_type") val mimeType: String? = null, + val link: String? = null, +) + +@Serializable +internal data class PremiumizeItemDetailsDto( + val status: String? = null, + val message: String? = null, + val code: String? = null, + val id: String? = null, + val name: String? = null, + val size: Long? = null, + @SerialName("created_at") val createdAt: Long? = null, + @SerialName("folder_id") val folderId: String? = null, + @SerialName("mime_type") val mimeType: String? = null, + val link: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt new file mode 100644 index 00000000..d70a7001 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridFileSelectors.kt @@ -0,0 +1,220 @@ +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() +} + +internal class PremiumizeDirectDownloadFileSelector { + fun selectFile( + files: List, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + ): PremiumizeDirectDownloadFileDto? { + 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 + } + } + } + + return playable.maxByOrNull { it.size ?: 0L } + } + + private fun PremiumizeDirectDownloadFileDto.isPlayableVideo(): Boolean = + !link.isNullOrBlank() && displayName().lowercase().hasVideoExtension() +} + +internal fun PremiumizeDirectDownloadFileDto.displayName(): String = + path.orEmpty().substringAfterLast('/').substringAfterLast('\\').ifBlank { path.orEmpty() } + +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/DebridMagnetBuilder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt new file mode 100644 index 00000000..e45d32bb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem + +internal object DebridMagnetBuilder { + fun fromStream(stream: StreamItem): String? { + stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it } + val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + stream.behaviorHints.filename + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { filename -> + append("&dn=") + append(encodePathSegment(filename)) + } + stream.sources + .mapNotNull(::trackerUrl) + .distinct() + .forEach { tracker -> + append("&tr=") + append(encodePathSegment(tracker)) + } + } + } + + private fun trackerUrl(source: String): String? { + val value = source.trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value + .removePrefix("tracker:") + .trim() + .takeIf { it.isNotBlank() } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt new file mode 100644 index 00000000..62b6b0fa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProvider.kt @@ -0,0 +1,133 @@ +package com.nuvio.app.features.debrid + +data class DebridProvider( + val id: String, + val displayName: String, + val shortName: String, + val visibleInUi: Boolean = true, + val authMethod: DebridProviderAuthMethod = DebridProviderAuthMethod.ApiKey, + val capabilities: Set = emptySet(), +) + +data class DebridServiceCredential( + val provider: DebridProvider, + val apiKey: String, +) + +enum class DebridProviderCapability { + ClientResolve, + LocalTorrentCacheCheck, + LocalTorrentResolve, + CloudLibrary, +} + +enum class DebridProviderAuthMethod { + ApiKey, + DeviceCode, +} + +object DebridProviders { + const val TORBOX_ID = "torbox" + const val PREMIUMIZE_ID = "premiumize" + const val REAL_DEBRID_ID = "realdebrid" + + val Torbox = DebridProvider( + id = TORBOX_ID, + displayName = "Torbox", + shortName = "TB", + authMethod = DebridProviderAuthMethod.DeviceCode, + capabilities = setOf( + DebridProviderCapability.ClientResolve, + DebridProviderCapability.LocalTorrentCacheCheck, + DebridProviderCapability.LocalTorrentResolve, + DebridProviderCapability.CloudLibrary, + ), + ) + + val Premiumize = DebridProvider( + id = PREMIUMIZE_ID, + displayName = "Premiumize", + shortName = "PM", + authMethod = DebridProviderAuthMethod.DeviceCode, + capabilities = setOf( + DebridProviderCapability.ClientResolve, + DebridProviderCapability.LocalTorrentCacheCheck, + DebridProviderCapability.LocalTorrentResolve, + DebridProviderCapability.CloudLibrary, + ), + ) + + val RealDebrid = DebridProvider( + id = REAL_DEBRID_ID, + displayName = "Real-Debrid", + shortName = "RD", + visibleInUi = false, + capabilities = setOf(DebridProviderCapability.ClientResolve), + ) + + private val registered = listOf(Torbox, Premiumize, 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 = + registered.mapNotNull { provider -> + settings.apiKeyFor(provider.id) + .trim() + .takeIf { provider.visibleInUi && it.isNotBlank() } + ?.let { apiKey -> DebridServiceCredential(provider, apiKey) } + } + + fun configuredResolverServices(settings: DebridSettings): List = + configuredServices(settings).filter { credential -> + credential.provider.supports(DebridProviderCapability.ClientResolve) || + credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) + } + + fun preferredResolverService(settings: DebridSettings): DebridServiceCredential? { + val services = configuredResolverServices(settings) + if (services.isEmpty()) return null + val preferredId = byId(settings.preferredResolverProviderId)?.id + return services.firstOrNull { it.provider.id == preferredId } ?: services.firstOrNull() + } + + 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" } + } +} + +fun DebridProvider.supports(capability: DebridProviderCapability): Boolean = + capability in capabilities diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt new file mode 100644 index 00000000..295179d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -0,0 +1,422 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CancellationException + +internal interface DebridProviderApi { + val provider: DebridProvider + + suspend fun validateApiKey(apiKey: String): Boolean + + suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? = null + + suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult = + DebridDeviceAuthorizationTokenResult.Unsupported + + suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult +} + +internal object DebridProviderApis { + private val registered = listOf( + TorboxDebridProviderApi(), + PremiumizeDebridProviderApi(), + RealDebridProviderApi(), + ) + + fun apiFor(providerId: String?): DebridProviderApi? { + val normalized = DebridProviders.byId(providerId)?.id ?: return null + return registered.firstOrNull { it.provider.id == normalized } + } +} + +internal data class DebridDeviceAuthorization( + val providerId: String, + val deviceCode: String, + val userCode: String, + val verificationUrl: String, + val friendlyVerificationUrl: String, + val intervalSeconds: Int, + val expiresAt: String?, +) + +internal sealed interface DebridDeviceAuthorizationTokenResult { + data class Authorized(val accessToken: String) : DebridDeviceAuthorizationTokenResult + data object Pending : DebridDeviceAuthorizationTokenResult + data object Expired : DebridDeviceAuthorizationTokenResult + data object Unsupported : DebridDeviceAuthorizationTokenResult + data class Failed(val message: String?) : DebridDeviceAuthorizationTokenResult +} + +private class TorboxDebridProviderApi( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.Torbox + + override suspend fun validateApiKey(apiKey: String): Boolean = + TorboxApiClient.validateApiKey(apiKey) + + override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? { + val response = TorboxApiClient.startDeviceAuthorization(appName = appName) + val data = response.body?.takeIf { response.isSuccessful && it.success != false }?.data + ?: return null + val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null + val userCode = data.code?.takeIf { it.isNotBlank() } ?: return null + val verificationUrl = data.verificationUrl?.takeIf { it.isNotBlank() } ?: return null + return DebridDeviceAuthorization( + providerId = provider.id, + deviceCode = deviceCode, + userCode = userCode, + verificationUrl = verificationUrl, + friendlyVerificationUrl = data.friendlyVerificationUrl?.takeIf { it.isNotBlank() } + ?: verificationUrl, + intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5, + expiresAt = data.expiresAt?.takeIf { it.isNotBlank() }, + ) + } + + override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult { + val normalized = deviceCode.trim() + if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) + val response = TorboxApiClient.redeemDeviceAuthorization(deviceCode = normalized) + return torboxDeviceAuthorizationTokenResult(response) + } + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() }, + videoSize = file.size, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + +internal class PremiumizeDebridProviderApi( + private val fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), + private val clientIdProvider: () -> String = { PremiumizeConfig.CLIENT_ID }, +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.Premiumize + + override suspend fun validateApiKey(apiKey: String): Boolean = + PremiumizeApiClient.validateApiKey(apiKey) + + override suspend fun startDeviceAuthorization(appName: String): DebridDeviceAuthorization? { + val clientId = premiumizeClientIdOrThrow() + val response = PremiumizeApiClient.startDeviceAuthorization(clientId = clientId) + return premiumizeDeviceAuthorizationFromResponse(response, provider.id) + } + + override suspend fun redeemDeviceAuthorization(deviceCode: String): DebridDeviceAuthorizationTokenResult { + val clientId = premiumizeClientIdOrThrow() + val normalized = deviceCode.trim() + if (normalized.isBlank()) return DebridDeviceAuthorizationTokenResult.Failed(null) + val response = PremiumizeApiClient.redeemDeviceAuthorization( + clientId = clientId, + deviceCode = normalized, + ) + return premiumizeDeviceAuthorizationTokenResult(response) + } + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val source = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: stream.playableDirectUrl?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + return resolvePremiumizeDirectDownload( + apiKey = apiKey, + source = source, + resolve = resolve, + season = season, + episode = episode, + fallbackFilename = stream.behaviorHints.filename, + fallbackSize = stream.behaviorHints.videoSize, + fileSelector = fileSelector, + ) + } + + private fun premiumizeClientIdOrThrow(): String = + clientIdProvider().trim().takeIf { it.isNotBlank() } + ?: throw IllegalStateException("Premiumize sign-in is missing PREMIUMIZE_CLIENT_ID.") +} + +private class RealDebridProviderApi( + private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(), +) : DebridProviderApi { + override val provider: DebridProvider = DebridProviders.RealDebrid + + override suspend fun validateApiKey(apiKey: String): Boolean = + RealDebridApiClient.validateApiKey(apiKey) + + override suspend fun resolveClientStream( + stream: StreamItem, + apiKey: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error + val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() } + ?: buildMagnetUri(resolve) + ?: return DirectDebridResolveResult.Stale + + return try { + val add = RealDebridApiClient.addMagnet(apiKey, magnet) + val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() } + ?: return add.toFailureForAdd() + var resolved = false + try { + val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoBefore.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val filesBefore = infoBefore.body?.files.orEmpty() + val file = fileSelector.selectFile( + files = filesBefore, + resolve = resolve, + season = season, + episode = episode, + ) ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString()) + if (!select.isSuccessful && select.status != 202) { + return DirectDebridResolveResult.Stale + } + + val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId) + if (!infoAfter.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val link = infoAfter.body?.firstDownloadLink() + ?: return DirectDebridResolveResult.Stale + val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link) + if (!unrestrict.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = unrestrict.body?.download?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + resolved = true + DirectDebridResolveResult.Success( + url = url, + filename = unrestrict.body.filename?.takeIf { it.isNotBlank() } + ?: file.displayName().takeIf { it.isNotBlank() }, + videoSize = unrestrict.body.filesize ?: file.bytes, + ) + } finally { + if (!resolved) { + runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) } + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + +private fun buildMagnetUri(resolve: StreamClientResolve): String? { + val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null + return buildString { + append("magnet:?xt=urn:btih:") + append(hash) + resolve.sources + .mapNotNull { it.toTrackerUrlOrNull() } + .distinct() + .forEach { source -> + append("&tr=") + append(encodePathSegment(source)) + } + } +} + +internal fun premiumizeDeviceAuthorizationFromResponse( + response: DebridApiResponse, + providerId: String, +): DebridDeviceAuthorization? { + val data = response.body?.takeIf { response.isSuccessful } ?: return null + val deviceCode = data.deviceCode?.takeIf { it.isNotBlank() } ?: return null + val userCode = data.userCode?.takeIf { it.isNotBlank() } ?: return null + val verificationUrl = data.verificationUri?.takeIf { it.isNotBlank() } ?: return null + return DebridDeviceAuthorization( + providerId = providerId, + deviceCode = deviceCode, + userCode = userCode, + verificationUrl = verificationUrl, + friendlyVerificationUrl = data.verificationUriComplete?.takeIf { it.isNotBlank() } + ?: verificationUrl, + intervalSeconds = data.interval?.coerceAtLeast(1) ?: 5, + expiresAt = data.expiresIn?.takeIf { it > 0 }?.let { "${it}s" }, + ) +} + +internal fun torboxDeviceAuthorizationTokenResult( + response: DebridApiResponse>, +): DebridDeviceAuthorizationTokenResult { + val envelope = response.body + val accessToken = envelope + ?.takeIf { response.isSuccessful && it.success != false } + ?.data + ?.accessToken + ?.takeIf { it.isNotBlank() } + if (accessToken != null) { + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + val message = listOfNotNull(envelope?.error, envelope?.detail, response.rawBody) + .joinToString(" ") + .lowercase() + return when { + message.contains("pending") || + message.contains("not authorized") || + message.contains("not been used") || + message.contains("not used yet") || + message.contains("scan the code") -> + DebridDeviceAuthorizationTokenResult.Pending + message.contains("expired") -> + DebridDeviceAuthorizationTokenResult.Expired + response.status == 404 || response.status == 409 || response.status == 425 -> + DebridDeviceAuthorizationTokenResult.Pending + response.status == 410 -> + DebridDeviceAuthorizationTokenResult.Expired + else -> + DebridDeviceAuthorizationTokenResult.Failed(envelope?.detail ?: envelope?.error) + } +} + +internal fun premiumizeDeviceAuthorizationTokenResult( + response: DebridApiResponse, +): DebridDeviceAuthorizationTokenResult { + val body = response.body + body?.accessToken?.takeIf { response.isSuccessful && it.isNotBlank() }?.let { accessToken -> + return DebridDeviceAuthorizationTokenResult.Authorized(accessToken) + } + return when (body?.error?.lowercase()) { + "authorization_pending", "slow_down" -> DebridDeviceAuthorizationTokenResult.Pending + "invalid_grant", "expired_token" -> DebridDeviceAuthorizationTokenResult.Expired + "access_denied" -> DebridDeviceAuthorizationTokenResult.Failed(body.errorDescription) + else -> { + if (response.status == 400 && body?.error.isNullOrBlank()) { + DebridDeviceAuthorizationTokenResult.Pending + } else { + DebridDeviceAuthorizationTokenResult.Failed(body?.errorDescription ?: body?.error ?: response.rawBody) + } + } + } +} + +internal suspend fun resolvePremiumizeDirectDownload( + apiKey: String, + source: String, + resolve: StreamClientResolve, + season: Int?, + episode: Int?, + fallbackFilename: String? = null, + fallbackSize: Long? = null, + fileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), +): DirectDebridResolveResult { + val normalizedSource = source.trim().takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale + return try { + val response = PremiumizeApiClient.directDownload(apiKey = apiKey, source = normalizedSource) + if (!response.isSuccessful) { + return when (response.status) { + 401, 403 -> DirectDebridResolveResult.Error + else -> DirectDebridResolveResult.Stale + } + } + val body = response.body ?: return DirectDebridResolveResult.Stale + if (body.status.equals("error", ignoreCase = true)) { + val message = listOfNotNull(body.message, body.code).joinToString(" ").lowercase() + return if (message.contains("cache") || message.contains("not found")) { + DirectDebridResolveResult.NotCached + } else { + DirectDebridResolveResult.Stale + } + } + val file = fileSelector.selectFile( + files = body.content.orEmpty(), + resolve = resolve, + season = season, + episode = episode, + ) ?: return DirectDebridResolveResult.Stale + val url = file.link?.takeIf { it.isNotBlank() } ?: return DirectDebridResolveResult.Stale + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() } ?: fallbackFilename, + videoSize = file.size ?: fallbackSize, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } +} + +private fun String.toTrackerUrlOrNull(): String? { + val value = trim() + if (value.isBlank() || value.startsWith("dht:", ignoreCase = true)) return null + return value.removePrefix("tracker:").trim().takeIf { it.isNotBlank() } +} + +private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + 409 -> DirectDebridResolveResult.NotCached + else -> DirectDebridResolveResult.Stale + } + +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() } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt new file mode 100644 index 00000000..8774316f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -0,0 +1,304 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.Serializable + +data class DebridSettings( + val enabled: Boolean = false, + val cloudLibraryEnabled: Boolean = true, + val providerApiKeys: Map = emptyMap(), + val preferredResolverProviderId: 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 torboxApiKey: String + get() = apiKeyFor(DebridProviders.TORBOX_ID) + + val realDebridApiKey: String + get() = apiKeyFor(DebridProviders.REAL_DEBRID_ID) + + val premiumizeApiKey: String + get() = apiKeyFor(DebridProviders.PREMIUMIZE_ID) + + val hasAnyApiKey: Boolean + get() = DebridProviders.configuredServices(this).isNotEmpty() + + val resolverServices: List + get() = DebridProviders.configuredResolverServices(this) + + val activeResolverCredential: DebridServiceCredential? + get() = DebridProviders.preferredResolverService(this) + + val activeResolverProviderId: String? + get() = activeResolverCredential?.provider?.id + + val hasResolverProvider: Boolean + get() = activeResolverCredential != null + + val linkResolvingEnabled: Boolean + get() = enabled + + val canResolvePlayableLinks: Boolean + get() = linkResolvingEnabled && hasResolverProvider + + val hasCloudLibraryProvider: Boolean + get() = DebridProviders.configuredServices(this) + .any { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) } + + val canUseCloudLibrary: Boolean + get() = cloudLibraryEnabled && hasCloudLibraryProvider + + val hasCustomStreamFormatting: Boolean + get() = DebridStreamFormatterDefaults.NAME_TEMPLATE.isNotBlank() || + DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE.isNotBlank() || + streamNameTemplate.isNotBlank() || + streamDescriptionTemplate.isNotBlank() + + fun apiKeyFor(providerId: String?): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId?.trim()?.lowercase() + ?: return "" + return providerApiKeys[normalized].orEmpty() + } +} + +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 + +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = + value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt new file mode 100644 index 00000000..a23c52f6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -0,0 +1,512 @@ +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 cloudLibraryEnabled = true + private var providerApiKeys = emptyMap() + private var preferredResolverProviderId = "" + 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 && !hasResolverProvider()) return + if (enabled == value) return + enabled = value + publish() + DebridSettingsStorage.saveEnabled(value) + } + + fun setLinkResolvingEnabled(value: Boolean) { + setEnabled(value) + } + + fun setCloudLibraryEnabled(value: Boolean) { + ensureLoaded() + if (value && !hasCloudLibraryProvider()) return + if (cloudLibraryEnabled == value) return + cloudLibraryEnabled = value + publish() + DebridSettingsStorage.saveCloudLibraryEnabled(value) + } + + fun setProviderApiKey(providerId: String, value: String) { + ensureLoaded() + val provider = DebridProviders.byId(providerId) ?: return + val normalized = value.trim() + if (providerApiKeys[provider.id].orEmpty() == normalized) return + providerApiKeys = if (normalized.isBlank()) { + providerApiKeys - provider.id + } else { + providerApiKeys + (provider.id to normalized) + } + normalizePreferredResolverProviderId(save = true) + disableIfNoResolver() + publish() + DebridSettingsStorage.saveProviderApiKey(provider.id, normalized) + } + + fun setTorboxApiKey(value: String) { + setProviderApiKey(DebridProviders.TORBOX_ID, value) + } + + fun setRealDebridApiKey(value: String) { + setProviderApiKey(DebridProviders.REAL_DEBRID_ID, value) + } + + fun setPremiumizeApiKey(value: String) { + setProviderApiKey(DebridProviders.PREMIUMIZE_ID, value) + } + + fun setPreferredResolverProviderId(providerId: String) { + ensureLoaded() + val normalized = DebridProviders.byId(providerId)?.id.orEmpty() + val next = connectedResolverProviderIds() + .firstOrNull { it == normalized } + ?: connectedResolverProviderIds().firstOrNull().orEmpty() + if (preferredResolverProviderId == next) return + preferredResolverProviderId = next + publish() + DebridSettingsStorage.savePreferredResolverProviderId(next) + } + + 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 = normalizeStreamTemplate(value, DebridTemplateKind.NAME) + if (streamNameTemplate == normalized) return + streamNameTemplate = normalized + publish() + DebridSettingsStorage.saveStreamNameTemplate(normalized) + } + + fun setStreamDescriptionTemplate(value: String) { + ensureLoaded() + val normalized = normalizeStreamTemplate(value, DebridTemplateKind.DESCRIPTION) + if (streamDescriptionTemplate == normalized) return + streamDescriptionTemplate = normalized + publish() + DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) + } + + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { + ensureLoaded() + streamNameTemplate = normalizeStreamTemplate(nameTemplate, DebridTemplateKind.NAME) + streamDescriptionTemplate = normalizeStreamTemplate(descriptionTemplate, DebridTemplateKind.DESCRIPTION) + publish() + DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate) + DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate) + } + + fun resetStreamTemplates() { + setStreamTemplates( + nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE, + descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ) + } + + private fun disableIfNoResolver() { + if (!hasResolverProvider()) { + enabled = false + DebridSettingsStorage.saveEnabled(false) + } + } + + private fun hasCloudLibraryProvider(): Boolean = + DebridProviders.visible().any { provider -> + provider.supports(DebridProviderCapability.CloudLibrary) && + providerApiKeys[provider.id].orEmpty().isNotBlank() + } + + private fun hasResolverProvider(): Boolean = connectedResolverProviderIds().isNotEmpty() + + private fun connectedResolverProviderIds(): List = + DebridProviders.visible().filter { provider -> + ( + provider.supports(DebridProviderCapability.ClientResolve) || + provider.supports(DebridProviderCapability.LocalTorrentResolve) + ) && + providerApiKeys[provider.id].orEmpty().isNotBlank() + }.map { it.id } + + private fun normalizePreferredResolverProviderId(save: Boolean = false) { + val providerId = DebridProviders.byId(preferredResolverProviderId)?.id.orEmpty() + val connectedResolverIds = connectedResolverProviderIds() + val normalized = if (providerId in connectedResolverIds) { + providerId + } else { + connectedResolverIds.firstOrNull().orEmpty() + } + if (preferredResolverProviderId != normalized) { + preferredResolverProviderId = normalized + if (save) { + DebridSettingsStorage.savePreferredResolverProviderId(normalized) + } + } + } + + private fun loadFromDisk() { + hasLoaded = true + providerApiKeys = DebridProviders.all() + .mapNotNull { provider -> + DebridSettingsStorage.loadProviderApiKey(provider.id) + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.let { apiKey -> provider.id to apiKey } + } + .toMap() + preferredResolverProviderId = DebridSettingsStorage.loadPreferredResolverProviderId() + ?.let(DebridProviders::byId) + ?.id + .orEmpty() + normalizePreferredResolverProviderId(save = true) + enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasResolverProvider() + cloudLibraryEnabled = DebridSettingsStorage.loadCloudLibraryEnabled() ?: true + 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 = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamNameTemplate().orEmpty(), + DebridTemplateKind.NAME, + ) + streamDescriptionTemplate = normalizeStreamTemplate( + DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(), + DebridTemplateKind.DESCRIPTION, + ) + publish() + } + + private fun publish() { + _uiState.value = DebridSettings( + enabled = enabled, + cloudLibraryEnabled = cloudLibraryEnabled, + providerApiKeys = providerApiKeys, + preferredResolverProviderId = preferredResolverProviderId, + 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 + } + } + + private enum class DebridTemplateKind { + NAME, + DESCRIPTION, + } + + private fun normalizeStreamTemplate(value: String, kind: DebridTemplateKind): String { + val trimmed = value.trim() + return when { + trimmed.isBlank() -> "" + kind == DebridTemplateKind.NAME && trimmed == DebridStreamFormatterDefaults.LEGACY_NAME_TEMPLATE -> "" + kind == DebridTemplateKind.DESCRIPTION && trimmed == DebridStreamFormatterDefaults.LEGACY_DESCRIPTION_TEMPLATE -> "" + else -> value + } + } +} + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt new file mode 100644 index 00000000..7d68db48 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -0,0 +1,40 @@ +package com.nuvio.app.features.debrid + +import kotlinx.serialization.json.JsonObject + +internal expect object DebridSettingsStorage { + fun loadEnabled(): Boolean? + fun saveEnabled(enabled: Boolean) + fun loadCloudLibraryEnabled(): Boolean? + fun saveCloudLibraryEnabled(enabled: Boolean) + fun loadPreferredResolverProviderId(): String? + fun savePreferredResolverProviderId(providerId: String) + fun loadProviderApiKey(providerId: String): String? + fun saveProviderApiKey(providerId: String, apiKey: String) + fun loadTorboxApiKey(): String? + fun saveTorboxApiKey(apiKey: String) + fun loadRealDebridApiKey(): String? + fun saveRealDebridApiKey(apiKey: String) + fun loadInstantPlaybackPreparationLimit(): Int? + fun saveInstantPlaybackPreparationLimit(limit: Int) + fun loadStreamMaxResults(): Int? + fun saveStreamMaxResults(maxResults: Int) + fun loadStreamSortMode(): String? + fun saveStreamSortMode(mode: String) + fun loadStreamMinimumQuality(): String? + fun saveStreamMinimumQuality(quality: String) + fun loadStreamDolbyVisionFilter(): String? + fun saveStreamDolbyVisionFilter(filter: String) + fun loadStreamHdrFilter(): String? + fun saveStreamHdrFilter(filter: String) + fun loadStreamCodecFilter(): String? + fun saveStreamCodecFilter(filter: String) + fun loadStreamPreferences(): String? + fun saveStreamPreferences(preferences: String) + fun loadStreamNameTemplate(): String? + fun saveStreamNameTemplate(template: String) + fun loadStreamDescriptionTemplate(): String? + fun saveStreamDescriptionTemplate(template: String) + fun exportToSyncPayload(): JsonObject + fun replaceFromSyncPayload(payload: JsonObject) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt new file mode 100644 index 00000000..a148c9de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -0,0 +1,171 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStream +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamClientResolveParsed +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamItem + +class DebridStreamFormatter( + private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), +) { + fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + if (!stream.isManagedDebridStream) return stream + val values = buildValues(stream, settings) + val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } + val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } + val formattedName = engine.render(nameTemplate, values) + .lineSequence() + .joinToString(" ") { it.trim() } + .replace(Regex("\\s+"), " ") + .trim() + val formattedDescription = engine.render(descriptionTemplate, values) + .lineSequence() + .map { it.trim() } + .filter { it.isNotBlank() } + .joinToString("\n") + .trim() + + return stream.copy( + name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, + description = formattedDescription.ifBlank { stream.description ?: stream.title }, + ) + } + + private fun buildValues(stream: StreamItem, settings: DebridSettings): Map { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val facts = DebridStreamMetadata.facts( + stream = stream, + preferences = DebridStreamMetadata.effectivePreferences(settings), + ) + val seasons = parsed?.seasons.orEmpty() + val episodes = parsed?.episodes.orEmpty() + val season = resolve?.season ?: seasons.singleOrFirstOrNull() + val episode = resolve?.episode ?: episodes.singleOrFirstOrNull() + val visualTags = facts.visualTags.mapNotUnknown { it.label } + val audioTags = facts.audioTags.mapNotUnknown { it.label } + val audioChannels = facts.audioChannels.mapNotUnknown { it.label } + 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 facts.resolution.labelUnlessUnknown(), + "stream.library" to false, + "stream.quality" to facts.quality.labelUnlessUnknown(), + "stream.visualTags" to visualTags, + "stream.audioTags" to audioTags, + "stream.audioChannels" to audioChannels, + "stream.languages" to languageValues(parsed, facts), + "stream.languageEmojis" to languageValues(parsed, facts).map { languageEmoji(it) }, + "stream.size" to facts.size?.let(::DebridTemplateBytes), + "stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes), + "stream.encode" to facts.encode.labelUnlessUnknown(), + "stream.indexer" to (raw?.indexer ?: raw?.tracker ?: stream.sourceName), + "stream.network" to (parsed?.network ?: raw?.network), + "stream.releaseGroup" to facts.releaseGroup.takeIf { it.isNotBlank() }, + "stream.duration" to parsed?.duration, + "stream.edition" to edition, + "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName), + "stream.regexMatched" to null, + "stream.type" to streamType(stream, resolve), + "service.cached" to serviceCached(stream, resolve), + "service.shortName" to DebridProviders.shortName(serviceId(stream)), + "service.name" to DebridProviders.displayName(serviceId(stream)), + "addon.name" to stream.addonName, + ) + } + + private fun serviceId(stream: StreamItem): String? = + stream.debridCacheStatus?.providerId ?: stream.clientResolve?.service + + private fun serviceCached(stream: StreamItem, resolve: StreamClientResolve?): Boolean? = + when (stream.debridCacheStatus?.state) { + StreamDebridCacheState.CACHED -> true + StreamDebridCacheState.NOT_CACHED -> false + StreamDebridCacheState.CHECKING, + StreamDebridCacheState.UNKNOWN, + null -> resolve?.isCached + } + + private fun streamType(stream: StreamItem, resolve: StreamClientResolve?): String = + when { + stream.debridCacheStatus != null -> "Debrid" + resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid" + resolve?.type.equals("torrent", ignoreCase = true) -> "p2p" + else -> resolve?.type.orEmpty() + } + + 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 languageValues(parsed: StreamClientResolveParsed?, facts: DebridStreamFacts): List = + parsed?.languages.orEmpty().ifEmpty { facts.languages.map { it.code } } + + 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 + } + + private inline fun List.mapNotUnknown(label: (T) -> String): List = + map(label).filterNot { it.equals("Unknown", ignoreCase = true) } + + private fun DebridStreamResolution.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamResolution.UNKNOWN } + + private fun DebridStreamQuality.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamQuality.UNKNOWN } + + private fun DebridStreamEncode.labelUnlessUnknown(): String? = + label.takeUnless { this == DebridStreamEncode.UNKNOWN } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt new file mode 100644 index 00000000..0129ae2e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatterDefaults.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.features.debrid + +object DebridStreamFormatterDefaults { + const val NAME_TEMPLATE = "{stream.resolution::exists[\"{stream.resolution} \"||\"\"]}{service.shortName::exists[\"{service.shortName}\"||\"Cloud\"]} Instant" + + const val DESCRIPTION_TEMPLATE = "" + + const val LEGACY_NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}{service.cached::istrue[\"Ready\"||\"Not Ready\"]}" + + const val LEGACY_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/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt new file mode 100644 index 00000000..f9eedc46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -0,0 +1,447 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamItem + +object DebridStreamPresentation { + private val formatter = DebridStreamFormatter() + + fun apply(groups: List, settings: DebridSettings): List { + if (!settings.canResolvePlayableLinks) return groups + return groups.map { group -> + val visibleStreams = group.streams + .filterNot { stream -> stream.isInactiveResolverStream(settings) } + .filterNot { stream -> stream.isUncachedDebridStream } + val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } + if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) + + val presentedDebridStreams = applyPreferences(debridStreams, settings) + .map { stream -> + if (settings.hasCustomStreamFormatting) { + formatter.format(stream, settings) + } else { + stream + } + } + val passthroughStreams = visibleStreams.filterNot { stream -> stream.isManagedDebridStream } + + group.copy(streams = presentedDebridStreams + passthroughStreams) + } + } + + internal fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = DebridStreamMetadata.effectivePreferences(settings) + return streams.map { it to DebridStreamMetadata.facts(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 } + } + + internal val StreamItem.isManagedDebridStream: Boolean + get() { + val status = debridCacheStatus + return isAddonDebridCandidate && (isDirectDebridStream || ( + isTorrentStream && + status != null && + DebridProviders.byId(status.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true && + status.state != StreamDebridCacheState.CHECKING + )) + } + + private val StreamItem.isUncachedDebridStream: Boolean + get() = isInstalledAddonStream && + DebridProviders.byId(debridCacheStatus?.providerId)?.supports(DebridProviderCapability.LocalTorrentCacheCheck) == true && + debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED + + private fun StreamItem.isInactiveResolverStream(settings: DebridSettings): Boolean { + val streamProviderId = DebridProviders.byId(clientResolve?.service)?.id ?: return false + val activeProviderId = settings.activeResolverProviderId ?: return false + return isDirectDebridStream && streamProviderId != activeProviderId + } + + 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 DebridStreamFacts.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: DebridStreamFacts, + right: DebridStreamFacts, + 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: DebridStreamFacts, + right: DebridStreamFacts, + 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) + } + } +} + +internal object DebridStreamMetadata { + 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() + } + + fun facts(stream: StreamItem, preferences: DebridStreamPreferences): DebridStreamFacts { + 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 DebridStreamFacts( + 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 + ?: stream.debridCacheStatus?.cachedSize + + 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, + stream.behaviorHints.filename, + stream.debridCacheStatus?.cachedName, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } +} + +internal data class DebridStreamFacts( + 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, +) + +private fun Int.gigabytes(): Long = this * 1_000_000_000L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt new file mode 100644 index 00000000..f397d841 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamTemplateEngine.kt @@ -0,0 +1,396 @@ +package com.nuvio.app.features.debrid + +import kotlin.math.abs +import kotlin.math.roundToLong + +internal data class DebridTemplateBytes(val value: Long) + +class DebridStreamTemplateEngine { + fun render(template: String, values: Map): String { + if (template.isEmpty()) return "" + val out = StringBuilder() + var index = 0 + while (index < template.length) { + val start = template.indexOf('{', index) + if (start < 0) { + out.append(template.substring(index)) + break + } + out.append(template.substring(index, start)) + val end = findPlaceholderEnd(template, start + 1) + if (end < 0) { + out.append(template.substring(start)) + break + } + val expression = template.substring(start + 1, end) + out.append(renderExpression(expression, values)) + index = end + 1 + } + return out.toString() + } + + private fun renderExpression(expression: String, values: Map): String { + val bracket = findTopLevelChar(expression, '[') + if (bracket >= 0 && expression.endsWith("]")) { + val condition = expression.substring(0, bracket) + val branches = parseBranches(expression.substring(bracket + 1, expression.length - 1)) + val selected = if (evaluateCondition(condition, values)) branches.first else branches.second + return render(selected, values) + } + + val tokens = splitOps(expression) + if (tokens.isEmpty()) return "" + var value: Any? = values[tokens.first()] + tokens.drop(1).forEach { op -> + value = applyTransform(value, op) + } + return valueToText(value) + } + + private fun evaluateCondition(expression: String, values: Map): Boolean { + val tokens = splitOps(expression).filter { it.isNotBlank() } + if (tokens.isEmpty()) return false + val groups = mutableListOf>() + var currentGroup = mutableListOf() + var index = 0 + while (index < tokens.size) { + when (tokens[index]) { + "or" -> { + groups += currentGroup + currentGroup = mutableListOf() + index++ + } + "and" -> index++ + else -> { + val field = tokens[index] + index++ + val ops = mutableListOf() + while ( + index < tokens.size && + tokens[index] != "and" && + tokens[index] != "or" && + !tokens[index].isFieldPath() + ) { + ops += tokens[index] + index++ + } + currentGroup += evaluateSingleCondition(values[field], ops) + } + } + } + groups += currentGroup + return groups.any { group -> group.isNotEmpty() && group.all { it } } + } + + private fun evaluateSingleCondition(value: Any?, ops: List): Boolean { + if (ops.isEmpty()) return isTruthy(value) + var result = false + var hasResult = false + ops.forEach { op -> + when { + op == "exists" -> { + result = exists(value) + hasResult = true + } + op == "istrue" -> { + result = if (hasResult) result else asBoolean(value) == true + hasResult = true + } + op == "isfalse" -> { + result = if (hasResult) !result else asBoolean(value) == false + hasResult = true + } + op.startsWith("~=") -> { + result = containsText(value, op.drop(2).trim()) + hasResult = true + } + op.startsWith("~") -> { + result = containsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith("=") -> { + result = equalsText(value, op.drop(1).trim()) + hasResult = true + } + op.startsWith(">=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left >= right } + hasResult = true + } + op.startsWith("<=") -> { + result = compareNumber(value, op.drop(2)) { left, right -> left <= right } + hasResult = true + } + op.startsWith(">") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left > right } + hasResult = true + } + op.startsWith("<") -> { + result = compareNumber(value, op.drop(1)) { left, right -> left < right } + hasResult = true + } + } + } + return result + } + + private fun applyTransform(value: Any?, op: String): Any? = + when { + op == "title" -> valueToText(value).titleCased() + op == "lower" -> valueToText(value).lowercase() + op == "upper" -> valueToText(value).uppercase() + op == "bytes" -> asNumber(value)?.let { formatBytes(it) }.orEmpty() + op == "time" -> asNumber(value)?.let { formatTime(it) }.orEmpty() + op.startsWith("join(") -> { + val separator = parseArgs(op).firstOrNull() ?: ", " + when (value) { + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(separator) + else -> valueToText(value) + } + } + op.startsWith("replace(") -> { + val args = parseArgs(op) + if (args.size < 2) valueToText(value) else valueToText(value).replace(args[0], args[1]) + } + else -> value + } + + private fun findPlaceholderEnd(text: String, start: Int): Int { + var quote: Char? = null + var index = start + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + } else { + when (char) { + '\'', '"' -> quote = char + '}' -> return index + } + } + index++ + } + return -1 + } + + private fun findTopLevelChar(text: String, target: Char): Int { + var quote: Char? = null + var parenDepth = 0 + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && (index == 0 || text[index - 1] != '\\')) quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + target -> if (parenDepth == 0) return index + } + } + return -1 + } + + private fun splitOps(text: String): List { + val tokens = mutableListOf() + var quote: Char? = null + var parenDepth = 0 + var start = 0 + var index = 0 + while (index < text.length) { + val char = text[index] + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + index++ + continue + } + when (char) { + '\'', '"' -> quote = char + '(' -> parenDepth++ + ')' -> parenDepth = (parenDepth - 1).coerceAtLeast(0) + ':' -> { + if (parenDepth == 0 && text.getOrNull(index + 1) == ':') { + tokens += text.substring(start, index).trim() + index += 2 + start = index + continue + } + } + } + index++ + } + tokens += text.substring(start).trim() + return tokens.filter { it.isNotEmpty() } + } + + private fun parseBranches(text: String): Pair { + val split = findBranchSeparator(text) + if (split < 0) return parseQuoted(text) to "" + return parseQuoted(text.substring(0, split)) to parseQuoted(text.substring(split + 2)) + } + + private fun findBranchSeparator(text: String): Int { + var quote: Char? = null + text.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && text.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + '|' -> if (text.getOrNull(index + 1) == '|') return index + } + } + return -1 + } + + private fun parseArgs(op: String): List { + val start = op.indexOf('(') + val end = op.lastIndexOf(')') + if (start < 0 || end <= start) return emptyList() + val body = op.substring(start + 1, end) + val args = mutableListOf() + var quote: Char? = null + var argStart = 0 + body.forEachIndexed { index, char -> + if (quote != null) { + if (char == quote && body.getOrNull(index - 1) != '\\') quote = null + return@forEachIndexed + } + when (char) { + '\'', '"' -> quote = char + ',' -> { + args += parseQuoted(body.substring(argStart, index)) + argStart = index + 1 + } + } + } + args += parseQuoted(body.substring(argStart)) + return args + } + + private fun parseQuoted(raw: String): String { + val trimmed = raw.trim() + val unquoted = if ( + trimmed.length >= 2 && + ((trimmed.first() == '"' && trimmed.last() == '"') || + (trimmed.first() == '\'' && trimmed.last() == '\'')) + ) { + trimmed.substring(1, trimmed.length - 1) + } else { + trimmed + } + return unquoted + .replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\'", "'") + .replace("\\\\", "\\") + } + + private fun String.isFieldPath(): Boolean = + startsWith("stream.") || startsWith("service.") || startsWith("addon.") + + private fun exists(value: Any?): Boolean = + when (value) { + null -> false + is String -> value.isNotBlank() + is Iterable<*> -> value.any() + is Array<*> -> value.isNotEmpty() + else -> true + } + + private fun isTruthy(value: Any?): Boolean = + when (value) { + is Boolean -> value + is DebridTemplateBytes -> value.value != 0L + is Number -> value.toDouble() != 0.0 + else -> exists(value) + } + + private fun asBoolean(value: Any?): Boolean? = + when (value) { + is Boolean -> value + is String -> value.toBooleanStrictOrNull() + else -> null + } + + private fun asNumber(value: Any?): Double? = + when (value) { + is Number -> value.toDouble() + is DebridTemplateBytes -> value.value.toDouble() + is String -> value.toDoubleOrNull() + else -> null + } + + private fun compareNumber(value: Any?, rawTarget: String, compare: (Double, Double) -> Boolean): Boolean { + val left = asNumber(value) ?: return false + val right = rawTarget.trim().toDoubleOrNull() ?: return false + return compare(left, right) + } + + private fun equalsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).trim().equals(target, ignoreCase = true) } + else -> valueToText(value).trim().equals(target, ignoreCase = true) + } + + private fun containsText(value: Any?, target: String): Boolean = + when (value) { + is Iterable<*> -> value.any { valueToText(it).contains(target, ignoreCase = true) } + else -> valueToText(value).contains(target, ignoreCase = true) + } + + private fun valueToText(value: Any?): String = + when (value) { + null -> "" + is Iterable<*> -> value.mapNotNull { valueToText(it).takeIf { text -> text.isNotBlank() } } + .joinToString(", ") + is DebridTemplateBytes -> formatBytes(value.value.toDouble()) + is Double -> if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() + is Float -> if (value % 1f == 0f) value.toLong().toString() else value.toString() + else -> value.toString() + } + + private fun String.titleCased(): String = + split(Regex("\\s+")) + .joinToString(" ") { word -> + if (word.isBlank()) { + word + } else { + word.lowercase().replaceFirstChar { char -> + if (char.isLowerCase()) char.titlecase() else char.toString() + } + } + } + + private fun formatBytes(value: Double): String { + val bytes = abs(value) + if (bytes < 1024.0) return "${value.toLong()} B" + val units = listOf("KB", "MB", "GB", "TB") + var current = bytes + var unitIndex = -1 + while (current >= 1024.0 && unitIndex < units.lastIndex) { + current /= 1024.0 + unitIndex++ + } + val signed = if (value < 0) -current else current + return if (signed >= 10 || signed % 1.0 == 0.0) { + "${signed.toLong()} ${units[unitIndex]}" + } else { + val tenths = (signed * 10.0).roundToLong() + "${tenths / 10}.${abs(tenths % 10)} ${units[unitIndex]}" + } + } + + private fun formatTime(value: Double): String { + val seconds = value.toLong() + val hours = seconds / 3600 + val minutes = (seconds % 3600) / 60 + val remainingSeconds = seconds % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m ${remainingSeconds}s" + else -> "${remainingSeconds}s" + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt new file mode 100644 index 00000000..2d9465d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridUrlEncoding.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.debrid + +internal fun encodePathSegment(value: String): String = + percentEncode(value, spaceAsPlus = false) + +internal fun encodeFormValue(value: String): String = + percentEncode(value, spaceAsPlus = true) + +internal fun queryString(vararg pairs: Pair): String = + pairs + .mapNotNull { (key, value) -> + value?.let { "${encodePathSegment(key)}=${encodePathSegment(it)}" } + } + .joinToString("&") + +private fun percentEncode(value: String, spaceAsPlus: Boolean): String = buildString { + val hex = "0123456789ABCDEF" + value.encodeToByteArray().forEach { byte -> + val code = byte.toInt() and 0xFF + val isUnreserved = (code in 'A'.code..'Z'.code) || + (code in 'a'.code..'z'.code) || + (code in '0'.code..'9'.code) || + code == '-'.code || + code == '.'.code || + code == '_'.code || + code == '~'.code + when { + isUnreserved -> append(code.toChar()) + spaceAsPlus && code == 0x20 -> append('+') + else -> { + append('%') + append(hex[code shr 4]) + append(hex[code and 0x0F]) + } + } + } +} + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt new file mode 100644 index 00000000..73c127fc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridResolver.kt @@ -0,0 +1,376 @@ +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.StreamDebridCacheState +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_not_cached +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 localAddonStreamResolver = LocalDebridAddonStreamResolver() + 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 { + if (!shouldResolveToPlayableStream(stream)) { + return DirectDebridResolveResult.Stale + } + val cacheKey = stream.debridResolveCacheKey(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? { + if (!shouldResolveToPlayableStream(stream)) return null + val cacheKey = stream.debridResolveCacheKey(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..DEBRID_RESOLVE_CACHE_TTL_MS) { + cached.result + } else { + resolvedCache.remove(cacheKey) + null + } + } + + fun shouldResolveToPlayableStream(stream: StreamItem): Boolean { + val settings = DebridSettingsRepository.snapshot() + if (!settings.canResolvePlayableLinks) return false + if (stream.needsLocalDebridResolve) { + return stream.isInstalledAddonStream && localTorrentResolveCredential(settings) != null + } + if (!stream.isInstalledAddonStream || !stream.isDirectDebridStream || stream.playableDirectUrl != null) { + return false + } + val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id ?: return false + return providerId == settings.activeResolverProviderId && + settings.apiKeyFor(providerId).isNotBlank() && + DebridProviderApis.apiFor(providerId) != null + } + + private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + if (stream.needsLocalDebridResolve) { + return localAddonStreamResolver.resolve(stream, season, episode) + } + val providerId = DebridProviders.byId(stream.clientResolve?.service)?.id + ?: return DirectDebridResolveResult.Error + val settings = DebridSettingsRepository.snapshot() + if (providerId != settings.activeResolverProviderId) { + return DirectDebridResolveResult.Stale + } + val apiKey = settings + .apiKeyFor(providerId) + .trim() + .takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.MissingApiKey + val api = DebridProviderApis.apiFor(providerId) ?: return DirectDebridResolveResult.Error + return api.resolveClientStream(stream, apiKey, season, episode) + } + + suspend fun resolveToPlayableStream( + stream: StreamItem, + season: Int?, + episode: Int?, + ): DirectDebridPlayableResult { + if (!shouldResolveToPlayableStream(stream)) { + 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.NotCached -> DirectDebridPlayableResult.NotCached + DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale + DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error + } + } +} + +private const val 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 NotCached : 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 NotCached : 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.NotCached -> runBlocking { getString(Res.string.debrid_not_cached) } + DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) } + DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) } + } + +private class LocalDebridAddonStreamResolver( + private val fileSelector: TorboxFileSelector = TorboxFileSelector(), + private val premiumizeFileSelector: PremiumizeDirectDownloadFileSelector = PremiumizeDirectDownloadFileSelector(), +) { + suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult { + val account = localTorrentResolveCredential() ?: return DirectDebridResolveResult.MissingApiKey + val apiKey = account.apiKey.trim() + + val hash = stream.infoHash?.trim()?.lowercase() + if (stream.debridCacheStatus?.state == StreamDebridCacheState.NOT_CACHED) { + return DirectDebridResolveResult.NotCached + } + if ( + !hash.isNullOrBlank() && + stream.debridCacheStatus?.state != StreamDebridCacheState.CACHED && + account.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) + ) { + when (LocalDebridService.isCached(account, hash)) { + false -> return DirectDebridResolveResult.NotCached + true, null -> Unit + } + } + + val magnet = DebridMagnetBuilder.fromStream(stream) + ?: return DirectDebridResolveResult.Stale + val resolve = stream.toResolveMetadata(season, episode, account.provider.id) + + return when (account.provider.id) { + DebridProviders.TORBOX_ID -> resolveTorbox(stream, resolve, apiKey, magnet, season, episode) + DebridProviders.PREMIUMIZE_ID -> resolvePremiumizeDirectDownload( + apiKey = apiKey, + source = magnet, + resolve = resolve, + season = season, + episode = episode, + fallbackFilename = stream.behaviorHints.filename, + fallbackSize = stream.behaviorHints.videoSize, + fileSelector = premiumizeFileSelector, + ) + else -> DirectDebridResolveResult.Error + } + } + + private suspend fun resolveTorbox( + stream: StreamItem, + resolve: StreamClientResolve, + apiKey: String, + magnet: String, + season: Int?, + episode: Int?, + ): DirectDebridResolveResult { + return try { + val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet) + val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId() + ?: return create.toFailureForCreate() + + val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId) + if (!torrent.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val files = torrent.body?.data?.files.orEmpty() + val file = fileSelector.selectFile(files, resolve, season, episode) + ?: return DirectDebridResolveResult.Stale + val fileId = file.id ?: return DirectDebridResolveResult.Stale + + val link = TorboxApiClient.requestDownloadLink( + apiKey = apiKey, + torrentId = torrentId, + fileId = fileId, + ) + if (!link.isSuccessful) { + return DirectDebridResolveResult.Stale + } + val url = link.body?.data?.takeIf { it.isNotBlank() } + ?: return DirectDebridResolveResult.Stale + + DirectDebridResolveResult.Success( + url = url, + filename = file.displayName().takeIf { it.isNotBlank() } + ?: stream.behaviorHints.filename?.takeIf { it.isNotBlank() }, + videoSize = file.size ?: stream.behaviorHints.videoSize, + ) + } catch (error: Exception) { + if (error is CancellationException) throw error + DirectDebridResolveResult.Error + } + } +} + +private fun localTorrentResolveCredential( + settings: DebridSettings = DebridSettingsRepository.snapshot(), +): DebridServiceCredential? = + settings.activeResolverCredential + ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentResolve) } + +private fun StreamItem.debridResolveCacheKey(season: Int?, episode: Int?): String? { + val resolve = clientResolve + if (resolve == null && needsLocalDebridResolve) { + val account = localTorrentResolveCredential() ?: return null + val apiKey = account.apiKey.trim().takeIf { it.isNotBlank() } ?: return null + val identity = infoHash ?: torrentMagnetUri ?: behaviorHints.filename ?: return null + return listOf( + account.provider.id, + apiKey.stableFingerprint(), + identity.trim().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().trim().lowercase(), + season?.toString().orEmpty(), + episode?.toString().orEmpty(), + ).joinToString("|") + } + resolve ?: return null + val providerId = DebridProviders.byId(resolve.service)?.id ?: return null + val settings = DebridSettingsRepository.snapshot() + if (providerId != settings.activeResolverProviderId) return null + val apiKey = settings + .apiKeyFor(providerId) + .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 StreamItem.toResolveMetadata(season: Int?, episode: Int?, providerId: String): StreamClientResolve = + StreamClientResolve( + type = "torrent", + infoHash = infoHash, + fileIdx = fileIdx, + magnetUri = torrentMagnetUri, + sources = sources, + torrentName = title ?: name, + filename = behaviorHints.filename, + season = season, + episode = episode, + service = providerId, + isCached = debridCacheStatus?.state == StreamDebridCacheState.CACHED, + ) + +private fun DebridApiResponse>.toFailureForCreate(): DirectDebridResolveResult = + when (status) { + 401, 403 -> DirectDebridResolveResult.Error + 409 -> DirectDebridResolveResult.NotCached + else -> DirectDebridResolveResult.Stale + } + +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/DirectDebridStreamPreparer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt new file mode 100644 index 00000000..775259b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparer.kt @@ -0,0 +1,205 @@ +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.canResolvePlayableLinks || limit <= 0) return + + val candidates = prioritizeCandidates( + streams = streams.filter(DirectDebridPlaybackResolver::shouldResolveToPlayableStream), + 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 debrid budget reached" } + return + } + + try { + when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) { + is DirectDebridPlayableResult.Success -> { + if (result.stream.playableDirectUrl != 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 { stream -> + stream.playableDirectUrl == null && + stream.isAddonDebridCandidate && + (stream.isDirectDebridStream || stream.isCachedDebridTorrentStream) + } + .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?.let { it.isAddonDebridCandidate && (it.isDirectDebridStream || it.isCachedDebridTorrentStream) } == 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, + eligibleGroupIds: Set? = null, + ): List { + val key = original.preparationKey() + return groups.map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@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(), + infoHash.orEmpty().lowercase(), + fileIdx?.toString().orEmpty(), + behaviorHints.filename.orEmpty().lowercase(), + playableDirectUrl.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(playableDirectUrl.orEmpty()) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt new file mode 100644 index 00000000..227a6d37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridAvailabilityService.kt @@ -0,0 +1,114 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamDebridCacheStatus +import com.nuvio.app.features.streams.StreamItem + +object LocalDebridAvailabilityService { + fun markChecking( + groups: List, + eligibleGroupIds: Set? = null, + ): List { + val account = cacheCheckAccount() ?: return groups + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> + if (stream.localAvailabilityHash() == null || stream.debridCacheStatus?.state == StreamDebridCacheState.CACHED) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = account.provider.id, + providerName = account.provider.displayName, + state = StreamDebridCacheState.CHECKING, + ), + ) + } + } + } + + suspend fun annotateCachedAvailability( + groups: List, + eligibleGroupIds: Set? = null, + ): List { + val account = cacheCheckAccount() ?: return groups + val hashes = groups + .filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds } + .flatMap { group -> + group.streams.mapNotNull { stream -> + stream.localAvailabilityHash() + ?.takeUnless { stream.debridCacheStatus?.state in FINAL_CACHE_STATES } + } + } + .distinct() + if (hashes.isEmpty()) return groups + + val cached = LocalDebridService.checkCached(account = account, hashes = hashes) + ?: return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> + val hash = stream.localAvailabilityHash() + if (hash == null) { + stream + } else { + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = account.provider.id, + providerName = account.provider.displayName, + state = StreamDebridCacheState.UNKNOWN, + ), + ) + } + } + + return groups.updateAvailabilityStatus(eligibleGroupIds) { stream -> + val hash = stream.localAvailabilityHash() ?: return@updateAvailabilityStatus stream + if (stream.debridCacheStatus?.state in FINAL_CACHE_STATES) return@updateAvailabilityStatus stream + val cachedItem = cached[hash] + stream.copy( + debridCacheStatus = StreamDebridCacheStatus( + providerId = account.provider.id, + providerName = account.provider.displayName, + state = if (cachedItem == null) StreamDebridCacheState.NOT_CACHED else StreamDebridCacheState.CACHED, + cachedName = cachedItem?.name, + cachedSize = cachedItem?.size, + ), + ) + } + } + + suspend fun isCached(hash: String): Boolean? { + val account = cacheCheckAccount() ?: return null + return LocalDebridService.isCached(account, hash) + } + + private fun cacheCheckAccount(): DebridServiceCredential? { + val settings = DebridSettingsRepository.snapshot() + if (!settings.canResolvePlayableLinks) return null + return settings.activeResolverCredential + ?.takeIf { credential -> credential.provider.supports(DebridProviderCapability.LocalTorrentCacheCheck) } + } +} + +private val FINAL_CACHE_STATES = setOf( + StreamDebridCacheState.CACHED, + StreamDebridCacheState.NOT_CACHED, +) + +internal fun StreamItem.localAvailabilityHash(): String? = + infoHash + ?.trim() + ?.lowercase() + ?.takeIf { isInstalledAddonStream && needsLocalDebridResolve && it.isNotBlank() } + +private fun List.updateAvailabilityStatus( + eligibleGroupIds: Set?, + transform: (StreamItem) -> StreamItem, +): List = + map { group -> + if (eligibleGroupIds != null && group.addonId !in eligibleGroupIds) return@map group + var changed = false + val updatedStreams = group.streams.map { stream -> + val updated = transform(stream) + if (updated != stream) changed = true + updated + } + if (changed) group.copy(streams = updatedStreams) else group + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt new file mode 100644 index 00000000..59888c7a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/LocalDebridService.kt @@ -0,0 +1,82 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull + +internal data class LocalDebridCachedItem( + val name: String?, + val size: Long?, +) + +internal object LocalDebridService { + suspend fun checkCached( + account: DebridServiceCredential, + hashes: List, + ): Map? = + when (account.provider.id) { + DebridProviders.TORBOX_ID -> checkTorboxCached(account.apiKey, hashes) + DebridProviders.PREMIUMIZE_ID -> checkPremiumizeCached(account.apiKey, hashes) + else -> null + } + + suspend fun isCached(account: DebridServiceCredential, hash: String): Boolean? { + val normalizedHash = hash.trim().lowercase().takeIf { it.isNotBlank() } ?: return null + return checkCached(account, listOf(normalizedHash))?.containsKey(normalizedHash) + } + + private suspend fun checkTorboxCached( + apiKey: String, + hashes: List, + ): Map? = + try { + val response = TorboxApiClient.checkCached(apiKey = apiKey, hashes = hashes) + if (!response.isSuccessful || response.body?.success == false) { + null + } else { + response.body?.data.orEmpty().mapKeys { it.key.lowercase() }.mapValues { (_, value) -> + LocalDebridCachedItem( + name = value.name, + size = value.size, + ) + } + } + } catch (error: Exception) { + if (error is CancellationException) throw error + null + } + + private suspend fun checkPremiumizeCached( + apiKey: String, + hashes: List, + ): Map? = + try { + val normalizedHashes = hashes + .map { it.trim().lowercase() } + .filter { it.isNotBlank() } + .distinct() + if (normalizedHashes.isEmpty()) return emptyMap() + val sources = normalizedHashes.map { hash -> "magnet:?xt=urn:btih:$hash" } + val response = PremiumizeApiClient.checkCache(apiKey = apiKey, items = sources) + val body = response.body + if (!response.isSuccessful || body?.status.equals("error", ignoreCase = true)) { + null + } else { + normalizedHashes.mapIndexedNotNull { index, hash -> + if (body?.response?.getOrNull(index) != true) return@mapIndexedNotNull null + hash to LocalDebridCachedItem( + name = body.filename?.getOrNull(index), + size = body.filesize?.getOrNull(index)?.asLongOrNull(), + ) + }.toMap() + } + } catch (error: Exception) { + if (error is CancellationException) throw error + null + } +} + +private fun kotlinx.serialization.json.JsonElement?.asLongOrNull(): Long? { + val primitive = this as? JsonPrimitive ?: return null + return primitive.longOrNull ?: primitive.content.toLongOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 899747c7..a9ea7373 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 @@ -81,6 +81,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository @@ -378,6 +379,29 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } + val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) { + if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) { + DetailDebridWarmupTarget( + videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id, + season = seriesAction?.seasonNumber, + episode = seriesAction?.episodeNumber, + ) + } else { + DetailDebridWarmupTarget( + videoId = meta.id, + season = null, + episode = null, + ) + } + } + LaunchedEffect(meta.type, debridWarmupTarget) { + AddonStreamWarmupRepository.preload( + type = meta.type, + videoId = debridWarmupTarget.videoId, + season = debridWarmupTarget.season, + episode = debridWarmupTarget.episode, + ) + } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -1367,3 +1391,14 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } + +private data class DetailDebridWarmupTarget( + val videoId: String, + val season: Int?, + val episode: Int?, +) + +private fun MetaDetails.isSeriesLikeForDebridWarmup(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/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index d2210058..9e655f77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction( watchedItems: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, +): SeriesPrimaryAction? = + seriesPrimaryAction( + content = WatchingContentRef(type = type, id = id), + entries = entries, + watchedItems = watchedItems, + todayIsoDate = todayIsoDate, + preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, + ) + +internal fun MetaDetails.seriesPrimaryAction( + content: WatchingContentRef, + entries: List, + watchedItems: List, + todayIsoDate: String, + preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): SeriesPrimaryAction? = decideSeriesPrimaryAction( - content = WatchingContentRef(type = type, id = id), + content = content, episodes = videos.map(MetaVideo::toDomainReleasedEpisode), progressRecords = entries.map(WatchProgressEntry::toDomainProgressRecord), watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord), todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, + showUnairedNextUp = showUnairedNextUp, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index 7ed74677..b0440dc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -117,7 +117,7 @@ object DownloadsRepository { ): DownloadEnqueueResult { ensureLoaded() - val sourceUrl = stream.directPlaybackUrl + val sourceUrl = stream.playableDirectUrl ?.trim() ?.takeIf { it.isNotBlank() } ?: return DownloadEnqueueResult.MissingUrl diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 3bf4715b..6cabeec3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -21,8 +21,15 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.findPlaybackTargetForProgress +import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.nextReleasedEpisodeAfter +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.SeriesPrimaryAction +import com.nuvio.app.features.details.seriesPrimaryAction import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -44,6 +51,7 @@ import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode +import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching @@ -51,14 +59,12 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.WatchProgressSourceLocal -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback -import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.continueWatchingEntries import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem +import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository @@ -100,12 +106,16 @@ fun HomeScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val homeUiState by HomeRepository.uiState.collectAsStateWithLifecycle() - val homeSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() + val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val traktSettingsUiState by remember { TraktSettingsRepository.ensureLoaded() @@ -167,47 +177,41 @@ fun HomeScreen( ) } - val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { - if (isTraktProgressActive) emptyList() else watchedUiState.items - } - - val allNextUpSeedEntries = remember( + val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - effectiveWatchedItems, + watchedUiState.items, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { - buildTvParityNextUpSeedEntries( + buildHomeNextUpSeedCandidates( progressEntries = watchProgressUiState.entries, - watchedItems = effectiveWatchedItems, + watchedItems = watchedUiState.items, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val recentNextUpSeedEntries = remember( - allNextUpSeedEntries, + val recentNextUpSeedCandidates = remember( + allNextUpSeedCandidates, isTraktProgressActive, traktSettingsUiState.continueWatchingDaysCap, ) { - filterEntriesForTraktContinueWatchingWindow( - entries = allNextUpSeedEntries, + filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates = allNextUpSeedCandidates, isTraktProgressActive = isTraktProgressActive, daysCap = traktSettingsUiState.continueWatchingDaysCap, nowEpochMs = WatchProgressClock.nowEpochMs(), ) } - val activeNextUpSeedContentIds = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapTo(mutableSetOf()) { entry -> entry.parentMetaId } + val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } } - val currentNextUpSeedByContentId = remember(allNextUpSeedEntries) { - allNextUpSeedEntries.mapNotNull { entry -> - val season = entry.seasonNumber ?: return@mapNotNull null - val episode = entry.episodeNumber ?: return@mapNotNull null - entry.parentMetaId to (season to episode) + val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates.associate { candidate -> + candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber) }.toMap() } @@ -215,10 +219,16 @@ fun HomeScreen( effectiveWatchProgressEntries.continueWatchingEntries() } - val latestCompletedAtBySeries = remember(allNextUpSeedEntries) { - allNextUpSeedEntries - .groupBy { entry -> entry.parentMetaId } - .mapValues { (_, entries) -> entries.maxOfOrNull { entry -> entry.lastUpdatedEpochMs } ?: Long.MIN_VALUE } + LaunchedEffect(visibleContinueWatchingEntries) { + if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) { + CloudLibraryRepository.ensureLoaded() + } + } + + val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) { + allNextUpSeedCandidates + .groupBy { candidate -> candidate.content.id } + .mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE } } val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) { @@ -236,17 +246,9 @@ fun HomeScreen( .toSet() } - val completedSeriesCandidates = remember(recentNextUpSeedEntries, nextUpSuppressedSeriesIds) { - recentNextUpSeedEntries.mapNotNull { seed -> - val season = seed.seasonNumber ?: return@mapNotNull null - val episode = seed.episodeNumber ?: return@mapNotNull null - if (season == 0 || seed.parentMetaId in nextUpSuppressedSeriesIds) return@mapNotNull null - CompletedSeriesCandidate( - content = WatchingContentRef(type = seed.parentMetaType, id = seed.parentMetaId), - seasonNumber = season, - episodeNumber = episode, - markedAtEpochMs = seed.lastUpdatedEpochMs, - ) + val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) { + recentNextUpSeedCandidates.filter { candidate -> + candidate.content.id !in nextUpSuppressedSeriesIds } } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() @@ -256,6 +258,17 @@ fun HomeScreen( var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } + val shouldValidateMissingNextUpSeeds = remember( + isTraktProgressActive, + watchProgressUiState.hasLoadedRemoteProgress, + watchedUiState.isLoaded, + ) { + if (isTraktProgressActive) { + watchProgressUiState.hasLoadedRemoteProgress + } else { + watchedUiState.isLoaded + } + } val cachedNextUpItems = remember( cachedSnapshots.first, continueWatchingPreferences.dismissedNextUpKeys, @@ -263,6 +276,7 @@ fun HomeScreen( currentNextUpSeedByContentId, isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, + shouldValidateMissingNextUpSeeds, processedNextUpContentIds, nextUpItemsBySeries, continueWatchingPreferences.showUnairedNextUp, @@ -270,25 +284,13 @@ fun HomeScreen( ) { cachedSnapshots.first.mapNotNull { cached -> if ( - !isTraktProgressActive && - watchedUiState.isLoaded && - cached.contentId !in activeNextUpSeedContentIds - ) { - return@mapNotNull null - } - if ( - isTraktProgressActive && - watchProgressUiState.hasLoadedRemoteProgress && + shouldValidateMissingNextUpSeeds && cached.contentId !in activeNextUpSeedContentIds ) { return@mapNotNull null } val currentSeed = currentNextUpSeedByContentId[cached.contentId] - if ( - currentSeed != null && - cached.seedSeason != null && - cached.seedEpisode != null - ) { + if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode if (seedChanged) return@mapNotNull null @@ -321,8 +323,16 @@ fun HomeScreen( nextUpItemsBySeries, cachedNextUpItems, continueWatchingPreferences.dismissedNextUpKeys, + activeNextUpSeedContentIds, + currentNextUpSeedByContentId, + shouldValidateMissingNextUpSeeds, ) { - val liveNextUpItems = nextUpItemsBySeries.filterValues { (_, item) -> + val liveNextUpItems = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = nextUpItemsBySeries, + activeSeedContentIds = activeNextUpSeedContentIds, + currentSeedByContentId = currentNextUpSeedByContentId, + shouldDropItemsWithoutActiveSeed = shouldValidateMissingNextUpSeeds, + ).filterValues { (_, item) -> nextUpDismissKey( item.parentMetaId, item.nextUpSeedSeasonNumber, @@ -345,6 +355,7 @@ fun HomeScreen( effectivNextUpItems, nextUpSuppressedSeriesIds, continueWatchingPreferences.sortMode, + cloudLibraryUiState, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, @@ -353,6 +364,7 @@ fun HomeScreen( nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), + cloudLibraryUiState = cloudLibraryUiState, ) } val availableManifests = remember(addonsUiState.addons) { @@ -396,6 +408,9 @@ fun HomeScreen( visibleContinueWatchingEntries, metaProviderKey, continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + watchProgressUiState.entries, + watchedUiState.items, ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() @@ -446,12 +461,18 @@ fun HomeScreen( if (meta == null) { return@withPermit null } - val nextEpisode = meta.nextReleasedEpisodeAfter( - seasonNumber = completedEntry.seasonNumber, - episodeNumber = completedEntry.episodeNumber, + val action = meta.seriesPrimaryAction( + content = completedEntry.content, + entries = watchProgressUiState.entries, + watchedItems = watchedUiState.items, todayIsoDate = todayIsoDate, + preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) + if (action?.resumePositionMs != null) { + return@withPermit null + } + val nextEpisode = action?.let { meta.videoForSeriesAction(it) } if (nextEpisode == null) { return@withPermit null } @@ -612,7 +633,10 @@ fun HomeScreen( } } items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeSettingsUiState.hideCatalogUnderline, + ) } } @@ -717,40 +741,99 @@ internal fun filterEntriesForTraktContinueWatchingWindow( return entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } } -private fun buildTvParityNextUpSeedEntries( +internal fun filterHomeNextUpCandidatesForTraktContinueWatchingWindow( + candidates: List, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + if (!isTraktProgressActive) return candidates + val normalizedDaysCap = normalizeTraktContinueWatchingDaysCap(daysCap) + if (normalizedDaysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return candidates + + val cutoffMs = nowEpochMs - (normalizedDaysCap.toLong() * MILLIS_PER_DAY) + return candidates.filter { candidate -> candidate.markedAtEpochMs >= cutoffMs } +} + +internal fun buildHomeNextUpSeedCandidates( progressEntries: List, watchedItems: List, isTraktProgressActive: Boolean, preferFurthestEpisode: Boolean, nowEpochMs: Long, -): List { - val rawSeeds = if (isTraktProgressActive) { - progressEntries.asSequence() - .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } - .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } - .filter { entry -> shouldUseAsTraktNextUpSeed(entry, nowEpochMs) } - .toList() - } else { - watchedItems.asSequence() - .filter { item -> item.type.isSeriesTypeForContinueWatching() } - .filter { item -> item.season != null && item.episode != null && item.season != 0 } - .filter { item -> !isMalformedNextUpSeedContentId(item.id) } - .map { item -> item.toNextUpSeedEntry() } - .toList() +): List { + val progressSeeds = progressEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .filter { entry -> entry.seasonNumber != null && entry.episodeNumber != null && entry.seasonNumber != 0 } + .filter { entry -> !isMalformedNextUpSeedContentId(entry.parentMetaId) } + .filter { entry -> + if (isTraktProgressActive) { + shouldUseAsTraktNextUpSeed(entry = entry, nowEpochMs = nowEpochMs) + } else { + entry.shouldUseAsCompletedSeedForContinueWatching() + } + } + .toList() + val watchedSeeds = watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) } - return if (isTraktProgressActive) { - mergeTvTraktNextUpSeeds(rawSeeds) - } else { - rawSeeds - .groupBy { entry -> nextUpSeedKey(entry) } - .mapNotNull { (_, entries) -> - choosePreferredNextUpSeed( - entries = entries, - preferFurthestEpisode = preferFurthestEpisode, - ) - } - .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + return WatchingState.latestCompletedBySeries( + progressEntries = progressSeeds, + watchedItems = watchedSeeds, + preferFurthestEpisode = preferFurthestEpisode, + ).mapNotNull { (content, completed) -> + if (!content.type.isSeriesTypeForContinueWatching()) return@mapNotNull null + if (completed.seasonNumber == 0) return@mapNotNull null + if (isMalformedNextUpSeedContentId(content.id)) return@mapNotNull null + CompletedSeriesCandidate( + content = content, + seasonNumber = completed.seasonNumber, + episodeNumber = completed.episodeNumber, + markedAtEpochMs = completed.markedAtEpochMs, + ) + }.sortedWith( + compareByDescending { candidate -> candidate.markedAtEpochMs } + .thenByDescending { candidate -> candidate.seasonNumber } + .thenByDescending { candidate -> candidate.episodeNumber }, + ) +} + +internal fun filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries: Map>, + activeSeedContentIds: Set, + currentSeedByContentId: Map>, + shouldDropItemsWithoutActiveSeed: Boolean, +): Map> = + nextUpItemsBySeries.filter { (contentId, pair) -> + if (shouldDropItemsWithoutActiveSeed && contentId !in activeSeedContentIds) { + return@filter false + } + val item = pair.second + val currentSeed = currentSeedByContentId[contentId] ?: return@filter true + item.nextUpSeedSeasonNumber == currentSeed.first && + item.nextUpSeedEpisodeNumber == currentSeed.second + } + +private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { + if (action.seasonNumber != null && action.episodeNumber != null) { + videos.firstOrNull { video -> + video.season == action.seasonNumber && + video.episode == action.episodeNumber + }?.let { return it } + } + return videos.firstOrNull { video -> + com.nuvio.app.features.watchprogress.buildPlaybackVideoId( + parentMetaId = id, + seasonNumber = video.season, + episodeNumber = video.episode, + fallbackVideoId = video.id, + ) == action.videoId || video.id == action.videoId } } @@ -765,103 +848,6 @@ private fun shouldUseAsTraktNextUpSeed( return ageMs in 0..OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS } -private fun WatchedItem.toNextUpSeedEntry(): WatchProgressEntry = - WatchProgressEntry( - contentType = type, - parentMetaId = id, - parentMetaType = type, - videoId = id, - title = name, - poster = poster, - seasonNumber = season, - episodeNumber = episode, - lastPositionMs = 1L, - durationMs = 1L, - lastUpdatedEpochMs = markedAtEpochMs, - isCompleted = true, - progressPercent = 100f, - source = WatchProgressSourceLocal, - ) - -private fun nextUpSeedKey(entry: WatchProgressEntry): String = - entry.parentMetaId.trim() - -private fun mergeTvTraktNextUpSeeds(entries: List): List { - val merged = linkedMapOf() - entries - .filter { entry -> entry.source == WatchProgressSourceTraktShowProgress } - .forEach { seed -> - merged[nextUpSeedKey(seed)] = seed - } - entries - .filter { entry -> entry.source == WatchProgressSourceTraktHistory || entry.source == WatchProgressSourceTraktPlayback } - .forEach { seed -> - val key = nextUpSeedKey(seed) - val existing = merged[key] - if (existing == null || shouldReplaceNextUpSeed(existing, seed)) { - merged[key] = seed - } - } - return merged.values.sortedByDescending { entry -> entry.lastUpdatedEpochMs } -} - -private fun shouldReplaceNextUpSeed( - existing: WatchProgressEntry, - candidate: WatchProgressEntry, -): Boolean { - val candidateSeason = candidate.seasonNumber ?: -1 - val candidateEpisode = candidate.episodeNumber ?: -1 - val existingSeason = existing.seasonNumber ?: -1 - val existingEpisode = existing.episodeNumber ?: -1 - return candidateSeason > existingSeason || - ( - candidateSeason == existingSeason && - ( - candidateEpisode > existingEpisode || - ( - candidateEpisode == existingEpisode && - candidate.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs - ) - ) - ) -} - -private fun choosePreferredNextUpSeed( - entries: List, - preferFurthestEpisode: Boolean, -): WatchProgressEntry? { - if (entries.isEmpty()) return null - val bestRank = entries.minOf(::nextUpSeedSourceRank) - return entries - .asSequence() - .filter { entry -> nextUpSeedSourceRank(entry) == bestRank } - .maxWithOrNull( - if (preferFurthestEpisode) { - compareBy( - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - { it.lastUpdatedEpochMs }, - ) - } else { - compareBy( - { it.lastUpdatedEpochMs }, - { it.seasonNumber ?: -1 }, - { it.episodeNumber ?: -1 }, - ) - }, - ) -} - -private fun nextUpSeedSourceRank(entry: WatchProgressEntry): Int = - when (entry.source) { - WatchProgressSourceTraktPlayback, - WatchProgressSourceTraktShowProgress, - -> 0 - WatchProgressSourceTraktHistory -> 1 - WatchProgressSourceLocal -> 2 - else -> 4 - } - private fun shouldTreatAsActiveInProgressForNextUpSuppression( progress: WatchProgressEntry, latestCompletedAt: Long?, @@ -871,15 +857,6 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( return progress.lastUpdatedEpochMs >= latestCompletedAt } -private fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { - val trimmed = contentId?.trim().orEmpty() - if (trimmed.isEmpty()) return true - return when (trimmed.lowercase()) { - "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true - else -> false - } -} - private fun heroMobileBelowSectionHeightHint( maxWidthDp: Float, continueWatchingVisible: Boolean, @@ -905,6 +882,7 @@ internal fun buildHomeContinueWatchingItems( nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", + cloudLibraryUiState: CloudLibraryUiState? = null, ): List { val suppressedSeriesIds = nextUpSuppressedSeriesIds ?: visibleEntries @@ -920,7 +898,9 @@ internal fun buildHomeContinueWatchingItems( val liveItem = entry.toContinueWatchingItem() HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - item = liveItem.withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]), + item = liveItem + .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) }, @@ -994,7 +974,7 @@ private fun applyStreamingStyleSort( return sortedReleased + sortedUnreleased } -private data class CompletedSeriesCandidate( +internal data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, val episodeNumber: Int, @@ -1160,9 +1140,16 @@ private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { if (fallback == null) return this + val fallbackTitle = fallback.title + .takeIf { it.isNotBlank() } + ?.takeUnless { fallback.hasPlaceholderCloudTitle() } return copy( - title = title.ifBlank { fallback.title }, + title = when { + title.isBlank() -> fallback.title + hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + else -> title + }, subtitle = subtitle.ifBlank { fallback.subtitle }, imageUrl = imageUrl ?: fallback.imageUrl, logo = logo ?: fallback.logo, @@ -1174,3 +1161,35 @@ private fun ContinueWatchingItem.withFallbackMetadata( released = released ?: fallback.released, ) } + +private fun ContinueWatchingItem.withCloudLibraryMetadata( + cloudLibraryUiState: CloudLibraryUiState?, +): ContinueWatchingItem { + if (!isCloudLibraryContinueWatchingItem() || cloudLibraryUiState == null) return this + val target = cloudLibraryUiState.findPlaybackTargetForProgress( + contentId = parentMetaId, + videoId = videoId, + ) ?: return this + val fileName = target.file.name.trim().takeIf { it.isNotBlank() } + ?: target.item.name.trim().takeIf { it.isNotBlank() } + ?: return this + return copy( + title = fileName, + pauseDescription = pauseDescription + ?: target.item.name.takeIf { itemName -> itemName.isNotBlank() && itemName != fileName }, + ) +} + +private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { + if (!isCloudLibraryContinueWatchingItem()) return false + val normalizedTitle = title.trim() + return normalizedTitle.equals(parentMetaId, ignoreCase = true) || + normalizedTitle.equals(videoId, ignoreCase = true) +} + +private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + +private fun WatchProgressEntry.isCloudLibraryProgressEntry(): Boolean = + contentType.equals(CloudLibraryContentType, ignoreCase = true) || + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index 6037ce7b..804a5e22 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -45,6 +45,8 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle @@ -64,10 +66,15 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin stringResource(Res.string.compose_player_episode_code_full, item.seasonNumber, item.episodeNumber) item.isNextUp -> stringResource(Res.string.continue_watching_up_next) + item.isCloudLibraryItem() -> + stringResource(Res.string.library_source_cloud) else -> stringResource(Res.string.media_movie) } +private fun ContinueWatchingItem.isCloudLibraryItem(): Boolean = + parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + private fun ContinueWatchingItem.continueWatchingArtworkUrl( useEpisodeThumbnails: Boolean, ): String? = when { @@ -392,6 +399,7 @@ private fun ContinueWatchingWideCard( imageUrl = artworkUrl, width = layout.widePosterStripWidth, blurred = shouldBlurArtwork, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, modifier = Modifier.fillMaxHeight(), ) Column( @@ -504,12 +512,12 @@ private fun ContinueWatchingPosterCard( val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = item.title, modifier = Modifier .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = if (item.isCloudLibraryItem()) ContentScale.Fit else ContentScale.Crop, ) } if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { @@ -589,6 +597,7 @@ private fun ArtworkPanel( imageUrl: String?, width: Dp, blurred: Boolean = false, + contentScale: ContentScale = ContentScale.Crop, modifier: Modifier = Modifier, ) { Box( @@ -598,12 +607,12 @@ private fun ArtworkPanel( ) { if (imageUrl != null) { AsyncImage( - model = imageUrl, + model = cloudLibraryDisplayArtworkUrl(imageUrl), contentDescription = null, modifier = Modifier .fillMaxSize() .then(if (blurred) Modifier.blur(18.dp) else Modifier), - contentScale = ContentScale.Crop, + contentScale = contentScale, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt index 3609fd00..d95b26bc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeSkeletonLoading.kt @@ -181,7 +181,10 @@ fun HomeSkeletonHero( } @Composable -fun HomeSkeletonRow(modifier: Modifier = Modifier) { +fun HomeSkeletonRow( + modifier: Modifier = Modifier, + showHeaderAccent: Boolean = true, +) { val brush = rememberHomeSkeletonBrush() val posterCardStyle = rememberPosterCardStyleUiState() val skeletonWidth = if (posterCardStyle.catalogLandscapeModeEnabled) { @@ -207,15 +210,17 @@ fun HomeSkeletonRow(modifier: Modifier = Modifier) { .clip(RoundedCornerShape(6.dp)) .background(brush), ) - // Accent bar - Box( - modifier = Modifier - .width(60.dp) - .height(4.dp) - .clip(RoundedCornerShape(999.dp)) - .background(brush), - ) - Spacer(modifier = Modifier.height(2.dp)) + if (showHeaderAccent) { + // Accent bar + Box( + modifier = Modifier + .width(60.dp) + .height(4.dp) + .clip(RoundedCornerShape(999.dp)) + .background(brush), + ) + Spacer(modifier = Modifier.height(2.dp)) + } // Poster row Row( horizontalArrangement = Arrangement.spacedBy(10.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 863fa3b4..af8b6410 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -1,33 +1,78 @@ package com.nuvio.app.features.library +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.LaunchedEffect 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.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryItemType +import com.nuvio.app.features.cloud.CloudLibraryRepository +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -47,17 +92,38 @@ fun LibraryScreen( onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, + onConnectCloudClick: (() -> Unit)? = null, ) { val uiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState }.collectAsStateWithLifecycle() + val cloudUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle() + val cloudSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val watchedUiState by remember { WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } + var sourceModeName by rememberSaveable { mutableStateOf(LibraryViewMode.Saved.name) } + val sourceMode = remember(sourceModeName) { + runCatching { LibraryViewMode.valueOf(sourceModeName) }.getOrDefault(LibraryViewMode.Saved) + } + var selectedProviderId by rememberSaveable { mutableStateOf(null) } + var selectedTypeName by rememberSaveable { mutableStateOf(null) } + val selectedType = remember(selectedTypeName) { + selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } + } + var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT @@ -98,6 +164,13 @@ fun LibraryScreen( } } + LaunchedEffect(sourceMode, cloudSettings.cloudLibraryEnabled, cloudSettings.providerApiKeys) { + if (sourceMode == LibraryViewMode.Cloud) { + CloudLibraryRepository.ensureLoaded() + selectedCloudItemKey = null + } + } + NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, @@ -111,90 +184,806 @@ fun LibraryScreen( .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( - title = if (isTraktSource) { + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { stringResource(Res.string.library_trakt_title) } else { stringResource(Res.string.library_title) }, modifier = Modifier.padding(horizontal = 16.dp), ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) Spacer(modifier = Modifier.height(6.dp)) } } - when { - !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { - items(3) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp)) + if (sourceMode == LibraryViewMode.Cloud) { + cloudLibraryContent( + uiState = cloudUiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + selectedCloudItemKey = selectedCloudItemKey, + onProviderSelected = { + selectedProviderId = it + selectedTypeName = null + selectedCloudItemKey = null + }, + onTypeSelected = { + selectedTypeName = it?.name + selectedCloudItemKey = null + }, + onItemSelected = { item -> + val playableFiles = item.playableFiles + when { + playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) + playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey + } + }, + onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, + onBackToItems = { selectedCloudItemKey = null }, + onRefresh = { CloudLibraryRepository.refresh() }, + onConnectCloudClick = onConnectCloudClick, + ) + } else { + when { + !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { + items(3) { + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) + } + } + + !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, + message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, + ) + } + } + } + + uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike && isTraktSource) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) + } else { + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) + }, + ) + } + } + } + + else -> { + librarySections( + sections = uiState.sections, + watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + onPosterClick = onPosterClick, + onSectionViewAllClick = onSectionViewAllClick, + onPosterLongClick = onPosterLongClick, + ) } } + } + } +} - !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { +private fun LazyListScope.cloudLibraryContent( + uiState: CloudLibraryUiState, + selectedProviderId: String?, + selectedType: CloudLibraryItemType?, + selectedCloudItemKey: String?, + onProviderSelected: (String?) -> Unit, + onTypeSelected: (CloudLibraryItemType?) -> Unit, + onItemSelected: (CloudLibraryItem) -> Unit, + onFileSelected: (CloudLibraryItem, CloudLibraryFile) -> Unit, + onBackToItems: () -> Unit, + onRefresh: () -> Unit, + onConnectCloudClick: (() -> Unit)?, +) { + when { + !uiState.isLoaded -> { + cloudLibrarySkeletonItems() + } + + !uiState.isEnabled -> { + item { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_disabled_title), + message = stringResource(Res.string.cloud_library_disabled_message), + actionLabel = stringResource(Res.string.cloud_library_disabled_action), + onActionClick = onConnectCloudClick, + ) + } + } + + !uiState.hasConnectedProvider -> { + item { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_connect_title), + message = stringResource(Res.string.cloud_library_connect_message), + actionLabel = stringResource(Res.string.cloud_library_connect_action), + onActionClick = onConnectCloudClick, + ) + } + } + + else -> { + val providerItems = uiState.items + .filter { item -> selectedProviderId == null || item.providerId == selectedProviderId } + val availableTypes = providerItems + .map { item -> item.type } + .distinct() + .sortedBy { type -> type.ordinal } + val effectiveSelectedType = selectedType?.takeIf { type -> type in availableTypes } + val filteredItems = providerItems + .filter { item -> effectiveSelectedType == null || item.type == effectiveSelectedType } + val selectedItem = filteredItems.firstOrNull { it.stableKey == selectedCloudItemKey } + + if (selectedItem != null) { item { - if (networkStatusUiState.isOfflineLike) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { + CloudLibraryFilePicker( + item = selectedItem, + onBack = onBackToItems, + onFileSelected = { file -> onFileSelected(selectedItem, file) }, + ) + } + } else { + item { + CloudLibraryToolbar( + uiState = uiState, + selectedProviderId = selectedProviderId, + selectedType = effectiveSelectedType, + availableTypes = availableTypes, + onProviderSelected = onProviderSelected, + onTypeSelected = onTypeSelected, + onRefresh = onRefresh, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + + uiState.providers + .filter { providerState -> selectedProviderId == null || providerState.providerId == selectedProviderId } + .filter { providerState -> !providerState.errorMessage.isNullOrBlank() && providerState.items.isEmpty() } + .forEach { providerState -> + item(key = "cloud-error-${providerState.providerId}") { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = stringResource(Res.string.cloud_library_load_failed, providerState.providerName), + message = providerState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = onRefresh, + ) + } + } + + if (uiState.isRefreshing && filteredItems.isEmpty()) { + cloudLibrarySkeletonItems() + } else if (filteredItems.isEmpty()) { + item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) - }, - message = uiState.errorMessage.orEmpty(), + title = stringResource(Res.string.cloud_library_empty_title), + message = stringResource(Res.string.cloud_library_empty_message), actionLabel = stringResource(Res.string.action_retry), - onActionClick = retryLibraryLoad, + onActionClick = onRefresh, + ) + } + } else { + items( + items = filteredItems, + key = { item -> item.stableKey }, + ) { item -> + CloudLibraryRow( + item = item, + onClick = { onItemSelected(item) }, ) } } } + } + } +} - uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) - }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) - }, - ) - } - } +private fun LazyListScope.cloudLibrarySkeletonItems() { + item(key = "cloud-library-skeleton-toolbar") { + CloudLibrarySkeletonToolbar( + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + items(3) { + CloudLibrarySkeletonRow() + } +} + +@Composable +private fun LibrarySourceSwitch( + selectedMode: LibraryViewMode, + onModeSelected: (LibraryViewMode) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + LibraryChip( + label = stringResource(Res.string.library_source_saved), + selected = selectedMode == LibraryViewMode.Saved, + onClick = { onModeSelected(LibraryViewMode.Saved) }, + ) + LibraryChip( + label = stringResource(Res.string.library_source_cloud), + selected = selectedMode == LibraryViewMode.Cloud, + onClick = { onModeSelected(LibraryViewMode.Cloud) }, + ) + } +} + +@Composable +private fun CloudLibraryToolbar( + uiState: CloudLibraryUiState, + selectedProviderId: String?, + selectedType: CloudLibraryItemType?, + availableTypes: List, + onProviderSelected: (String?) -> Unit, + onTypeSelected: (CloudLibraryItemType?) -> Unit, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + val providerOptions = buildList { + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_provider_all))) + addAll( + uiState.providers.map { provider -> + NuvioDropdownOption( + key = provider.providerId, + label = provider.providerName, + ) + }, + ) + } + val typeOptions = buildList { + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.cloud_library_type_all))) + addAll( + availableTypes.map { type -> + NuvioDropdownOption( + key = type.name, + label = cloudLibraryTypeLabel(type), + ) + }, + ) + } + val selectedProviderName = uiState.providers + .firstOrNull { provider -> provider.providerId == selectedProviderId } + ?.providerName + ?: stringResource(Res.string.cloud_library_provider_all) + val selectedTypeLabel = selectedType?.let { type -> cloudLibraryTypeLabel(type) } + ?: stringResource(Res.string.cloud_library_type_all) + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier + .weight(1f) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NuvioDropdownChip( + title = stringResource(Res.string.cloud_library_select_provider), + label = selectedProviderName, + selectedKey = selectedProviderId.orEmpty(), + options = providerOptions, + enabled = providerOptions.size > 1, + onSelected = { option -> + onProviderSelected(option.key.ifBlank { null }) + }, + ) + NuvioDropdownChip( + title = stringResource(Res.string.cloud_library_select_type), + label = selectedTypeLabel, + selectedKey = selectedType?.name.orEmpty(), + options = typeOptions, + enabled = typeOptions.size > 1, + onSelected = { option -> + val type = option.key + .takeIf { it.isNotBlank() } + ?.let(CloudLibraryItemType::valueOf) + onTypeSelected(type) + }, + ) } - - else -> { - librarySections( - sections = uiState.sections, - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onPosterClick, - onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = onPosterLongClick, + IconButton(onClick = onRefresh) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = stringResource(Res.string.cloud_library_refresh), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } +@Composable +private fun LibraryChip( + label: String, + selected: Boolean, + loading: Boolean = false, + error: Boolean = false, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + Surface( + modifier = Modifier + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = if (selected) colorScheme.primaryContainer else colorScheme.surfaceContainerLow, + border = if (selected) BorderStroke(1.dp, colorScheme.primary.copy(alpha = 0.45f)) else null, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (loading) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = colorScheme.primary, + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = when { + error -> colorScheme.error + selected -> colorScheme.onPrimaryContainer + else -> colorScheme.onSurfaceVariant + }, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun CloudLibraryRow( + item: CloudLibraryItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val playableCount = item.playableFiles.size + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp) + .clickable(enabled = playableCount > 0, onClick = onClick), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = cloudLibrarySubtitle(item), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = cloudLibraryStatusLine(item), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (playableCount > 0) { + IconButton(onClick = onClick) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(Res.string.action_play), + ) + } + } + } + item.progressFraction?.takeIf { it in 0f..0.999f }?.let { progress -> + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } +} + +@Composable +private fun CloudLibraryFilePicker( + item: CloudLibraryItem, + onBack: () -> Unit, + onFileSelected: (CloudLibraryFile) -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.name, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.cloud_library_file_picker_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + val files = item.playableFiles + if (files.isEmpty()) { + Column( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(Res.string.cloud_library_no_files_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.cloud_library_no_files_message), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + files.forEach { file -> + CloudLibraryFileRow( + file = file, + onClick = { onFileSelected(file) }, + ) + } + } + } + } +} + +@Composable +private fun CloudLibraryFileRow( + file: CloudLibraryFile, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.58f), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + modifier = Modifier + .padding(top = 2.dp) + .size(18.dp), + imageVector = Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + modifier = Modifier.weight(1f), + text = file.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = file.sizeBytes?.let { size -> formatCloudBytes(size) }.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource(Res.string.cloud_library_play_file), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun cloudLibrarySubtitle(item: CloudLibraryItem): String { + val fileLine = when (val playableCount = item.playableFiles.size) { + 0 -> stringResource(Res.string.cloud_library_no_playable_files) + 1 -> item.playableFiles.first().name + else -> stringResource(Res.string.cloud_library_playable_file_count, playableCount) + } + return listOf(item.providerName, cloudLibraryTypeLabel(item.type), fileLine).joinToString(" • ") +} + +@Composable +private fun cloudLibraryStatusLine(item: CloudLibraryItem): String { + val fallback = if (item.playableFiles.isEmpty()) { + stringResource(Res.string.cloud_library_no_playable_files) + } else { + stringResource(Res.string.cloud_library_status_ready) + } + return listOfNotNull( + item.status?.toDisplayStatus(), + item.sizeBytes?.let(::formatCloudBytes), + item.progressFraction?.let { "${(it * 100f).toInt()}%" }, + ).joinToString(" • ").ifBlank { fallback } +} + +@Composable +private fun cloudLibraryTypeLabel(type: CloudLibraryItemType): String = + when (type) { + CloudLibraryItemType.Torrent -> stringResource(Res.string.cloud_library_type_torrents) + CloudLibraryItemType.Usenet -> stringResource(Res.string.cloud_library_type_usenet) + CloudLibraryItemType.WebDownload -> stringResource(Res.string.cloud_library_type_web) + CloudLibraryItemType.File -> stringResource(Res.string.cloud_library_type_files) + } + +private fun formatCloudBytes(bytes: Long): String { + if (bytes <= 0L) return "0 ${localizedByteUnit("B")}" + val kib = 1024.0 + val mib = kib * 1024.0 + val gib = mib * 1024.0 + val value = bytes.toDouble() + return when { + value >= gib -> "${((value / gib) * 10.0).toInt() / 10.0} ${localizedByteUnit("GB")}" + value >= mib -> "${((value / mib) * 10.0).toInt() / 10.0} ${localizedByteUnit("MB")}" + value >= kib -> "${((value / kib) * 10.0).toInt() / 10.0} ${localizedByteUnit("KB")}" + else -> "$bytes ${localizedByteUnit("B")}" + } +} + +private fun String.toDisplayStatus(): String = + replace('_', ' ') + .lowercase() + .replaceFirstChar { it.titlecase() } + +@Composable +private fun CloudLibrarySkeletonToolbar( + modifier: Modifier = Modifier, +) { + val brush = rememberCloudLibrarySkeletonBrush() + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CloudSkeletonBlock(brush = brush, width = 112.dp, height = 36.dp, cornerRadius = 12.dp) + CloudSkeletonBlock(brush = brush, width = 92.dp, height = 36.dp, cornerRadius = 12.dp) + } + } +} + +@Composable +private fun CloudLibrarySkeletonRow( + modifier: Modifier = Modifier, +) { + val brush = rememberCloudLibrarySkeletonBrush() + Surface( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.74f), + height = 18.dp, + cornerRadius = 6.dp, + ) + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.9f), + height = 14.dp, + cornerRadius = 6.dp, + ) + CloudSkeletonBlock( + brush = brush, + modifier = Modifier.fillMaxWidth(0.52f), + height = 12.dp, + cornerRadius = 6.dp, + ) + } + CloudSkeletonBlock(brush = brush, width = 48.dp, height = 48.dp, cornerRadius = 24.dp) + } + } + } +} + +@Composable +private fun rememberCloudLibrarySkeletonBrush(): Brush { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f), + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.9f), + ) + val transition = rememberInfiniteTransition() + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1200, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + ) + return Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 200f, 0f), + end = Offset(translateAnim, 0f), + ) +} + +@Composable +private fun CloudSkeletonBlock( + brush: Brush, + modifier: Modifier = Modifier, + width: Dp? = null, + height: Dp, + cornerRadius: Dp, +) { + val sizeModifier = if (width != null) { + modifier.size(width = width, height = height) + } else { + modifier.height(height) + } + Box( + modifier = sizeModifier + .clip(RoundedCornerShape(cornerRadius)) + .background(brush), + ) +} + +private enum class LibraryViewMode { + Saved, + Cloud, +} + private fun LazyListScope.librarySections( sections: List, watchedKeys: Set, + showHeaderAccent: Boolean, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, @@ -209,6 +998,7 @@ private fun LazyListScope.librarySections( entries = previewItems, headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), + showHeaderAccent = showHeaderAccent, onViewAllClick = if (section.items.size > LIBRARY_SECTION_PREVIEW_LIMIT) { onSectionViewAllClick?.let { { it(section) } } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 69eb462e..02f88cc4 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 @@ -57,10 +57,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState @@ -460,6 +463,10 @@ private fun EpisodeStreamsSubView( onDismiss: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState @@ -597,10 +604,11 @@ private fun EpisodeStreamsSubView( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> EpisodeSourceStreamRow( stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), onClick = { onStreamSelected(stream, episode) }, ) } @@ -613,6 +621,7 @@ private fun EpisodeStreamsSubView( @Composable private fun EpisodeSourceStreamRow( stream: StreamItem, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -622,7 +631,7 @@ private fun EpisodeSourceStreamRow( .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) .background(colorScheme.surfaceVariant.copy(alpha = 0.35f)) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fcfcadd1..27c0036d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -38,6 +38,11 @@ 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.DebridSettingsRepository +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 @@ -67,6 +72,7 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.isIos +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest @@ -857,8 +863,56 @@ fun PlayerScreen( playerController?.seekTo(targetPositionMs) } + fun resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, + ): Boolean { + if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) 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) { - val url = stream.directPlaybackUrl ?: return + 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.playableDirectUrl ?: return if (url == activeSourceUrl) return val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) flushWatchProgress() @@ -899,7 +953,27 @@ fun PlayerScreen( } fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { - val url = stream.directPlaybackUrl ?: return + 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.playableDirectUrl ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false @@ -1094,12 +1168,30 @@ fun PlayerScreen( val installedAddonNames = AddonRepository.uiState.value.addons .map { it.displayTitle } .toSet() + val debridSettings = DebridSettingsRepository.snapshot() val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds - val isUnlimitedTimeout = timeoutSeconds == Int.MAX_VALUE var autoSelectTriggered = false var timeoutElapsed = false var selectedStream: StreamItem? = null + val autoSelectSettled = CompletableDeferred() + + fun settleAutoSelect() { + if (!autoSelectSettled.isCompleted) { + autoSelectSettled.complete(Unit) + } + } + + fun selectStream(stream: StreamItem) { + autoSelectTriggered = true + selectedStream = stream + settleAutoSelect() + } + + fun finishWithoutSelection() { + autoSelectTriggered = true + settleAutoSelect() + } // Full select: tries binge group first, then falls back to mode-based selection fun trySelectStream(streams: List): StreamItem? { @@ -1114,6 +1206,8 @@ fun PlayerScreen( preferredBingeGroup = preferredBingeGroup, preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, bingeGroupOnly = bingeGroupOnlyManualMode, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) } @@ -1131,6 +1225,8 @@ fun PlayerScreen( preferredBingeGroup = preferredBingeGroup, preferBingeGroupInSelection = true, bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) } @@ -1145,38 +1241,35 @@ fun PlayerScreen( // Already resolved } else if (timeoutElapsed) { // Timeout elapsed: full select (binge group + fallback to mode) - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - autoSelectTriggered = true - selectedStream = candidate - } - } - } else { - // Before timeout: eagerly check binge group only - if (allStreams.isNotEmpty()) { - val earlyMatch = tryBingeGroupOnly(allStreams) - if (earlyMatch != null) { - autoSelectTriggered = true - selectedStream = earlyMatch - } - } - } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } else { + // Before timeout: eagerly check binge group only + if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + selectStream(earlyMatch) + } + } + } // If all addons finished loading and no match yet, do a final full select if (!autoSelectTriggered && !state.isAnyLoading) { - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - autoSelectTriggered = true - selectedStream = candidate - } - } - if (!autoSelectTriggered) { - autoSelectTriggered = true - } - return@collectLatest - } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + if (!autoSelectTriggered) { + finishWithoutSelection() + } + return@collectLatest + } if (autoSelectTriggered) return@collectLatest } @@ -1192,51 +1285,56 @@ fun PlayerScreen( timeoutElapsed = true if (!autoSelectTriggered) { val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - autoSelectTriggered = true - selectedStream = candidate - } - } - } - if (selectedStream != null) { - innerJob.cancel() - } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { - // Streams arrived but no match after full select — don't wait further - innerJob.cancel() - autoSelectTriggered = true - } else { - // No addon responded yet — wait with hard ceiling - val completed = withTimeoutOrNull(timeoutMs) { innerJob.join() } - if (completed == null) { - innerJob.cancel() - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - autoSelectTriggered = true - } - } - } - } else { - // Instant (0) or unlimited: timeoutElapsed immediately so each - // addon response triggers a full select attempt in the collect. - timeoutElapsed = true - val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS - val completed = withTimeoutOrNull(hardTimeout) { innerJob.join() } - if (completed == null) { - innerJob.cancel() - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - autoSelectTriggered = true - } - } - } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + // Streams arrived but no match after full select — don't wait further + innerJob.cancel() + finishWithoutSelection() + } else { + // No addon responded yet — wait with hard ceiling + val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null) { + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } + } else { + // Instant (0) or unlimited: timeoutElapsed immediately so each + // addon response triggers a full select attempt in the collect. + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + trySelectStream(allStreams)?.let(::selectStream) + } + } + val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS + val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null) { + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } // Handle result nextEpisodeAutoPlaySearching = false diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index 9e64a911..bf68cb5c 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 @@ -36,6 +36,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,9 +48,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.i18n.localizedByteUnit +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback import kotlin.math.round import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -67,6 +71,10 @@ fun PlayerSourcesPanel( modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() AnimatedVisibility( visible = visible, @@ -203,7 +211,7 @@ fun PlayerSourcesPanel( ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" }, + key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> val isCurrent = isCurrentStream( stream = stream, @@ -213,6 +221,7 @@ fun PlayerSourcesPanel( SourceStreamRow( stream = stream, isCurrent = isCurrent, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), onClick = { onStreamSelected(stream) }, ) } @@ -230,6 +239,7 @@ fun PlayerSourcesPanel( private fun SourceStreamRow( stream: StreamItem, isCurrent: Boolean, + enabled: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -256,7 +266,7 @@ private fun SourceStreamRow( Modifier }, ) - .clickable(onClick = onClick) + .clickable(enabled = enabled, onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -452,9 +462,9 @@ private fun isCurrentStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.directPlaybackUrl == currentUrl) return true + if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.directPlaybackUrl == currentUrl + stream.playableDirectUrl == currentUrl ) return true return false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 24ea2129..12b59bb4 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,11 +5,16 @@ 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.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginRuntimeResult import com.nuvio.app.features.plugins.PluginScraper +import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.AddonStreamGroup import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem @@ -154,6 +159,10 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons + val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() + PlayerSettingsRepository.ensureLoaded() + val playerSettings = PlayerSettingsRepository.uiState.value + val debridSettings = DebridSettingsRepository.snapshot() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) @@ -196,8 +205,13 @@ object PlayerStreamsRepository { } val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -211,14 +225,72 @@ object PlayerStreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } stateFlow.value = StreamsUiState( groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, ) val job = scope.launch { - val addonJobs = streamAddons.map { addon -> + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } + val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val debridAvailabilityJobs = mutableListOf() + fun emptyStateReason(groups: List, anyLoading: Boolean) = + if (!anyLoading && groups.all { it.streams.isEmpty() }) { + if (groups.all { !it.error.isNullOrBlank() }) { + com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed + } else { + com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound + } + } else { + null + } + + fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = + DebridStreamPresentation.apply( + groups = listOf(group), + settings = debridSettings, + ).firstOrNull() ?: group + + fun publishStreamGroup(group: AddonStreamGroup) { + stateFlow.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { currentGroup -> + if (currentGroup.addonId == group.addonId) group else currentGroup + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = emptyStateReason(updated, anyLoading), + ) + } + } + + fun launchDebridAvailability(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = LocalDebridAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + publishStreamGroup(checkingGroup) + + val availabilityJob = launch { + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + publishStreamGroup(presentDebridGroup(availabilityGroup)) + } + debridAvailabilityJobs += availabilityJob + } + + val addonJobs = pendingStreamAddons.map { addon -> async { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -287,23 +359,32 @@ object PlayerStreamsRepository { } repeat(jobs.size) { val result = completions.receive() - stateFlow.update { current -> - val updated = StreamAutoPlaySelector.orderAddonStreams( - groups = current.groups.map { g -> if (g.addonId == result.addonId) result else g }, - installedOrder = installedAddonOrder, - ) - val anyLoading = updated.any { it.isLoading } - current.copy( - groups = updated, - isAnyLoading = anyLoading, - emptyStateReason = if (!anyLoading && updated.all { it.streams.isEmpty() }) { - if (updated.all { !it.error.isNullOrBlank() }) { - com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed - } else { - com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound - } - } else null, - ) + publishStreamGroup(result) + launchDebridAvailability(result) + } + for (availabilityJob in debridAvailabilityJobs) { + availabilityJob.join() + } + launch { + DirectDebridStreamPreparer.prepare( + streams = stateFlow.value.groups + .filter { it.addonId in installedAddonIds } + .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, + eligibleGroupIds = installedAddonIds, + ), + ) + } } } completions.close() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 3f1901f3..2cf53cba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -2,7 +2,6 @@ package com.nuvio.app.features.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,31 +12,16 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize 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.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SheetState import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,12 +33,9 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioBottomSheetActionRow -import com.nuvio.app.core.ui.NuvioBottomSheetDivider -import com.nuvio.app.core.ui.NuvioModalBottomSheet -import com.nuvio.app.core.ui.dismissNuvioBottomSheet -import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable @@ -62,7 +43,6 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.watching.application.WatchingState -import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -174,19 +154,19 @@ private fun DiscoverFilterRow( modifier = modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_type), label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type), selectedKey = state.selectedType, - options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) }, + options = state.typeOptions.map { NuvioDropdownOption(key = it, label = it.displayTypeLabel()) }, enabled = state.typeOptions.isNotEmpty(), onSelected = { onTypeSelected(it.key) }, ) - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_catalog), label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog), selectedKey = state.selectedCatalogKey, - options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) }, + options = state.catalogOptions.map { option -> NuvioDropdownOption(key = option.key, label = option.catalogName) }, enabled = state.catalogOptions.isNotEmpty(), onSelected = { onCatalogSelected(it.key) }, ) @@ -194,11 +174,11 @@ private fun DiscoverFilterRow( val selectedCatalog = state.selectedCatalog val genreOptions = buildList { if (selectedCatalog?.genreRequired != true) { - add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres))) + add(NuvioDropdownOption(key = "", label = stringResource(Res.string.discover_all_genres))) } - addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) }) + addAll(state.genreOptions.map { genre -> NuvioDropdownOption(key = genre, label = genre) }) } - DiscoverDropdownChip( + NuvioDropdownChip( title = stringResource(Res.string.discover_select_genre), label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres), selectedKey = state.selectedGenre ?: "", @@ -211,132 +191,6 @@ private fun DiscoverFilterRow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DiscoverDropdownChip( - title: String, - label: String, - selectedKey: String?, - options: List, - enabled: Boolean, - onSelected: (DiscoverOptionItem) -> Unit, -) { - var isSheetVisible by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val coroutineScope = rememberCoroutineScope() - - Row( - modifier = Modifier - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) - .then( - if (enabled) { - Modifier.clickable { isSheetVisible = true } - } else { - Modifier - }, - ) - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - style = MaterialTheme.typography.labelLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Icon( - imageVector = Icons.Rounded.KeyboardArrowDown, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, - ) - } - - if (isSheetVisible) { - DiscoverOptionsSheet( - title = title, - options = options, - selectedKey = selectedKey, - sheetState = sheetState, - onDismiss = { - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = { isSheetVisible = false }, - ) - } - }, - onSelected = { option -> - onSelected(option) - coroutineScope.launch { - dismissNuvioBottomSheet( - sheetState = sheetState, - onDismiss = { isSheetVisible = false }, - ) - } - }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DiscoverOptionsSheet( - title: String, - options: List, - selectedKey: String?, - sheetState: SheetState, - onDismiss: () -> Unit, - onSelected: (DiscoverOptionItem) -> Unit, -) { - NuvioModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(16.dp)), - ) { - Text( - text = title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - NuvioBottomSheetDivider() - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 420.dp), - ) { - itemsIndexed(options) { index, option -> - NuvioBottomSheetActionRow( - title = option.label, - onClick = { onSelected(option) }, - trailingContent = { - if (option.key == selectedKey) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } - }, - ) - if (index < options.lastIndex) { - NuvioBottomSheetDivider() - } - } - } - } - } -} - @Composable private fun DiscoverGridRow( items: List, @@ -518,11 +372,6 @@ private fun DiscoverEmptyStateCard( ) } -private data class DiscoverOptionItem( - val key: String, - val label: String, -) - @Composable private fun String.displayTypeLabel(): String = when (lowercase()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index adcaa7e6..8aba024f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -104,7 +104,10 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() - val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -305,13 +308,19 @@ fun SearchScreen( when { isWaitingForSearch -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } uiState.isLoading && uiState.sections.isEmpty() -> { items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } @@ -351,7 +360,10 @@ fun SearchScreen( } if (uiState.isLoading) { item(key = "search_loading_more") { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = homeSectionPadding), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt new file mode 100644 index 00000000..3ea86ce7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -0,0 +1,1670 @@ +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.CircularProgressIndicator +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.LaunchedEffect +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +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.DebridDeviceAuthorization +import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviderApis +import com.nuvio.app.features.debrid.DebridProviderAuthMethod +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.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay +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_retry +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_cloud_library +import nuvio.composeapp.generated.resources.settings_debrid_cloud_library_description +import nuvio.composeapp.generated.resources.settings_debrid_connected +import nuvio.composeapp.generated.resources.settings_debrid_connect_provider +import nuvio.composeapp.generated.resources.settings_debrid_disconnect_provider +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_code_copied +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_connected +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_expired +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_failed +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_instructions +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_missing_configuration +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_open +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_starting +import nuvio.composeapp.generated.resources.settings_debrid_device_auth_waiting +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_disconnect +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_description +import nuvio.composeapp.generated.resources.settings_debrid_provider_device_description +import nuvio.composeapp.generated.resources.settings_debrid_resolve_with +import nuvio.composeapp.generated.resources.settings_debrid_resolve_with_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 + +private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services" + +internal fun LazyListScope.debridSettingsContent( + isTablet: Boolean, + settings: DebridSettings, +) { + item { + var showResolverProviderDialog by rememberSaveable { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + val resolverProviders = settings.resolverServices.map { it.provider } + val activeResolverProvider = settings.activeResolverCredential?.provider + 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) + DebridPreferenceRow( + isTablet = isTablet, + title = "Learn more", + description = "Cloud Library, connected accounts, and playable-link preparation.", + value = "Open", + enabled = true, + onClick = { runCatching { uriHandler.openUri(CLOUD_SERVICES_FAQ_URL) } }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_cloud_library), + description = stringResource(Res.string.settings_debrid_cloud_library_description), + checked = settings.canUseCloudLibrary, + enabled = settings.hasCloudLibraryProvider, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setCloudLibraryEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_debrid_enable), + description = stringResource(Res.string.settings_debrid_enable_description), + checked = settings.canResolvePlayableLinks, + enabled = settings.hasResolverProvider, + isTablet = isTablet, + onCheckedChange = DebridSettingsRepository::setLinkResolvingEnabled, + ) + if (settings.canResolvePlayableLinks && resolverProviders.size > 1 && activeResolverProvider != null) { + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_debrid_resolve_with), + description = stringResource(Res.string.settings_debrid_resolve_with_description), + value = activeResolverProvider.displayName, + enabled = true, + onClick = { showResolverProviderDialog = true }, + ) + } + if (!settings.hasResolverProvider) { + SettingsGroupDivider(isTablet = isTablet) + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_add_key_first), + ) + } + } + } + + if (showResolverProviderDialog && resolverProviders.size > 1 && activeResolverProvider != null) { + DebridSingleChoiceDialog( + title = stringResource(Res.string.settings_debrid_resolve_with), + selectedValue = activeResolverProvider, + options = resolverProviders, + label = { provider -> provider.displayName }, + onSelected = { provider -> DebridSettingsRepository.setPreferredResolverProviderId(provider.id) }, + onDismiss = { showResolverProviderDialog = false }, + ) + } + } + + item { + var activeApiKeyProviderId by rememberSaveable { mutableStateOf(null) } + var activeDeviceAuthProviderId by rememberSaveable { mutableStateOf(null) } + val providers = remember { DebridProviders.visible() } + val notSetLabel = stringResource(Res.string.settings_debrid_not_set) + val connectedLabel = stringResource(Res.string.settings_debrid_connected) + + SettingsSection( + title = stringResource(Res.string.settings_debrid_section_providers), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + providers.forEachIndexed { index, provider -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + DebridPreferenceRow( + isTablet = isTablet, + title = provider.displayName, + description = if (provider.authMethod == DebridProviderAuthMethod.DeviceCode) { + stringResource(Res.string.settings_debrid_provider_device_description, provider.displayName) + } else { + stringResource(Res.string.settings_debrid_provider_description, provider.displayName) + }, + value = providerCredentialStatus( + provider = provider, + credential = settings.apiKeyFor(provider.id), + notSetLabel = notSetLabel, + connectedLabel = connectedLabel, + ), + enabled = true, + onClick = { + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> activeDeviceAuthProviderId = provider.id + DebridProviderAuthMethod.ApiKey -> activeApiKeyProviderId = provider.id + } + }, + ) + } + } + } + + activeDeviceAuthProviderId + ?.let(DebridProviders::byId) + ?.let { provider -> + DebridDeviceAuthDialog( + provider = provider, + currentValue = settings.apiKeyFor(provider.id), + onConnected = { token -> DebridSettingsRepository.setProviderApiKey(provider.id, token) }, + onDisconnect = { DebridSettingsRepository.setProviderApiKey(provider.id, "") }, + onDismiss = { activeDeviceAuthProviderId = null }, + ) + } + + activeApiKeyProviderId + ?.let(DebridProviders::byId) + ?.let { provider -> + DebridApiKeyDialog( + providerId = provider.id, + title = stringResource(Res.string.settings_debrid_dialog_title, provider.displayName), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle, provider.displayName), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder, provider.displayName), + currentValue = settings.apiKeyFor(provider.id), + onSave = { apiKey -> DebridSettingsRepository.setProviderApiKey(provider.id, apiKey) }, + onDismiss = { activeApiKeyProviderId = null }, + ) + } + } + + if (!settings.canResolvePlayableLinks) return + + item { + var showPrepareCountDialog by rememberSaveable { mutableStateOf(false) } + val prepareLimit = settings.instantPlaybackPreparationLimit + val prepareEnabled = 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.canResolvePlayableLinks, + 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 = "Result Management", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many results appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.canResolvePlayableLinks, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort results", + description = "Choose how results are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.canResolvePlayableLinks, + 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.canResolvePlayableLinks, + 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.canResolvePlayableLinks, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter results by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.canResolvePlayableLinks, + 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.canResolvePlayableLinks, + 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( + value = settings.streamNameTemplate, + defaultValue = DebridStreamFormatterDefaults.NAME_TEMPLATE, + ), + enabled = settings.canResolvePlayableLinks, + 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( + value = settings.streamDescriptionTemplate, + defaultValue = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + ), + enabled = settings.canResolvePlayableLinks, + 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.canResolvePlayableLinks, + 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, defaultValue: String): String { + if (value.trim().isBlank() || value.trim() == defaultValue.trim()) return "Default format" + val firstLine = value + .lineSequence() + .map { it.trim() } + .firstOrNull { it.isNotBlank() } + ?: return "Default format" + 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 results", + 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 results" else "$value results" + +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 qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected 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 results with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide results 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 DebridDeviceAuthDialog( + provider: DebridProvider, + currentValue: String, + onConnected: (String) -> Unit, + onDisconnect: () -> Unit, + onDismiss: () -> Unit, +) { + val uriHandler = LocalUriHandler.current + val clipboardManager = LocalClipboardManager.current + val isConnected = currentValue.isNotBlank() + var restartNonce by rememberSaveable(provider.id) { mutableStateOf(0) } + var session by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + var isStarting by remember(provider.id, restartNonce, isConnected) { mutableStateOf(!isConnected) } + var isPolling by remember(provider.id, restartNonce, isConnected) { mutableStateOf(false) } + var statusMessage by remember(provider.id, restartNonce, isConnected) { mutableStateOf(null) } + + val startingMessage = stringResource(Res.string.settings_debrid_device_auth_starting) + val waitingMessage = stringResource(Res.string.settings_debrid_device_auth_waiting) + val failedMessage = stringResource(Res.string.settings_debrid_device_auth_failed) + val missingConfigurationMessage = stringResource(Res.string.settings_debrid_device_auth_missing_configuration) + val expiredMessage = stringResource(Res.string.settings_debrid_device_auth_expired) + val codeCopiedMessage = stringResource(Res.string.settings_debrid_device_auth_code_copied) + + LaunchedEffect(provider.id, restartNonce, isConnected) { + if (isConnected) { + isStarting = false + isPolling = false + statusMessage = null + session = null + return@LaunchedEffect + } + isStarting = true + isPolling = false + statusMessage = null + val startResult = runCatching { + DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") + }.onFailure { error -> + if (error is CancellationException) throw error + } + session = startResult.getOrNull() + isStarting = false + statusMessage = if (session == null) { + startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } + ?.let { missingConfigurationMessage } + ?: failedMessage + } else { + waitingMessage + } + } + + LaunchedEffect(session?.deviceCode, restartNonce, isConnected) { + if (isConnected) return@LaunchedEffect + val activeSession = session ?: return@LaunchedEffect + while (true) { + delay(activeSession.intervalSeconds.coerceAtLeast(1) * 1_000L) + isPolling = true + val result = runCatching { + DebridProviderApis.apiFor(provider.id) + ?.redeemDeviceAuthorization(activeSession.deviceCode) + ?: DebridDeviceAuthorizationTokenResult.Unsupported + }.getOrElse { error -> + if (error is CancellationException) throw error + if (error.isCancelledHttpRequest()) { + DebridDeviceAuthorizationTokenResult.Pending + } else { + DebridDeviceAuthorizationTokenResult.Failed(null) + } + } + isPolling = false + when (result) { + is DebridDeviceAuthorizationTokenResult.Authorized -> { + onConnected(result.accessToken) + onDismiss() + return@LaunchedEffect + } + + DebridDeviceAuthorizationTokenResult.Pending -> { + statusMessage = waitingMessage + } + + DebridDeviceAuthorizationTokenResult.Expired -> { + statusMessage = expiredMessage + return@LaunchedEffect + } + + is DebridDeviceAuthorizationTokenResult.Failed -> { + statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) + return@LaunchedEffect + } + + DebridDeviceAuthorizationTokenResult.Unsupported -> { + statusMessage = failedMessage + return@LaunchedEffect + } + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface( + title = stringResource( + if (isConnected) Res.string.settings_debrid_disconnect_provider else Res.string.settings_debrid_connect_provider, + provider.displayName, + ), + ) { + if (isConnected) { + Text( + text = stringResource(Res.string.settings_debrid_device_auth_connected, provider.displayName), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else if (isStarting) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(18.dp)) + Text( + text = startingMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + session?.let { activeSession -> + Text( + text = stringResource(Res.string.settings_debrid_device_auth_instructions), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + clipboardManager.setText(AnnotatedString(activeSession.userCode)) + statusMessage = codeCopiedMessage + }, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + ) { + Column( + modifier = Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = activeSession.userCode, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = activeSession.friendlyVerificationUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + statusMessage?.let { message -> + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isPolling) { + CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(16.dp)) + } + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = if (message == failedMessage || message == expiredMessage || message == missingConfigurationMessage) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + if (isConnected) { + Button( + onClick = { + onDisconnect() + onDismiss() + }, + ) { + Text(stringResource(Res.string.settings_debrid_disconnect)) + } + } + if (!isConnected && !isStarting && session == null) { + TextButton(onClick = { restartNonce += 1 }) { + Text(stringResource(Res.string.action_retry)) + } + } + if (!isConnected) session?.let { activeSession -> + Button( + onClick = { + runCatching { uriHandler.openUri(activeSession.verificationUrl) } + .onFailure { statusMessage = failedMessage } + }, + enabled = !isStarting, + ) { + Text(stringResource(Res.string.settings_debrid_device_auth_open)) + } + } + } + } + } +} + +private fun Throwable.isCancelledHttpRequest(): Boolean { + val text = listOfNotNull(message, toString()) + .joinToString(" ") + .lowercase() + return "code=-999" in text || + ("nsurlerrordomain" in text && ("cancelled" in text || "canceled" in text)) +} + +private fun String?.toDeviceAuthStatusMessage(fallback: String): String { + val value = this?.trim()?.takeIf { it.isNotBlank() } ?: return fallback + val lower = value.lowercase() + return if ( + value.length > 180 || + "exception in http request" in lower || + "nsurlerrordomain" in lower || + "userinfo=" in lower + ) { + fallback + } else { + value + } +} + +@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)}" +} + +private fun providerCredentialStatus( + provider: DebridProvider, + credential: String, + notSetLabel: String, + connectedLabel: String, +): String = + when (provider.authMethod) { + DebridProviderAuthMethod.DeviceCode -> if (credential.isBlank()) notSetLabel else connectedLabel + DebridProviderAuthMethod.ApiKey -> maskDebridApiKey(credential, notSetLabel) + } + +@Composable +private fun DebridInfoRow( + isTablet: Boolean, + text: String, +) { + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index 7602c3e2..a4999c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,10 +1,14 @@ package com.nuvio.app.features.settings +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudQueue import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_debrid_description import nuvio.composeapp.generated.resources.settings_integrations_section_title import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description import org.jetbrains.compose.resources.stringResource @@ -13,6 +17,7 @@ internal fun LazyListScope.integrationsContent( isTablet: Boolean, onTmdbClick: () -> Unit, onMdbListClick: () -> Unit, + onDebridClick: () -> Unit, ) { item { SettingsSection( @@ -35,6 +40,14 @@ internal fun LazyListScope.integrationsContent( isTablet = isTablet, onClick = onMdbListClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_debrid), + description = stringResource(Res.string.settings_integrations_debrid_description), + icon = Icons.Rounded.CloudQueue, + isTablet = isTablet, + onClick = onDebridClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index d030a785..a6eb2a40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -13,6 +13,7 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_addons import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_debrid import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching import nuvio.composeapp.generated.resources.compose_settings_page_homescreen import nuvio.composeapp.generated.resources.compose_settings_page_integrations @@ -129,6 +130,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Integrations, ), + Debrid( + titleRes = Res.string.compose_settings_page_debrid, + category = SettingsCategory.General, + parentPage = Integrations, + ), TraktAuthentication( titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 08864811..fb85bc73 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -59,6 +59,8 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -92,6 +94,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L fun SettingsScreen( modifier: Modifier = Modifier, rootActionRequests: Flow = emptyFlow(), + requestedPageName: String? = null, + onRequestedPageConsumed: () -> Unit = {}, rootActionsEnabled: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, @@ -132,6 +136,10 @@ fun SettingsScreen( MdbListSettingsRepository.ensureLoaded() MdbListSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val traktAuthUiState by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState @@ -215,6 +223,15 @@ fun SettingsScreen( } } + LaunchedEffect(requestedPageName, rootActionsEnabled) { + val targetPage = requestedPageName + ?.let { runCatching { SettingsPage.valueOf(it) }.getOrNull() } + ?: return@LaunchedEffect + if (!rootActionsEnabled) return@LaunchedEffect + currentPage = targetPage.name + onRequestedPageConsumed() + } + PlatformBackHandler( enabled = rootActionsEnabled && previousPage != null, onBack = { previousPage?.let { currentPage = it.name } }, @@ -251,6 +268,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -299,6 +317,7 @@ fun SettingsScreen( episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, + debridSettings = debridSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, @@ -357,6 +376,7 @@ private fun MobileSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -571,6 +591,7 @@ private fun MobileSettingsScreen( isTablet = false, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = false, @@ -580,6 +601,10 @@ private fun MobileSettingsScreen( isTablet = false, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = false, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = false, uiState = traktAuthUiState, @@ -665,6 +690,7 @@ private fun TabletSettingsScreen( episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, + debridSettings: DebridSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, @@ -937,6 +963,7 @@ private fun TabletSettingsScreen( isTablet = true, onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + onDebridClick = { onPageChange(SettingsPage.Debrid) }, ) SettingsPage.TmdbEnrichment -> tmdbSettingsContent( isTablet = true, @@ -946,6 +973,10 @@ private fun TabletSettingsScreen( isTablet = true, settings = mdbListSettings, ) + SettingsPage.Debrid -> debridSettingsContent( + isTablet = true, + settings = debridSettings, + ) SettingsPage.TraktAuthentication -> traktSettingsContent( isTablet = true, uiState = traktAuthUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt new file mode 100644 index 00000000..8c731baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt @@ -0,0 +1,307 @@ +package com.nuvio.app.features.streams + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.AddonManifest +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.buildAddonResourceUrl +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.debrid.DebridSettings +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation +import com.nuvio.app.features.debrid.DirectDebridStreamPreparer +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService +import com.nuvio.app.features.player.PlayerSettingsRepository +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.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L + +object AddonStreamWarmupRepository { + private val log = Logger.withTag("AddonStreamWarmup") + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val cache = mutableMapOf() + private val inFlight = mutableMapOf>>() + + fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return + scope.launch { + runCatching { fetchWarmup(key) } + .onFailure { error -> + if (error is CancellationException) throw error + log.d(error) { "Addon stream warmup failed" } + } + } + } + + fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List? { + val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null + if (!mutex.tryLock()) return null + return try { + cachedGroupsLocked(key) + } finally { + mutex.unlock() + } + } + + private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List { + cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it } + + var ownsFetch = false + val newFetch = scope.async(start = CoroutineStart.LAZY) { + fetchWarmupUncached(key) + } + val activeFetch = mutex.withLock { + cachedGroupsLocked(key)?.let { cached -> + return@withLock null to cached + } + val existing = inFlight[key] + if (existing != null) { + existing to null + } else { + inFlight[key] = newFetch + ownsFetch = true + newFetch to null + } + } + activeFetch.second?.let { + newFetch.cancel() + return it + } + val deferred = activeFetch.first ?: return emptyList() + if (!ownsFetch) newFetch.cancel() + if (ownsFetch) deferred.start() + + return try { + val result = deferred.await() + val cacheableGroups = result.filter { it.streams.isNotEmpty() } + if (ownsFetch && cacheableGroups.isNotEmpty()) { + mutex.withLock { + cache[key] = CachedAddonStreamWarmup( + groups = cacheableGroups, + createdAtMs = epochMs(), + ) + } + } + result + } finally { + if (ownsFetch) { + mutex.withLock { + if (inFlight[key] === deferred) { + inFlight.remove(key) + } + } + } + } + } + + private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List { + val targets = key.addonTargets + if (targets.isEmpty()) return emptyList() + + val addonIds = targets.map { it.addonId }.toSet() + val orderedGroups = coroutineScope { + targets.map { target -> + async { + val group = fetchAddonStreams( + target = target, + type = key.type, + videoId = key.videoId, + ) + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = LocalDebridAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + DebridStreamPresentation.apply( + groups = listOf(availabilityGroup), + settings = key.settings, + ).firstOrNull() ?: availabilityGroup + } + }.awaitAll() + }.let { groups -> + StreamAutoPlaySelector.orderAddonStreams( + groups = groups, + installedOrder = targets.map { it.addonName }, + ) + } + + var preparedGroups = orderedGroups + + PlayerSettingsRepository.ensureLoaded() + DirectDebridStreamPreparer.prepare( + streams = preparedGroups.flatMap { it.streams }, + season = key.season, + episode = key.episode, + playerSettings = PlayerSettingsRepository.uiState.value, + installedAddonNames = targets.map { it.addonName }.toSet(), + ) { original, prepared -> + preparedGroups = DirectDebridStreamPreparer.replacePreparedStream( + groups = preparedGroups, + original = original, + prepared = prepared, + eligibleGroupIds = addonIds, + ) + } + + return preparedGroups + } + + private suspend fun fetchAddonStreams( + target: AddonStreamWarmupTarget, + type: String, + videoId: String, + ): AddonStreamGroup { + val url = buildAddonResourceUrl( + manifestUrl = target.manifest.transportUrl, + resource = "stream", + type = type, + id = videoId, + ) + return runCatchingUnlessCancelled { + val payload = httpGetText(url) + StreamParser.parse( + payload = payload, + addonName = target.addonName, + addonId = target.addonId, + ) + }.fold( + onSuccess = { streams -> + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = streams, + isLoading = false, + ) + }, + onFailure = { error -> + log.d(error) { "Failed to warm addon stream target ${target.addonName}" } + AddonStreamGroup( + addonName = target.addonName, + addonId = target.addonId, + streams = emptyList(), + isLoading = false, + error = error.message, + ) + }, + ) + } + + private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? { + val normalizedType = type.trim().lowercase() + val normalizedVideoId = videoId.trim() + if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null + + DebridSettingsRepository.ensureLoaded() + val settings = DebridSettingsRepository.snapshot() + if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null + + AddonRepository.initialize() + val addonTargets = AddonRepository.uiState.value.addons + .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } + if (addonTargets.isEmpty()) return null + + return AddonStreamWarmupKey( + type = normalizedType, + videoId = normalizedVideoId, + season = season, + episode = episode, + addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, + settingsFingerprint = settings.warmupFingerprint(), + settings = settings, + addonTargets = addonTargets, + ) + } + + private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List? { + val cached = cache[key] ?: return null + val age = epochMs() - cached.createdAtMs + return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) { + cached.groups + } else { + cache.remove(key) + null + } + } +} + +private data class AddonStreamWarmupKey( + val type: String, + val videoId: String, + val season: Int?, + val episode: Int?, + val addonFingerprint: String, + val settingsFingerprint: String, + val settings: DebridSettings, + val addonTargets: List, +) + +private data class AddonStreamWarmupTarget( + val addonName: String, + val addonId: String, + val manifest: AddonManifest, + val fingerprint: String, +) + +private data class CachedAddonStreamWarmup( + val groups: List, + val createdAtMs: Long, +) + +private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? { + val manifest = manifest ?: return null + val supportsRequestedStream = manifest.resources.any { resource -> + resource.name == "stream" && + resource.types.contains(type) && + (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) }) + } + if (!supportsRequestedStream) return null + + val addonName = displayTitle.ifBlank { manifest.name } + return AddonStreamWarmupTarget( + addonName = addonName, + addonId = "addon:${manifest.id}:$manifestUrl", + manifest = manifest, + fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName", + ) +} + +private fun DebridSettings.warmupFingerprint(): String = + listOf( + enabled, + torboxApiKey, + instantPlaybackPreparationLimit, + streamMaxResults, + streamSortMode, + streamMinimumQuality, + streamDolbyVisionFilter, + streamHdrFilter, + streamCodecFilter, + streamPreferences, + streamNameTemplate, + streamDescriptionTemplate, + ).joinToString("|") + +private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 46d38159..00d9856f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -15,15 +15,19 @@ object StreamAutoPlaySelector { } } - if (installedOrder.isEmpty()) return groups + val (directDebridEntries, remainingEntries) = groups.partition { group -> + group.addonId.startsWith("debrid:") || + group.streams.any { stream -> stream.isAddonDebridCandidate && stream.isDirectDebridStream } + } + if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries - val (addonEntries, pluginEntries) = groups.partition { group -> + val (addonEntries, pluginEntries) = remainingEntries.partition { group -> group.addonName in addonRankByName } val orderedAddons = addonEntries.sortedBy { group -> addonRankByName.getValue(group.addonName) } - return orderedAddons + pluginEntries + return directDebridEntries + orderedAddons + pluginEntries } fun selectAutoPlayStream( @@ -37,8 +41,39 @@ object StreamAutoPlaySelector { preferredBingeGroup: String? = null, preferBingeGroupInSelection: Boolean = false, bingeGroupOnly: Boolean = false, - ): StreamItem? { - if (streams.isEmpty()) return null + debridEnabled: Boolean = true, + activeResolverProviderId: String? = null, + ): StreamItem? = + evaluateAutoPlayStream( + streams = streams, + mode = mode, + regexPattern = regexPattern, + source = source, + installedAddonNames = installedAddonNames, + selectedAddons = selectedAddons, + selectedPlugins = selectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = preferBingeGroupInSelection, + bingeGroupOnly = bingeGroupOnly, + debridEnabled = debridEnabled, + activeResolverProviderId = activeResolverProviderId, + ).stream + + fun evaluateAutoPlayStream( + streams: List, + mode: StreamAutoPlayMode, + regexPattern: String, + source: StreamAutoPlaySource, + installedAddonNames: Set, + selectedAddons: Set, + selectedPlugins: Set, + preferredBingeGroup: String? = null, + preferBingeGroupInSelection: Boolean = false, + bingeGroupOnly: Boolean = false, + debridEnabled: Boolean = true, + activeResolverProviderId: String? = null, + ): StreamAutoPlayEvaluation { + if (streams.isEmpty()) return StreamAutoPlayEvaluation() val sourceScopedStreams = when (source) { StreamAutoPlaySource.ALL_SOURCES -> streams @@ -53,31 +88,50 @@ object StreamAutoPlaySelector { selectedPlugins.isEmpty() || stream.addonName in selectedPlugins } } - if (candidateStreams.isEmpty()) return null - if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) return null - - val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() - if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { - val bingeGroupMatch = candidateStreams.firstOrNull { stream -> - stream.behaviorHints.bingeGroup == targetBingeGroup && stream.isAutoPlayable() - } - if (bingeGroupMatch != null) return bingeGroupMatch - // When bingeGroupOnly = true, do NOT fall through to mode-based selection - if (bingeGroupOnly) return null - } else if (bingeGroupOnly) { - // bingeGroupOnly requested but no preferredBingeGroup or preferBingeGroupInSelection is false - // Fall through to mode-based selection (bingeGroupOnly has no effect without a binge group to match) - if (mode == StreamAutoPlayMode.MANUAL) return null + if (candidateStreams.isEmpty()) return StreamAutoPlayEvaluation() + if (mode == StreamAutoPlayMode.MANUAL && !bingeGroupOnly) { + return StreamAutoPlayEvaluation() } - return when (mode) { - StreamAutoPlayMode.MANUAL -> null - StreamAutoPlayMode.FIRST_STREAM -> candidateStreams.firstOrNull { it.isAutoPlayable() } + val targetBingeGroup = preferredBingeGroup?.trim().orEmpty() + val bingeGroupCandidates = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + candidateStreams.filter { stream -> stream.behaviorHints.bingeGroup == targetBingeGroup } + } else { + emptyList() + } + val preferredReadyStream = bingeGroupCandidates.firstOrNull { stream -> + stream.isAutoPlayable(debridEnabled, activeResolverProviderId) + } + if (bingeGroupOnly) { + val readyStreams = preferredReadyStream?.let(::listOf).orEmpty() + return StreamAutoPlayEvaluation( + stream = preferredReadyStream, + readyStreams = readyStreams, + hasPendingDebridCandidate = preferredReadyStream == null && + bingeGroupCandidates.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + }, + ) + } + if (mode == StreamAutoPlayMode.MANUAL) { + return StreamAutoPlayEvaluation() + } + val preferredStream = if (preferBingeGroupInSelection && targetBingeGroup.isNotEmpty()) { + candidateStreams.firstOrNull { stream -> + stream.behaviorHints.bingeGroup == targetBingeGroup && + stream.isAutoPlayable(debridEnabled, activeResolverProviderId) + } + } else { + null + } + val matchingStreams = when (mode) { + StreamAutoPlayMode.MANUAL -> emptyList() + StreamAutoPlayMode.FIRST_STREAM -> candidateStreams StreamAutoPlayMode.REGEX_MATCH -> { val pattern = regexPattern.trim() val userRegex = runCatching { Regex(pattern, RegexOption.IGNORE_CASE) }.getOrNull() - ?: return null + ?: return StreamAutoPlayEvaluation() val exclusionMatches = Regex("\\(\\?![^)]*?\\(([^)]+)\\)").findAll(pattern) @@ -91,9 +145,8 @@ object StreamAutoPlaySelector { Regex("\\b(${exclusionWords.joinToString("|")})\\b", RegexOption.IGNORE_CASE) } else null - val matchingStreams = candidateStreams.filter { stream -> - if (!stream.isAutoPlayable()) return@filter false - val url = stream.directPlaybackUrl.orEmpty() + candidateStreams.filter { stream -> + val url = stream.playableDirectUrl.orEmpty() val searchableText = buildString { append(stream.addonName).append(' ') @@ -111,13 +164,65 @@ object StreamAutoPlaySelector { true } - - if (matchingStreams.isEmpty()) return null - matchingStreams.firstOrNull { it.isAutoPlayable() } } } + if (matchingStreams.isEmpty() && preferredStream == null) return StreamAutoPlayEvaluation() + + val readyStreams = buildList { + preferredStream?.let(::add) + matchingStreams + .filter { it.isAutoPlayable(debridEnabled, activeResolverProviderId) } + .filterNot { it == preferredStream } + .forEach(::add) + } + val selected = readyStreams.firstOrNull() + if (selected != null) { + return StreamAutoPlayEvaluation( + stream = selected, + readyStreams = readyStreams, + ) + } + + return StreamAutoPlayEvaluation( + readyStreams = readyStreams, + hasPendingDebridCandidate = matchingStreams.any { + it.isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + }, + ) } - private fun StreamItem.isAutoPlayable(): Boolean = - directPlaybackUrl != null + private fun StreamItem.isAutoPlayable( + debridEnabled: Boolean, + activeResolverProviderId: String?, + ): Boolean = + playableDirectUrl != null || + (debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId)) + + private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean = + when { + isDirectDebridStream -> clientResolve?.service.matchesResolver(activeResolverProviderId) + isCachedDebridTorrentStream -> debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId) + else -> false + } + + private fun StreamItem.isPendingDebridAutoPlay( + debridEnabled: Boolean, + activeResolverProviderId: String?, + ): Boolean { + if (!debridEnabled || !isInstalledAddonStream || !needsLocalDebridResolve) return false + if (!debridCacheStatus?.providerId.matchesResolver(activeResolverProviderId)) return false + val state = debridCacheStatus?.state + return state == null || state == StreamDebridCacheState.CHECKING + } + + private fun String?.matchesResolver(activeResolverProviderId: String?): Boolean { + val active = activeResolverProviderId?.trim().orEmpty() + return active.isBlank() || this == null || equals(active, ignoreCase = true) + } } + +data class StreamAutoPlayEvaluation( + val stream: StreamItem? = null, + val readyStreams: List = emptyList(), + val hasPendingDebridCandidate: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index fe223534..b88c8251 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,8 @@ data class StreamItem( val addonName: String, val addonId: String, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), + val clientResolve: StreamClientResolve? = null, + val debridCacheStatus: StreamDebridCacheStatus? = null, ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -27,18 +29,46 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val playableDirectUrl: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { !it.isMagnetLink() } + + val torrentMagnetUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isMagnetLink() } + + val isDirectDebridStream: Boolean + get() = clientResolve?.isDirectDebridCandidate == true + + val isInstalledAddonStream: Boolean + get() = addonId.startsWith("addon:") + val isTorrentStream: Boolean - get() = !infoHash.isNullOrBlank() || + get() = !isDirectDebridStream && ( + !infoHash.isNullOrBlank() || url.isMagnetLink() || externalUrl.isMagnetLink() + ) + + val isCachedDebridTorrentStream: Boolean + get() = isTorrentStream && debridCacheStatus?.state == StreamDebridCacheState.CACHED + + val needsLocalDebridResolve: Boolean + get() = isTorrentStream && playableDirectUrl == null + + val isAddonDebridCandidate: Boolean + get() = isInstalledAddonStream && (needsLocalDebridResolve || isDirectDebridStream) val hasPlayableSource: Boolean - get() = url != null || infoHash != null || externalUrl != null + get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +fun StreamItem.isSelectableForPlayback(debridEnabled: Boolean): Boolean = + playableDirectUrl != null || (debridEnabled && isAddonDebridCandidate) + data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, @@ -53,6 +83,86 @@ data class StreamProxyHeaders( val response: Map? = null, ) +enum class StreamDebridCacheState { + CHECKING, + CACHED, + NOT_CACHED, + UNKNOWN, +} + +data class StreamDebridCacheStatus( + val providerId: String, + val providerName: String, + val state: StreamDebridCacheState, + val cachedName: String? = null, + val cachedSize: Long? = 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, @@ -76,6 +186,7 @@ data class StreamsUiState( val isAnyLoading: Boolean = false, val emptyStateReason: StreamsEmptyStateReason? = null, val autoPlayStream: StreamItem? = null, + val autoPlayCandidates: List = emptyList(), val isDirectAutoPlayFlow: Boolean = false, val showDirectAutoPlayOverlay: Boolean = false, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 72a6fc5c..9a6aa866 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -26,8 +26,10 @@ object StreamParser { val url = obj.string("url") val infoHash = obj.string("infoHash") val externalUrl = obj.string("externalUrl") + val clientResolve = obj.objectValue("clientResolve")?.toClientResolve() - if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null + // Must have at least one playable source + if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null val hintsObj = obj["behaviorHints"] as? JsonObject val proxyHeaders = hintsObj @@ -44,6 +46,7 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, @@ -80,6 +83,11 @@ object StreamParser { ?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } .orEmpty() + private fun JsonObject.intList(name: String): List = + (this[name] as? JsonArray) + ?.mapNotNull { it.jsonPrimitive.intOrNull } + .orEmpty() + private fun JsonObject.stringMap(): Map = entries.mapNotNull { (key, value) -> (value as? JsonPrimitive)?.contentOrNull @@ -99,4 +107,67 @@ object StreamParser { ) } + private fun JsonObject.toClientResolve(): StreamClientResolve = + StreamClientResolve( + type = string("type"), + infoHash = string("infoHash"), + fileIdx = int("fileIdx"), + magnetUri = string("magnetUri"), + sources = stringList("sources"), + torrentName = string("torrentName"), + filename = string("filename"), + mediaType = string("mediaType"), + mediaId = string("mediaId"), + mediaOnlyId = string("mediaOnlyId"), + title = string("title"), + season = int("season"), + episode = int("episode"), + service = string("service"), + serviceIndex = int("serviceIndex"), + serviceExtension = string("serviceExtension"), + isCached = boolean("isCached"), + stream = objectValue("stream")?.toClientResolveStream(), + ) + + private fun JsonObject.toClientResolveStream(): StreamClientResolveStream = + StreamClientResolveStream( + raw = objectValue("raw")?.toClientResolveRaw(), + ) + + private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw = + StreamClientResolveRaw( + torrentName = string("torrentName"), + filename = string("filename"), + size = long("size"), + folderSize = long("folderSize"), + tracker = string("tracker"), + indexer = string("indexer"), + network = string("network"), + parsed = objectValue("parsed")?.toClientResolveParsed(), + ) + + private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed = + StreamClientResolveParsed( + rawTitle = string("raw_title"), + parsedTitle = string("parsed_title"), + year = int("year"), + resolution = string("resolution"), + seasons = intList("seasons"), + episodes = intList("episodes"), + quality = string("quality"), + hdr = stringList("hdr"), + codec = string("codec"), + audio = stringList("audio"), + channels = stringList("channels"), + languages = stringList("languages"), + group = string("group"), + network = string("network"), + edition = string("edition"), + duration = long("duration"), + bitDepth = string("bit_depth"), + extended = boolean("extended"), + theatrical = boolean("theatrical"), + remastered = boolean("remastered"), + unrated = boolean("unrated"), + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 56a76194..bb17f2f5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -5,6 +5,10 @@ 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.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamPresentation +import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -101,6 +105,7 @@ object StreamsRepository { PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value + val debridSettings = DebridSettingsRepository.snapshot() val autoPlayMode = playerSettings.streamAutoPlayMode val isAutoPlayEnabled = !manualSelection && autoPlayMode != StreamAutoPlayMode.MANUAL && !(autoPlayMode == StreamAutoPlayMode.REGEX_MATCH && @@ -198,8 +203,13 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } + val warmedAddonGroups = AddonStreamWarmupRepository + .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) + .orEmpty() + .associateBy { it.addonId } + val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - AddonStreamGroup( + warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -213,26 +223,30 @@ object StreamsRepository { isLoading = true, ) }, installedAddonOrder) + val isInitiallyLoading = initialGroups.any { it.isLoading } _uiState.value = StreamsUiState( requestToken = requestToken, groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), - isAnyLoading = true, + isAnyLoading = isInitiallyLoading, emptyStateReason = null, isDirectAutoPlayFlow = isDirectAutoPlayFlow, showDirectAutoPlayOverlay = isDirectAutoPlayFlow, ) activeJob = scope.launch { + val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val completions = Channel(capacity = Channel.BUFFERED) val pluginRemainingByAddonId = pluginProviderGroups .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = streamAddons.size + + val totalTasks = pendingStreamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() + val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val debridAvailabilityJobs = mutableListOf() var autoSelectTriggered = false var timeoutElapsed = false fun publishCompletion(completion: StreamLoadCompletion) { @@ -240,6 +254,48 @@ object StreamsRepository { log.d { "Ignoring late stream load completion after channel close" } } } + fun presentDebridGroup(group: AddonStreamGroup): AddonStreamGroup = + DebridStreamPresentation.apply( + groups = listOf(group), + settings = debridSettings, + ).firstOrNull() ?: group + + fun publishAddonGroup(group: AddonStreamGroup) { + _uiState.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { currentGroup -> + if (currentGroup.addonId == group.addonId) group else currentGroup + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + } + + fun launchDebridAvailability(group: AddonStreamGroup) { + if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return + + val eligibleGroupIds = setOf(group.addonId) + val checkingGroup = LocalDebridAvailabilityService.markChecking( + groups = listOf(group), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: group + publishAddonGroup(checkingGroup) + + val availabilityJob = launch { + val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( + groups = listOf(checkingGroup), + eligibleGroupIds = eligibleGroupIds, + ).firstOrNull() ?: checkingGroup + publishAddonGroup(presentDebridGroup(availabilityGroup)) + } + debridAvailabilityJobs += availabilityJob + } val timeoutJob = if (isDirectAutoPlayFlow) { val timeoutSeconds = playerSettings.streamAutoPlayTimeoutSeconds @@ -270,6 +326,8 @@ object StreamsRepository { preferredBingeGroup = persistedBingeGroup, preferBingeGroupInSelection = persistedBingeGroup != null, bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) _uiState.update { it.copy(autoPlayStream = selected) } } @@ -294,7 +352,7 @@ object StreamsRepository { if (!autoSelectTriggered) { val allStreams = _uiState.value.groups.flatMap { it.streams } if (allStreams.isNotEmpty()) { - val selected = StreamAutoPlaySelector.selectAutoPlayStream( + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( streams = allStreams, mode = autoPlayMode, regexPattern = playerSettings.streamAutoPlayRegex, @@ -305,11 +363,19 @@ object StreamsRepository { preferredBingeGroup = persistedBingeGroup, preferBingeGroupInSelection = persistedBingeGroup != null, bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) - if (selected != null) { + if (evaluation.stream != null || !evaluation.hasPendingDebridCandidate) { autoSelectTriggered = true - _uiState.update { it.copy(autoPlayStream = selected) } - } else { + _uiState.update { + it.copy( + autoPlayStream = evaluation.stream, + autoPlayCandidates = evaluation.readyStreams, + ) + } + } + if (evaluation.stream == null && !evaluation.hasPendingDebridCandidate) { _uiState.update { it.copy( isDirectAutoPlayFlow = false, @@ -325,7 +391,7 @@ object StreamsRepository { null } - streamAddons.forEach { addon -> + pendingStreamAddons.forEach { addon -> launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -414,20 +480,8 @@ object StreamsRepository { when (val completion = completions.receive()) { is StreamLoadCompletion.Addon -> { 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), - ) - } + publishAddonGroup(result) + launchDebridAvailability(result) } is StreamLoadCompletion.PluginScraper -> { @@ -473,6 +527,32 @@ object StreamsRepository { } } + } + + for (availabilityJob in debridAvailabilityJobs) { + availabilityJob.join() + } + launch { + DirectDebridStreamPreparer.prepare( + streams = _uiState.value.groups + .filter { it.addonId in installedAddonIds } + .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, + eligibleGroupIds = installedAddonIds, + ), + ) + } + } // Early match / timeout-elapsed auto-select on each addon response if (isDirectAutoPlayFlow && !autoSelectTriggered) { @@ -491,6 +571,8 @@ object StreamsRepository { preferredBingeGroup = persistedBingeGroup, preferBingeGroupInSelection = persistedBingeGroup != null, bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) if (selected != null) { autoSelectTriggered = true @@ -509,6 +591,8 @@ object StreamsRepository { preferredBingeGroup = persistedBingeGroup, preferBingeGroupInSelection = true, bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) if (earlyMatch != null) { autoSelectTriggered = true @@ -523,7 +607,7 @@ object StreamsRepository { if (isDirectAutoPlayFlow && !autoSelectTriggered) { autoSelectTriggered = true val allStreams = _uiState.value.groups.flatMap { it.streams } - val selected = StreamAutoPlaySelector.selectAutoPlayStream( + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( streams = allStreams, mode = autoPlayMode, regexPattern = playerSettings.streamAutoPlayRegex, @@ -534,8 +618,15 @@ object StreamsRepository { preferredBingeGroup = persistedBingeGroup, preferBingeGroupInSelection = persistedBingeGroup != null, bingeGroupOnly = false, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, ) - _uiState.update { it.copy(autoPlayStream = selected) } + _uiState.update { + it.copy( + autoPlayStream = evaluation.stream, + autoPlayCandidates = evaluation.readyStreams, + ) + } } if (isDirectAutoPlayFlow && _uiState.value.autoPlayStream == null) { _uiState.update { @@ -558,12 +649,33 @@ object StreamsRepository { _uiState.update { it.copy( autoPlayStream = null, + autoPlayCandidates = emptyList(), isDirectAutoPlayFlow = false, showDirectAutoPlayOverlay = false, ) } } + fun skipAutoPlayStream(stream: StreamItem): Boolean { + var hasNext = false + _uiState.update { current -> + val failedIndex = current.autoPlayCandidates.indexOf(stream) + val remaining = if (failedIndex >= 0) { + current.autoPlayCandidates.drop(failedIndex + 1) + } else { + current.autoPlayCandidates.drop(1) + } + hasNext = remaining.isNotEmpty() + current.copy( + autoPlayStream = remaining.firstOrNull(), + autoPlayCandidates = remaining, + isDirectAutoPlayFlow = remaining.isNotEmpty(), + showDirectAutoPlayOverlay = remaining.isNotEmpty(), + ) + } + return hasNext + } + fun cancelLoading() { activeJob?.cancel() activeJob = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 41302ad0..b7440d06 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 @@ -3,9 +3,12 @@ package com.nuvio.app.features.streams import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -85,6 +88,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch @@ -130,6 +135,10 @@ fun StreamsScreen( PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() val watchProgressUiState by remember { WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState @@ -141,7 +150,6 @@ fun StreamsScreen( val clipboardManager = LocalClipboardManager.current val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) - val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -217,14 +225,12 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.canResolvePlayableLinks, + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -238,14 +244,12 @@ fun StreamsScreen( episodeNumber = episodeNumber, episodeTitle = episodeTitle, uiState = uiState, + debridEnabled = debridSettings.canResolvePlayableLinks, + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && !debridSettings.hasCustomStreamFormatting, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, onStreamSelected = { stream, positionMs, progressFraction -> - if (stream.isTorrentStream) { - NuvioToastController.show(torrentUnsupportedText) - } else { - onStreamSelected(stream, positionMs, progressFraction) - } + onStreamSelected(stream, positionMs, progressFraction) }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) @@ -340,7 +344,7 @@ fun StreamsScreen( externalPlayerEnabled = playerSettings.externalPlayerEnabled, onDismiss = { streamActionsTarget = null }, onCopyLink = { stream -> - val directUrl = stream.directPlaybackUrl + val directUrl = stream.playableDirectUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) NuvioToastController.show(streamLinkCopiedText) @@ -388,6 +392,8 @@ private fun MobileStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -468,6 +474,8 @@ private fun MobileStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -761,6 +769,8 @@ private fun FilterChip( @Composable internal fun StreamList( uiState: StreamsUiState, + debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -799,6 +809,8 @@ internal fun StreamList( sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group), group = group, showHeader = uiState.selectedFilter == null, + debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -822,6 +834,8 @@ private fun LazyListScope.streamSection( sectionKey: String, group: AddonStreamGroup, showHeader: Boolean, + debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -865,13 +879,15 @@ private fun LazyListScope.streamSection( ) { _, stream -> StreamCard( stream = stream, + enabled = stream.isSelectableForPlayback(debridEnabled), + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onClick = { - if (stream.directPlaybackUrl != null || stream.isTorrentStream) { + if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, onLongClick = { - if (stream.directPlaybackUrl != null) { + if (stream.playableDirectUrl != null) { onStreamLongPress(stream) } }, @@ -898,7 +914,7 @@ internal fun streamCardRenderKey( append(':') append(itemIndex) append(':') - append(stream.url ?: stream.infoHash ?: stream.streamLabel) + append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel) } // --------------------------------------------------------------------------- @@ -968,11 +984,12 @@ private fun StreamSourceHeader( @Composable private fun StreamCard( stream: StreamItem, + enabled: Boolean, + appendInstantServiceToDefaultName: Boolean, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier @@ -987,7 +1004,7 @@ private fun StreamCard( .clip(cardShape) .background(Color.White.copy(alpha = 0.05f)) .combinedClickable( - enabled = isEnabled, + enabled = enabled, onClick = onClick, onLongClick = onLongClick, ) @@ -995,15 +1012,9 @@ private fun StreamCard( verticalAlignment = Alignment.Top, ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = stream.streamLabel, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - color = MaterialTheme.colorScheme.onSurface, + StreamNameWithInstantService( + stream = stream, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, ) val subtitle = stream.streamSubtitle @@ -1020,13 +1031,68 @@ private fun StreamCard( } Spacer(modifier = Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { StreamFileSizeBadge(stream = stream) } } } } +@Composable +private fun StreamNameWithInstantService( + stream: StreamItem, + appendInstantServiceToDefaultName: Boolean, +) { + val nameStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val instantLabel = if (appendInstantServiceToDefaultName) { + stream.instantServiceLabel() + } else { + null + } + val showInstantLabel = instantLabel != null + val visibleState = remember(stream.streamLabel) { + MutableTransitionState(showInstantLabel) + } + visibleState.targetState = showInstantLabel + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stream.streamLabel, + modifier = Modifier.weight(1f, fill = false), + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(durationMillis = 260)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 120), + shrinkTowards = Alignment.Start, + ), + label = "streamNameInstantService", + ) { + Text( + text = " ${instantLabel.orEmpty()}", + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StreamActionsSheet( @@ -1125,6 +1191,15 @@ private fun StreamActionsSheet( } } +private fun StreamItem.instantServiceLabel(): String? { + val status = debridCacheStatus ?: return null + if (status.state != StreamDebridCacheState.CACHED) return null + val providerLabel = DebridProviders.shortName(status.providerId) + .ifBlank { status.providerName.trim() } + .ifBlank { DebridProviders.displayName(status.providerId) } + return "- $providerLabel Instant" +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 3f33ce98..5ed13ae7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -60,6 +60,8 @@ internal fun TabletStreamsLayout( episodeNumber: Int?, episodeTitle: String?, uiState: StreamsUiState, + debridEnabled: Boolean, + appendInstantServiceToDefaultName: Boolean, resumePositionMs: Long?, resumeProgressFraction: Float?, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, @@ -199,6 +201,8 @@ internal fun TabletStreamsLayout( StreamList( uiState = uiState, + debridEnabled = debridEnabled, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 10263a55..d3c2bc37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter( seasonNumber: Int?, episodeNumber: Int?, todayIsoDate: String, + showUnairedNextUp: Boolean = false, ): WatchingReleasedEpisode? { val sortedEpisodes = episodes.sortedWith( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), @@ -76,7 +77,7 @@ fun nextReleasedEpisodeAfter( candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, - showUnairedNextUp = false, + showUnairedNextUp = showUnairedNextUp, ) } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } @@ -89,6 +90,7 @@ fun decideSeriesPrimaryAction( watchedRecords: List, todayIsoDate: String, preferFurthestEpisode: Boolean = true, + showUnairedNextUp: Boolean = false, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -112,6 +114,7 @@ fun decideSeriesPrimaryAction( seasonNumber = latestCompletedEpisode.seasonNumber, episodeNumber = latestCompletedEpisode.episodeNumber, todayIsoDate = todayIsoDate, + showUnairedNextUp = showUnairedNextUp, ) } else { val sorted = episodes diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 9fb84629..7783f353 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -1,5 +1,7 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.CloudLibraryContentType +import com.nuvio.app.features.cloud.cloudLibraryProviderPosterUrl import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable @@ -199,6 +201,7 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -213,9 +216,9 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, + imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, logo = normalizedEntry.logo, - poster = normalizedEntry.poster, + poster = normalizedEntry.poster ?: cloudPosterUrl, background = normalizedEntry.background, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, @@ -233,6 +236,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { ) } +private fun WatchProgressEntry.cloudLibraryPosterFallbackUrl(): String? { + if (!contentType.equals(CloudLibraryContentType, ignoreCase = true) && + !parentMetaType.equals(CloudLibraryContentType, ignoreCase = true) + ) { + return null + } + return cloudLibraryProviderPosterUrl(parentMetaId) + ?: cloudLibraryProviderPosterUrl(providerAddonId) +} + internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt new file mode 100644 index 00000000..25f25e39 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/CloudLibraryStoreTest.kt @@ -0,0 +1,249 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProvider +import com.nuvio.app.features.debrid.DebridProviderCapability +import com.nuvio.app.features.debrid.DebridServiceCredential +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CloudLibraryStoreTest { + @Test + fun `refresh aggregates multiple providers without provider-specific assumptions`() = runBlocking { + val firstProvider = cloudProvider(id = "alpha", name = "Alpha") + val secondProvider = cloudProvider(id = "beta", name = "Beta") + val store = CloudLibraryStore( + credentialsProvider = { + listOf( + DebridServiceCredential(firstProvider, "alpha-token"), + DebridServiceCredential(secondProvider, "beta-token"), + ) + }, + providerApis = listOf( + FakeCloudProviderApi( + provider = firstProvider, + items = listOf(cloudItem(firstProvider, "one")), + ), + FakeCloudProviderApi( + provider = secondProvider, + items = listOf(cloudItem(secondProvider, "two")), + ), + ), + ) + + val state = store.refresh() + + assertTrue(state.isLoaded) + assertEquals(listOf("alpha", "beta"), state.providers.map { it.providerId }) + assertEquals(listOf("one", "two"), state.items.map { it.id }) + } + + @Test + fun `refresh ignores connected providers without cloud library capability`() = runBlocking { + val cloudProvider = cloudProvider(id = "cloud", name = "Cloud") + val unsupportedProvider = DebridProvider( + id = "plain", + displayName = "Plain", + shortName = "P", + capabilities = setOf(DebridProviderCapability.ClientResolve), + ) + val store = CloudLibraryStore( + credentialsProvider = { + listOf( + DebridServiceCredential(cloudProvider, "cloud-token"), + DebridServiceCredential(unsupportedProvider, "plain-token"), + ) + }, + providerApis = listOf( + FakeCloudProviderApi( + provider = cloudProvider, + items = listOf(cloudItem(cloudProvider, "cloud-item")), + ), + ), + ) + + val state = store.refresh() + + assertEquals(listOf("cloud"), state.providers.map { it.providerId }) + assertEquals(listOf("cloud-item"), state.items.map { it.id }) + } + + @Test + fun `playback target lookup matches cloud watch progress video id`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = CloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "Torrent", + files = listOf( + CloudLibraryFile(id = "7", name = "sample.mkv", playable = true), + CloudLibraryFile(id = "8", name = "movie.mkv", playable = true), + ), + ) + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = "torbox:Torrent:29773238", + videoId = "torbox:Torrent:29773238:8", + ), + ) + + assertEquals(item, target.item) + assertEquals("8", target.file.id) + } + + @Test + fun `playback target lookup falls back to single playable file`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = cloudItem(provider, "29773238") + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val target = assertNotNull( + state.findPlaybackTargetForProgress( + contentId = item.stableKey, + videoId = item.stableKey, + ), + ) + + assertEquals(item, target.item) + assertEquals(item.playableFiles.single(), target.file) + } + + @Test + fun `resolve playback reuses already resolved file url`() = runBlocking { + val provider = cloudProvider(id = "premiumize", name = "Premiumize") + val api = FakeCloudProviderApi( + provider = provider, + items = emptyList(), + ) + val store = CloudLibraryStore( + credentialsProvider = { + listOf(DebridServiceCredential(provider, "token")) + }, + providerApis = listOf(api), + ) + val item = cloudItem(provider, "ready") + val file = item.playableFiles.single().copy(playbackUrl = "https://cached.example/video.mkv") + + val result = store.resolvePlayback(item = item, file = file) + + assertTrue(result is CloudLibraryPlaybackResult.Success) + assertEquals("https://cached.example/video.mkv", result.url) + assertEquals(0, api.resolvePlaybackCalls) + } + + @Test + fun `resolved playback url is remembered in cloud library state`() { + val provider = cloudProvider(id = "torbox", name = "TorBox") + val item = cloudItem(provider, "29773238") + val file = item.playableFiles.single() + val state = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = provider, + items = listOf(item), + ), + ), + ) + + val updated = state.withResolvedPlaybackUrl( + item = item, + file = file, + url = "https://resolved.example/movie.mkv", + ) + + val target = assertNotNull( + updated.findPlaybackTargetForProgress( + contentId = item.stableKey, + videoId = item.playbackVideoId(file), + ), + ) + assertEquals("https://resolved.example/movie.mkv", target.file.playbackUrl) + } + + @Test + fun `provider poster urls are mapped for cloud services`() { + assertEquals( + TorboxCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("torbox:Torrent:29773238"), + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryProviderPosterUrl("cloud:premiumize"), + ) + assertTrue( + cloudLibraryDisplayArtworkUrl(TorboxCloudLibraryPosterUrl) + ?.startsWith("data:image/svg+xml;base64,") == true, + ) + assertEquals( + PremiumizeCloudLibraryPosterUrl, + cloudLibraryDisplayArtworkUrl(PremiumizeCloudLibraryPosterUrl), + ) + } +} + +private class FakeCloudProviderApi( + override val provider: DebridProvider, + private val items: List, +) : CloudLibraryProviderApi { + var resolvePlaybackCalls: Int = 0 + private set + + override suspend fun listItems(apiKey: String): Result> = + Result.success(items) + + override suspend fun resolvePlayback( + apiKey: String, + item: CloudLibraryItem, + file: CloudLibraryFile, + ): CloudLibraryPlaybackResult { + resolvePlaybackCalls += 1 + return CloudLibraryPlaybackResult.Success(url = "https://example.test/${item.id}/${file.id}") + } +} + +private fun cloudProvider(id: String, name: String): DebridProvider = + DebridProvider( + id = id, + displayName = name, + shortName = name.take(1), + capabilities = setOf(DebridProviderCapability.CloudLibrary), + ) + +private fun cloudItem(provider: DebridProvider, id: String): CloudLibraryItem = + CloudLibraryItem( + providerId = provider.id, + providerName = provider.displayName, + id = id, + type = CloudLibraryItemType.Torrent, + name = id, + files = listOf( + CloudLibraryFile( + id = "file-$id", + name = "$id.mkv", + playable = true, + ), + ), + ) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt new file mode 100644 index 00000000..9e08f5a1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/PremiumizeCloudLibraryProviderApiTest.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.PremiumizeCloudFileDto +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PremiumizeCloudLibraryProviderApiTest { + @Test + fun `groups nested files by top-level folder and keeps root files standalone`() { + val items = premiumizeCloudItemsFromFiles( + files = listOf( + PremiumizeCloudFileDto( + id = "e01", + name = "Show.S01E01.mkv", + path = "Show/Season 01/Show.S01E01.mkv", + size = 1_000, + mimeType = "video/x-matroska", + link = "https://pm/e01", + ), + PremiumizeCloudFileDto( + id = "e02", + name = "Show.S01E02.mkv", + path = "Show/Season 01/Show.S01E02.mkv", + size = 2_000, + mimeType = "video/x-matroska", + link = "https://pm/e02", + ), + PremiumizeCloudFileDto( + id = "movie", + name = "Movie.mp4", + path = "Movie.mp4", + size = 3_000, + mimeType = "video/mp4", + link = "https://pm/movie", + ), + ), + providerId = DebridProviders.PREMIUMIZE_ID, + providerName = "Premiumize", + ) + + assertEquals(listOf("Movie.mp4", "Show"), items.map { it.name }) + assertEquals(CloudLibraryItemType.File, items.first().type) + assertEquals(listOf("Show.S01E01.mkv", "Show.S01E02.mkv"), items[1].files.map { it.name }) + assertEquals("https://pm/e01", items[1].files.first().playbackUrl) + } + + @Test + fun `marks non video and missing fields as non playable without dropping valid files`() { + val items = premiumizeCloudItemsFromFiles( + files = listOf( + PremiumizeCloudFileDto(id = "notes", name = "notes.txt", path = "Pack/notes.txt", size = 100), + PremiumizeCloudFileDto(id = "video", name = "video.avi", path = "Pack/video.avi", size = 200), + PremiumizeCloudFileDto(id = "missing", name = null, path = null, size = 300), + ), + providerId = DebridProviders.PREMIUMIZE_ID, + providerName = "Premiumize", + ) + + assertEquals(1, items.size) + assertEquals(2, items.single().files.size) + assertFalse(items.single().files.first { it.name == "notes.txt" }.playable) + assertTrue(items.single().files.first { it.name == "video.avi" }.playable) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt new file mode 100644 index 00000000..92e15b52 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -0,0 +1,193 @@ +package com.nuvio.app.features.cloud + +import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.debrid.TorboxCloudFileDto +import com.nuvio.app.features.debrid.TorboxCloudItemDto +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TorboxCloudLibraryProviderApiTest { + @Test + fun `maps torrent dto with status progress size and playable files`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(42), + name = "Movie Pack", + status = "completed", + progress = 75.0, + size = 1_024L, + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(8), + name = "movie.mkv", + mimeType = "video/x-matroska", + size = 512L, + ), + ), + ).toCloudLibraryItem( + providerId = DebridProviders.Torbox.id, + providerName = DebridProviders.Torbox.displayName, + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals("42", item.id) + assertEquals(CloudLibraryItemType.Torrent, item.type) + assertEquals("completed", item.status) + assertEquals(0.75f, item.progressFraction) + assertEquals(1_024L, item.sizeBytes) + assertEquals(listOf("8"), item.files.map { it.id }) + assertTrue(item.files.single().playable) + } + + @Test + fun `mapping falls back to hash and file absolute path when friendly fields are missing`() { + val item = TorboxCloudItemDto( + hash = "abc123", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive("file-1"), + absolutePath = "/downloads/show.mp4", + size = 256L, + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Usenet, + ) + + assertNotNull(item) + assertEquals("abc123", item.id) + assertEquals("abc123", item.name) + assertEquals("show.mp4", item.files.single().name) + assertTrue(item.files.single().playable) + } + + @Test + fun `mapping prefers absolute path basename when file name repeats pack name`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(44), + name = "The Rookie S01", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "The Rookie S01", + absolutePath = "/The Rookie S01/The.Rookie.S01E01.1080p.WEB-DL.mkv", + mimeType = "video/x-matroska", + ), + TorboxCloudFileDto( + id = JsonPrimitive(2), + shortName = "The Rookie S01", + absolutePath = "/The Rookie S01/The.Rookie.S01E02.1080p.WEB-DL.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals( + listOf( + "The.Rookie.S01E01.1080p.WEB-DL.mkv", + "The.Rookie.S01E02.1080p.WEB-DL.mkv", + ), + item.playableFiles.map { it.name }, + ) + } + + @Test + fun `mapping prefers short name when Torbox file name is a relative pack path`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(29556645), + name = "From.The.Earth.To.The.Moon.1998.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + shortName = "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + absolutePath = "/completed/2c229180e129280a36ba7f3a22e2f5135a02a766/From.The.Earth.To.The.Moon.S01.2160p.MAX.WEB-DL.x265.10bit.HDR.TrueHD.7.1.Atmos-FLUX[rartv]/From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals( + "From.The.Earth.To.The.Moon.S01E01.2160p.MAX.WEB-DL.TrueHD.Atmos.7.1.HDR.DV.HEVC-FLUX.mkv", + item.playableFiles.single().name, + ) + } + + @Test + fun `mapping handles missing item ids and empty file lists`() { + assertNull( + TorboxCloudItemDto(name = "No ID").toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.WebDownload, + ), + ) + + val item = TorboxCloudItemDto( + id = JsonPrimitive(7), + name = "Empty", + files = emptyList(), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.WebDownload, + ) + + assertNotNull(item) + assertTrue(item.files.isEmpty()) + assertTrue(item.playableFiles.isEmpty()) + } + + @Test + fun `mapping keeps non-playable files but excludes them from playable files`() { + val item = TorboxCloudItemDto( + id = JsonPrimitive(9), + name = "Mixed", + files = listOf( + TorboxCloudFileDto( + id = JsonPrimitive(1), + name = "readme.txt", + mimeType = "text/plain", + ), + TorboxCloudFileDto( + name = "missing-id.mkv", + mimeType = "video/x-matroska", + ), + ), + ).toCloudLibraryItem( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertNotNull(item) + assertEquals(2, item.files.size) + assertFalse(item.files[0].playable) + assertFalse(item.files[1].playable) + assertTrue(item.playableFiles.isEmpty()) + } + + @Test + fun `request download parameter names match Torbox item type`() { + assertEquals("torrent_id", torboxRequestIdParameterName(CloudLibraryItemType.Torrent)) + assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet)) + assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt new file mode 100644 index 00000000..04159a21 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridFileSelectorTest.kt @@ -0,0 +1,184 @@ +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) + } + + @Test + fun `Premiumize direct download selector ignores non-video and matches episode`() { + val files = listOf( + PremiumizeDirectDownloadFileDto(path = "Show/Readme.txt", size = 9_000, link = "https://pm/readme"), + PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E02.mkv", size = 2_000, link = "https://pm/e02"), + PremiumizeDirectDownloadFileDto(path = "Show/Show.S01E01.mkv", size = 1_000, link = "https://pm/e01"), + ) + + val selected = PremiumizeDirectDownloadFileSelector().selectFile( + files = files, + resolve = resolve(season = 1, episode = 1), + season = null, + episode = null, + ) + + assertEquals("Show/Show.S01E01.mkv", selected?.path) + } + + @Test + fun `Premiumize direct download selector falls back to largest playable file`() { + val files = listOf( + PremiumizeDirectDownloadFileDto(path = "small.mp4", size = 1_000, link = "https://pm/small"), + PremiumizeDirectDownloadFileDto(path = "large.mkv", size = 3_000, link = "https://pm/large"), + PremiumizeDirectDownloadFileDto(path = "large-without-link.mkv", size = 9_000, link = null), + ) + + val selected = PremiumizeDirectDownloadFileSelector().selectFile( + files = files, + resolve = resolve(), + season = null, + episode = null, + ) + + assertEquals("large.mkv", selected?.path) + } + + 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/DebridProviderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt new file mode 100644 index 00000000..ce8a66e7 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridProviderTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebridProviderTest { + @Test + fun `torbox exposes local addon capabilities`() { + assertTrue(DebridProviders.Torbox.authMethod == DebridProviderAuthMethod.DeviceCode) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.ClientResolve)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.LocalTorrentResolve)) + assertTrue(DebridProviders.Torbox.supports(DebridProviderCapability.CloudLibrary)) + } + + @Test + fun `premiumize exposes oauth and cloud service capabilities`() { + assertTrue(DebridProviders.Premiumize.visibleInUi) + assertTrue(DebridProviders.Premiumize.authMethod == DebridProviderAuthMethod.DeviceCode) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.ClientResolve)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.LocalTorrentResolve)) + assertTrue(DebridProviders.Premiumize.supports(DebridProviderCapability.CloudLibrary)) + } + + @Test + fun `real debrid stays hidden from local addon capability paths`() { + assertTrue(DebridProviders.RealDebrid.authMethod == DebridProviderAuthMethod.ApiKey) + assertFalse(DebridProviders.RealDebrid.visibleInUi) + assertTrue(DebridProviders.RealDebrid.supports(DebridProviderCapability.ClientResolve)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentCacheCheck)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.LocalTorrentResolve)) + assertFalse(DebridProviders.RealDebrid.supports(DebridProviderCapability.CloudLibrary)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt new file mode 100644 index 00000000..dbe4dd26 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridSettingsTest.kt @@ -0,0 +1,66 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DebridSettingsTest { + @Test + fun `normalizes provider ids when reading api keys`() { + val settings = DebridSettings( + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"), + ) + + assertEquals("tb_key", settings.apiKeyFor("TORBOX")) + assertEquals("tb_key", settings.torboxApiKey) + assertEquals("", settings.realDebridApiKey) + } + + @Test + fun `configured services are driven by visible registered providers`() { + val settings = DebridSettings( + providerApiKeys = mapOf( + DebridProviders.TORBOX_ID to "tb_key", + DebridProviders.PREMIUMIZE_ID to "pm_key", + DebridProviders.REAL_DEBRID_ID to "rd_key", + ), + ) + + val services = DebridProviders.configuredServices(settings) + + assertEquals(listOf(DebridProviders.TORBOX_ID, DebridProviders.PREMIUMIZE_ID), services.map { it.provider.id }) + assertEquals(listOf("tb_key", "pm_key"), services.map { it.apiKey }) + assertTrue(settings.hasAnyApiKey) + assertFalse(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID)) + } + + @Test + fun `preferred resolver uses saved provider when connected and falls back otherwise`() { + val preferred = DebridSettings( + enabled = true, + providerApiKeys = mapOf( + DebridProviders.TORBOX_ID to "tb_key", + DebridProviders.PREMIUMIZE_ID to "pm_key", + ), + preferredResolverProviderId = DebridProviders.PREMIUMIZE_ID, + ) + val fallback = preferred.copy(preferredResolverProviderId = DebridProviders.REAL_DEBRID_ID) + + assertEquals(DebridProviders.PREMIUMIZE_ID, preferred.activeResolverProviderId) + assertEquals(DebridProviders.TORBOX_ID, fallback.activeResolverProviderId) + assertTrue(preferred.canResolvePlayableLinks) + } + + @Test + fun `cloud library and link resolving capabilities are independent`() { + val settings = DebridSettings( + enabled = false, + cloudLibraryEnabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "tb_key"), + ) + + assertTrue(settings.canUseCloudLibrary) + assertFalse(settings.canResolvePlayableLinks) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt new file mode 100644 index 00000000..d97f29c5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -0,0 +1,222 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamBehaviorHints +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.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamDebridCacheStatus +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class DebridStreamPresentationTest { + @Test + fun `formats cached addon torrent streams with custom templates`() { + val stream = localTorboxStream( + filename = "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv", + size = 8_589_934_592, + ) + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamNameTemplate = "{stream.resolution} {service.shortName} {service.cached::istrue[\"Ready\"||\"Not Ready\"]}", + streamDescriptionTemplate = "{stream.quality} {stream.encode}\n{stream.size::bytes}\n{stream.filename}", + ), + ) + + assertEquals("2160p TB Ready", formatted.name) + val description = formatted.description.orEmpty() + assertContains(description, "WEB-DL HEVC") + assertContains(description, "8 GB") + assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") + } + + @Test + fun `default formatter replaces addon source labels for managed streams`() { + val stream = premiumizeDirectStream( + name = "[P2P] Torrentio 2160p - PM Instant", + filename = "The.Boys.S03E01.Payback.2160p.WEB-DL.H265.mkv", + size = 12_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Torrentio", + addonId = "addon:torrentio", + streams = listOf(stream), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.PREMIUMIZE_ID to "pm_key"), + ), + ).single().streams.single() + + val name = presented.name.orEmpty() + assertEquals("2160p PM Instant", name) + assertFalse(name.contains("P2P", ignoreCase = true)) + assertFalse(name.contains("torrent", ignoreCase = true)) + assertFalse(name.contains("Torrentio", ignoreCase = true)) + assertFalse(name.contains("Comet", ignoreCase = true)) + } + + @Test + fun `applies debrid sort filters and limits without removing normal urls`() { + val low = localTorboxStream( + name = "Low", + filename = "Movie.720p.BluRay.x264-GRP.mkv", + size = 4_000_000_000, + ) + val large = localTorboxStream( + name = "Large", + filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv", + size = 40_000_000_000, + ) + val mid = localTorboxStream( + name = "Mid", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + val urlStream = StreamItem( + name = "Resolved addon URL", + url = "https://example.test/video.m3u8", + addonName = "Addon", + addonId = "addon:test", + ) + + val group = AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(low, large, mid, urlStream), + ) + val presented = DebridStreamPresentation.apply( + groups = listOf(group), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamMaxResults = 2, + streamSortMode = DebridStreamSortMode.QUALITY_DESC, + streamMinimumQuality = DebridStreamMinimumQuality.P1080, + streamCodecFilter = DebridStreamCodecFilter.HEVC, + ), + ).single().streams + + assertEquals(listOf("2160p TB Instant", "1080p TB Instant", "Resolved addon URL"), presented.map { it.name }) + } + + @Test + fun `hides addon torrent streams that are not cached`() { + val cached = localTorboxStream( + name = "Cached", + filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv", + size = 10_000_000_000, + ) + val uncached = localTorboxStream( + name = "Uncached", + filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv", + size = 20_000_000_000, + cacheState = StreamDebridCacheState.NOT_CACHED, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(cached, uncached), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + ), + ).single().streams + + assertEquals(listOf("1080p TB Instant"), presented.map { it.name }) + } + + @Test + fun `leaves cloud-service results untouched when link resolving is off`() { + val uncached = localTorboxStream( + name = "Uncached", + filename = "Movie.2160p.WEB-DL.HEVC-GRP.mkv", + size = 20_000_000_000, + cacheState = StreamDebridCacheState.NOT_CACHED, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(uncached), + ), + ), + settings = DebridSettings( + enabled = false, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + ), + ).single().streams + + assertEquals(listOf("Uncached"), presented.map { it.name }) + } + + private fun localTorboxStream( + name: String = "Torrent", + filename: String, + size: Long, + cacheState: StreamDebridCacheState = StreamDebridCacheState.CACHED, + ): StreamItem = + StreamItem( + name = name, + infoHash = "abcdef1234567890abcdef1234567890abcdef12$size".take(40), + addonName = "Addon", + addonId = "addon:test", + behaviorHints = StreamBehaviorHints( + filename = filename, + videoSize = size, + ), + debridCacheStatus = StreamDebridCacheStatus( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + state = cacheState, + cachedName = filename, + cachedSize = size, + ), + ) + + private fun premiumizeDirectStream( + name: String, + filename: String, + size: Long, + ): StreamItem = + StreamItem( + name = name, + addonName = "Torrentio", + addonId = "addon:torrentio", + clientResolve = StreamClientResolve( + type = "debrid", + service = DebridProviders.PREMIUMIZE_ID, + filename = filename, + isCached = true, + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + filename = filename, + size = size, + parsed = StreamClientResolveParsed( + resolution = "2160p", + ), + ), + ), + ), + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt new file mode 100644 index 00000000..8f56606c --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamPreparerTest.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.player.PlayerSettingsUiState +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamClientResolve +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals + +class DirectDebridStreamPreparerTest { + + @Test + fun `prioritizes autoplay direct debrid match before display order`() { + val first = directDebridStream(name = "1080p", infoHash = "hash-1") + val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2") + val remaining = directDebridStream(name = "720p", infoHash = "hash-3") + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(first, autoPlayMatch, remaining), + limit = 2, + playerSettings = PlayerSettingsUiState( + streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH, + streamAutoPlayRegex = "2160p", + ), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(autoPlayMatch, first), selected) + } + + @Test + fun `skips already resolved and duplicate direct debrid candidates`() { + val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1") + val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1") + val alreadyResolved = directDebridStream( + name = "2160p", + infoHash = "hash-2", + url = "https://example.com/ready.mp4", + ) + + val selected = DirectDebridStreamPreparer.prioritizeCandidates( + streams = listOf(unresolved, duplicate, alreadyResolved), + limit = 5, + playerSettings = PlayerSettingsUiState(), + installedAddonNames = emptySet(), + ) + + assertEquals(listOf(unresolved), selected) + } + + private fun directDebridStream( + name: String, + infoHash: String, + url: String? = null, + ): StreamItem = + StreamItem( + name = name, + url = url, + addonName = "Addon", + addonId = "addon:test", + 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/debrid/PremiumizeDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt new file mode 100644 index 00000000..b4b87e4a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/PremiumizeDeviceAuthTest.kt @@ -0,0 +1,62 @@ +package com.nuvio.app.features.debrid + +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class PremiumizeDeviceAuthTest { + @Test + fun `maps pending and slow down oauth states to pending`() { + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + premiumizeDeviceAuthorizationTokenResult(tokenError("authorization_pending")), + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + premiumizeDeviceAuthorizationTokenResult(tokenError("slow_down")), + ) + } + + @Test + fun `maps success expired denied and invalid oauth states`() { + assertTrue( + premiumizeDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 200, + body = PremiumizeDeviceTokenDto(accessToken = "pm-token", tokenType = "Bearer"), + rawBody = "", + ), + ) is DebridDeviceAuthorizationTokenResult.Authorized, + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Expired, + premiumizeDeviceAuthorizationTokenResult(tokenError("invalid_grant")), + ) + assertTrue( + premiumizeDeviceAuthorizationTokenResult(tokenError("access_denied")) is + DebridDeviceAuthorizationTokenResult.Failed, + ) + } + + @Test + fun `missing Premiumize client id fails before device flow starts`() = runBlocking { + val api = PremiumizeDebridProviderApi(clientIdProvider = { "" }) + + val failed = try { + api.startDeviceAuthorization("Nuvio") + false + } catch (_: IllegalStateException) { + true + } + + assertTrue(failed) + } + + private fun tokenError(error: String): DebridApiResponse = + DebridApiResponse( + status = 400, + body = PremiumizeDeviceTokenDto(error = error, errorDescription = error), + rawBody = """{"error":"$error"}""", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt new file mode 100644 index 00000000..594a985a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/TorboxDeviceAuthTest.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.debrid + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TorboxDeviceAuthTest { + @Test + fun `maps unused device code response to pending`() { + val response = DebridApiResponse( + status = 400, + body = TorboxEnvelopeDto( + success = false, + detail = "This device code has not been used yet. Please wait for the user to scan the code.", + ), + rawBody = "", + ) + + assertEquals( + DebridDeviceAuthorizationTokenResult.Pending, + torboxDeviceAuthorizationTokenResult(response), + ) + } + + @Test + fun `maps authorized and expired Torbox device states`() { + assertTrue( + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 200, + body = TorboxEnvelopeDto( + success = true, + data = TorboxDeviceTokenDto(accessToken = "tb-token", tokenType = "Bearer"), + ), + rawBody = "", + ), + ) is DebridDeviceAuthorizationTokenResult.Authorized, + ) + assertEquals( + DebridDeviceAuthorizationTokenResult.Expired, + torboxDeviceAuthorizationTokenResult( + DebridApiResponse( + status = 410, + body = TorboxEnvelopeDto(success = false, detail = "Device code expired."), + rawBody = "", + ), + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index e5428e16..a70a2627 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -89,6 +90,46 @@ class SeriesPlaybackResolverTest { assertEquals("show:1:3", action.videoId) } + @Test + fun seriesPrimaryAction_uses_explicit_content_when_meta_id_is_alias() { + val meta = MetaDetails( + id = "tt1234567", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "s4e14", title = "Episode 14", season = 4, episode = 14, released = "2026-03-01"), + MetaVideo(id = "s4e15", title = "Episode 15", season = 4, episode = 15, released = "2026-03-08"), + ), + ) + + val action = meta.seriesPrimaryAction( + content = WatchingContentRef(type = "series", id = "tmdb:98765"), + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "tmdb:98765", + parentMetaType = "series", + videoId = "tmdb:98765:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastPositionMs = 10_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Up Next • S4E15", action.label) + assertEquals("tmdb:98765:4:15", action.videoId) + assertEquals(4, action.seasonNumber) + assertEquals(15, action.episodeNumber) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb..b7226664 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -1,10 +1,19 @@ package com.nuvio.app.features.home +import com.nuvio.app.features.cloud.CloudLibraryFile +import com.nuvio.app.features.cloud.CloudLibraryItem +import com.nuvio.app.features.cloud.CloudLibraryItemType +import com.nuvio.app.features.cloud.CloudLibraryProviderState +import com.nuvio.app.features.cloud.CloudLibraryUiState +import com.nuvio.app.features.cloud.playbackVideoId +import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class HomeScreenTest { @@ -84,6 +93,45 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `build home continue watching items enriches cloud title from library file`() { + val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv") + val cloudItem = CloudLibraryItem( + providerId = DebridProviders.TORBOX_ID, + providerName = DebridProviders.Torbox.displayName, + id = "29773238", + type = CloudLibraryItemType.Torrent, + name = "GOAT torrent", + files = listOf(file), + ) + val progress = WatchProgressEntry( + contentType = "cloud", + parentMetaId = cloudItem.stableKey, + parentMetaType = "cloud", + videoId = cloudItem.playbackVideoId(file), + title = cloudItem.stableKey, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 500L, + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(progress), + nextUpItemsBySeries = emptyMap(), + cloudLibraryUiState = CloudLibraryUiState( + isLoaded = true, + providers = listOf( + CloudLibraryProviderState( + provider = DebridProviders.Torbox, + items = listOf(cloudItem), + ), + ), + ), + ) + + assertEquals("GOAT.2026.2160p.UHD.mkv", result.single().title) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( @@ -146,6 +194,85 @@ class HomeScreenTest { assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) } + @Test + fun `home next up seed uses completed progress when watched item lags on Nuvio Sync`() { + val completedProgress = progressEntry( + videoId = "show:4:14", + title = "Show", + seasonNumber = 4, + episodeNumber = 14, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val olderWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 10, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(completedProgress), + watchedItems = listOf(olderWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(1, result.size) + assertEquals("show", result.single().content.id) + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `home next up seed uses furthest watched item when progress is older`() { + val olderCompletedProgress = progressEntry( + videoId = "show:4:10", + title = "Show", + seasonNumber = 4, + episodeNumber = 10, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ) + val newerWatchedItem = watchedItem( + id = "show", + season = 4, + episode = 14, + markedAtEpochMs = 1_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(olderCompletedProgress), + watchedItems = listOf(newerWatchedItem), + isTraktProgressActive = false, + preferFurthestEpisode = true, + nowEpochMs = 3_000L, + ) + + assertEquals(4, result.single().seasonNumber) + assertEquals(14, result.single().episodeNumber) + } + + @Test + fun `stale live next up item is dropped when current seed advances`() { + val staleNextUp = continueWatchingItem( + videoId = "show:4:11", + subtitle = "Up Next • S4E11", + seedSeasonNumber = 4, + seedEpisodeNumber = 10, + ) + + val result = filterNextUpItemsByCurrentSeeds( + nextUpItemsBySeries = mapOf("show" to (1_000L to staleNextUp)), + activeSeedContentIds = setOf("show"), + currentSeedByContentId = mapOf("show" to (4 to 14)), + shouldDropItemsWithoutActiveSeed = true, + ) + + assertTrue(result.isEmpty()) + } + private fun progressEntry( videoId: String, title: String, @@ -153,6 +280,7 @@ class HomeScreenTest { seasonNumber: Int? = 1, episodeNumber: Int? = 4, episodeTitle: String? = "Episode", + isCompleted: Boolean = false, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -166,11 +294,16 @@ class HomeScreenTest { lastPositionMs = if (seasonNumber != null && episodeNumber != null) 120_000L else 60_000L, durationMs = 1_000_000L, lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, ) private fun continueWatchingItem( videoId: String, subtitle: String, + seasonNumber: Int? = 1, + episodeNumber: Int? = 4, + seedSeasonNumber: Int? = seasonNumber, + seedEpisodeNumber: Int? = episodeNumber, ): ContinueWatchingItem = ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), @@ -179,14 +312,32 @@ class HomeScreenTest { title = "Show", subtitle = subtitle, imageUrl = null, - seasonNumber = 1, - episodeNumber = 4, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), + isNextUp = true, + nextUpSeedSeasonNumber = seedSeasonNumber, + nextUpSeedEpisodeNumber = seedEpisodeNumber, resumePositionMs = 0L, durationMs = 0L, progressFraction = 0f, ) + private fun watchedItem( + id: String, + season: Int, + episode: Int, + markedAtEpochMs: Long, + ): WatchedItem = + WatchedItem( + id = id, + type = "series", + name = "Show", + season = season, + episode = episode, + markedAtEpochMs = markedAtEpochMs, + ) + private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelectorTest.kt index 45fa4740..000d00da 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 @@ -2,7 +2,9 @@ package com.nuvio.app.features.streams import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class StreamAutoPlaySelectorTest { @@ -145,16 +147,140 @@ 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) + } + + @Test + fun `timeout evaluation keeps pending regex debrid candidate open`() { + val pending = stream( + addonName = "Torrentio", + name = "The Show 1080p", + infoHash = "hash-pending", + cacheState = StreamDebridCacheState.CHECKING, + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(pending), + mode = StreamAutoPlayMode.REGEX_MATCH, + regexPattern = "1080p", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Torrentio"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertNull(evaluation.stream) + assertTrue(evaluation.hasPendingDebridCandidate) + } + + @Test + fun `timeout evaluation still selects direct link while debrid candidate is pending`() { + val pending = stream( + addonName = "Torrentio", + name = "The Show 1080p", + infoHash = "hash-pending", + cacheState = StreamDebridCacheState.CHECKING, + ) + val direct = stream( + addonName = "Direct Addon", + url = "https://example.com/video.mp4", + name = "The Show 1080p", + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(pending, direct), + mode = StreamAutoPlayMode.REGEX_MATCH, + regexPattern = "1080p", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Torrentio", "Direct Addon"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertEquals(direct, evaluation.stream) + assertFalse(evaluation.hasPendingDebridCandidate) + } + + @Test + fun `direct debrid candidate must match active resolver`() { + val torbox = stream( + addonName = "Comet", + name = "TB Instant", + directDebrid = true, + directDebridService = "torbox", + ) + + val evaluation = StreamAutoPlaySelector.evaluateAutoPlayStream( + streams = listOf(torbox), + mode = StreamAutoPlayMode.FIRST_STREAM, + regexPattern = "", + source = StreamAutoPlaySource.ALL_SOURCES, + installedAddonNames = setOf("Comet"), + selectedAddons = emptySet(), + selectedPlugins = emptySet(), + debridEnabled = true, + activeResolverProviderId = "premiumize", + ) + + assertNull(evaluation.stream) + assertFalse(evaluation.hasPendingDebridCandidate) + } + private fun stream( addonName: String, url: String? = null, name: String? = null, bingeGroup: String? = null, + directDebrid: Boolean = false, + directDebridService: String = "torbox", + infoHash: String? = null, + cacheState: StreamDebridCacheState? = null, ): StreamItem = StreamItem( name = name, url = url, + infoHash = infoHash, addonName = addonName, - addonId = addonName, + addonId = "addon:$addonName", + clientResolve = if (directDebrid) { + StreamClientResolve( + type = "debrid", + service = directDebridService, + isCached = true, + infoHash = "hash", + ) + } else { + null + }, + debridCacheStatus = cacheState?.let { state -> + StreamDebridCacheStatus( + providerId = "premiumize", + providerName = "Premiumize", + state = state, + ) + }, behaviorHints = StreamBehaviorHints( bingeGroup = bingeGroup, ), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 09434fac..41519dac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -120,4 +120,55 @@ class StreamParserTest { assertEquals("ok", responseHeaders["x-test"]) } + @Test + fun `parse keeps client resolve metadata without direct URL`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "name": "Instant", + "clientResolve": { + "type": "debrid", + "infoHash": "abc123", + "fileIdx": 4, + "sources": ["udp://tracker.example"], + "torrentName": "Movie Pack", + "filename": "Movie.2024.2160p.mkv", + "service": "torbox", + "isCached": true, + "stream": { + "raw": { + "size": 1610612736, + "indexer": "Indexer", + "parsed": { + "parsed_title": "Movie", + "year": 2024, + "resolution": "2160p", + "hdr": ["DV"], + "audio": ["Atmos"], + "episodes": [1, 2], + "bit_depth": "10bit" + } + } + } + } + } + ] + } + """.trimIndent(), + addonName = "Debrid Fixture", + 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/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index b6112ba8..883e47e6 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.watchprogress +import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo import kotlin.test.Test import kotlin.test.assertEquals @@ -95,6 +96,23 @@ class WatchProgressRulesTest { assertEquals(2, result.size) } + @Test + fun `cloud continue watching uses provider poster fallback`() { + val item = WatchProgressEntry( + contentType = "cloud", + parentMetaId = "torbox:Torrent:29773238", + parentMetaType = "cloud", + videoId = "torbox:Torrent:29773238:8", + title = "Cloud file", + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = 1L, + ).toContinueWatchingItem() + + assertEquals(TorboxCloudLibraryPosterUrl, item.poster) + assertEquals(TorboxCloudLibraryPosterUrl, item.imageUrl) + } + @Test fun `continue watching excludes explicit 100 percent entries even when completion flag is false`() { val completedByPercent = entry( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt new file mode 100644 index 00000000..d11d9c64 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -0,0 +1,235 @@ +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 cloudLibraryEnabledKey = "debrid_cloud_library_enabled" + private const val preferredResolverProviderIdKey = "debrid_preferred_resolver_provider_id" + 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 fun syncKeys(): List = + listOf( + enabledKey, + cloudLibraryEnabledKey, + preferredResolverProviderIdKey, + instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + DebridProviders.all().map { providerApiKeyKey(it.id) } + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadCloudLibraryEnabled(): Boolean? = loadBoolean(cloudLibraryEnabledKey) + + actual fun saveCloudLibraryEnabled(enabled: Boolean) { + saveBoolean(cloudLibraryEnabledKey, enabled) + } + + actual fun loadPreferredResolverProviderId(): String? = loadString(preferredResolverProviderIdKey) + + actual fun savePreferredResolverProviderId(providerId: String) { + saveString(preferredResolverProviderIdKey, providerId) + } + + actual fun loadProviderApiKey(providerId: String): String? = + loadString(providerApiKeyKey(providerId)) + + actual fun saveProviderApiKey(providerId: String, apiKey: String) { + saveString(providerApiKeyKey(providerId), apiKey) + } + + actual fun loadTorboxApiKey(): String? = loadProviderApiKey(DebridProviders.TORBOX_ID) + + actual fun saveTorboxApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.TORBOX_ID, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadProviderApiKey(DebridProviders.REAL_DEBRID_ID) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveProviderApiKey(DebridProviders.REAL_DEBRID_ID, 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)) } + loadCloudLibraryEnabled()?.let { put(cloudLibraryEnabledKey, encodeSyncBoolean(it)) } + loadPreferredResolverProviderId()?.let { put(preferredResolverProviderIdKey, encodeSyncString(it)) } + DebridProviders.all().forEach { provider -> + loadProviderApiKey(provider.id)?.let { + put(providerApiKeyKey(provider.id), 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.decodeSyncBoolean(cloudLibraryEnabledKey)?.let(::saveCloudLibraryEnabled) + payload.decodeSyncString(preferredResolverProviderIdKey)?.let(::savePreferredResolverProviderId) + DebridProviders.all().forEach { provider -> + payload.decodeSyncString(providerApiKeyKey(provider.id))?.let { apiKey -> + saveProviderApiKey(provider.id, apiKey) + } + } + 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) + } + + private fun providerApiKeyKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return when (normalized) { + DebridProviders.TORBOX_ID -> torboxApiKeyKey + DebridProviders.REAL_DEBRID_ID -> realDebridApiKeyKey + else -> "debrid_${normalized}_api_key" + } + } +} diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 10b019f6..48de7ef6 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=64 -MARKETING_VERSION=0.1.22 +MARKETING_VERSION=0.1.0