Merge branch 'stripdebrid' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-22 17:19:30 +05:30
commit ada9bc00f7
77 changed files with 11954 additions and 654 deletions

View file

@ -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(

View file

@ -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)

View file

@ -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<String> =
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"
}
}
}

View file

@ -379,6 +379,7 @@
<string name="compose_settings_page_appearance">Utseende</string>
<string name="compose_settings_page_content_discovery">Innhold &amp; oppdagelse</string>
<string name="compose_settings_page_continue_watching">Fortsett å se</string>
<string name="compose_settings_page_debrid">Tilkoblede tjenester</string>
<string name="compose_settings_page_homescreen">Hjemmeoppsett</string>
<string name="compose_settings_page_integrations">Integrasjoner</string>
<string name="compose_settings_page_licenses_attributions">Lisenser &amp; attribusjon</string>
@ -587,6 +588,48 @@
<string name="settings_integrations_section_title">Integrasjoner</string>
<string name="settings_integrations_tmdb_description">Metadata-berikelse-kontroller</string>
<string name="settings_integrations_mdblist_description">Eksterne vurderingsleverandører</string>
<string name="settings_integrations_debrid_description">Koble til kontoer for lenker og bibliotektilgang</string>
<string name="settings_debrid_section_title">Tilkoblede tjenester</string>
<string name="settings_debrid_experimental_notice">Disse integrasjonene er eksperimentelle og kan endres eller fjernes senere.</string>
<string name="settings_debrid_cloud_library">Skybibliotek</string>
<string name="settings_debrid_cloud_library_description">Bla gjennom og spill filer som allerede finnes i tilkoblede kontoer.</string>
<string name="settings_debrid_enable">Løs spillbare lenker</string>
<string name="settings_debrid_enable_description">Be en tilkoblet tjeneste om spillbare lenker når et resultat trenger det. Dette kan legge elementet til i den tjenesten.</string>
<string name="settings_debrid_resolve_with">Løs med</string>
<string name="settings_debrid_resolve_with_description">Velg hvilken tilkoblet konto som håndterer spillbare lenker.</string>
<string name="settings_debrid_add_key_first">Koble til en konto først.</string>
<string name="settings_debrid_section_providers">Kontoer</string>
<string name="settings_debrid_provider_description">Koble til %1$s-kontoen din.</string>
<string name="settings_debrid_provider_device_description">Koble til %1$s-kontoen din i nettleseren.</string>
<string name="settings_debrid_dialog_title">%1$s API-nøkkel</string>
<string name="settings_debrid_dialog_subtitle">Skriv inn API-nøkkelen din for %1$s.</string>
<string name="settings_debrid_dialog_placeholder">Skriv inn %1$s API-nøkkel</string>
<string name="settings_debrid_connected">Tilkoblet</string>
<string name="settings_debrid_connect_provider">Koble til %1$s</string>
<string name="settings_debrid_disconnect_provider">Koble fra %1$s</string>
<string name="settings_debrid_disconnect">Koble fra</string>
<string name="settings_debrid_device_auth_connected">%1$s er koblet til på denne enheten.</string>
<string name="settings_debrid_device_auth_starting">Starter sikker innlogging...</string>
<string name="settings_debrid_device_auth_instructions">Åpne lenken og skriv inn denne koden for å godkjenne Nuvio.</string>
<string name="settings_debrid_device_auth_code_copied">Kode kopiert.</string>
<string name="settings_debrid_device_auth_open">Åpne lenke</string>
<string name="settings_debrid_device_auth_waiting">Venter på godkjenning...</string>
<string name="settings_debrid_device_auth_failed">Kunne ikke starte innlogging.</string>
<string name="settings_debrid_device_auth_missing_configuration">Denne innloggingsmetoden er ikke konfigurert i denne versjonen.</string>
<string name="settings_debrid_device_auth_expired">Denne koden er utløpt. Prøv igjen.</string>
<string name="settings_debrid_section_instant_playback">Lenkeforberedelse</string>
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
<string name="settings_debrid_prepare_instant_playback_description">Løs spillbare lenker før avspilling starter.</string>
<string name="settings_debrid_prepare_stream_count">Lenker å forberede</string>
<string name="settings_debrid_prepare_count_one">1 lenke</string>
<string name="settings_debrid_prepare_count_many">%1$d lenker</string>
<string name="settings_debrid_section_formatting">Formatering</string>
<string name="settings_debrid_name_template">Navnemal</string>
<string name="settings_debrid_name_template_description">Styrer hvordan resultatnavn vises.</string>
<string name="settings_debrid_description_template">Beskrivelsesmal</string>
<string name="settings_debrid_description_template_description">Styrer metadata vist under hvert resultat.</string>
<string name="settings_debrid_key_valid">API-nøkkel validert.</string>
<string name="settings_debrid_key_invalid">Kunne ikke validere denne API-nøkkelen.</string>
<string name="settings_mdb_add_api_key_first">Legg til MDBList API-nøkkel før du skrur på vurderinger.</string>
<string name="settings_mdb_api_key_description">Kreves for å hente vurderinger fra MDBList</string>
<string name="settings_mdb_api_key_label">API-nøkkel</string>
@ -1122,6 +1165,9 @@
<string name="streams_resume_from_time">Gjenoppta fra %1$s</string>
<string name="streams_size">STØRRELSE %1$s</string>
<string name="streams_torrent_not_supported">Denne strømtypen støttes ikke</string>
<string name="debrid_missing_api_key">Koble til en konto i Innstillinger.</string>
<string name="debrid_stream_stale">Denne lenken er utgått. Oppdaterer resultater.</string>
<string name="debrid_resolve_failed">Kunne ikke åpne denne lenken.</string>
<string name="external_player_failed">Kunne ikke åpne ekstern avspiller</string>
<string name="external_player_not_configured">Velg en ekstern avspiller i innstillinger først</string>
<string name="external_player_unavailable">Ingen ekstern avspiller er tilgjengelig</string>

View file

@ -380,6 +380,7 @@
<string name="compose_settings_page_appearance">Wygląd</string>
<string name="compose_settings_page_content_discovery">Treści i odkrywanie</string>
<string name="compose_settings_page_continue_watching">Kontynuuj oglądanie</string>
<string name="compose_settings_page_debrid">Debrid</string>
<string name="compose_settings_page_homescreen">Ekran główny</string>
<string name="compose_settings_page_integrations">Integracje</string>
<string name="compose_settings_page_licenses_attributions">Licencje i atrybucje</string>
@ -588,6 +589,34 @@
<string name="settings_integrations_section_title">INTEGRACJE</string>
<string name="settings_integrations_tmdb_description">Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko.</string>
<string name="settings_integrations_mdblist_description">Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów.</string>
<string name="settings_integrations_debrid_description">Eksperymentalne źródła z kont chmurowych</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Obsługa Debrid jest eksperymentalna i może zostać zachowana, zmieniona lub usunięta w przyszłości.</string>
<string name="settings_debrid_enable">Włącz źródła</string>
<string name="settings_debrid_enable_description">Pokaż odtwarzalne wyniki z połączonych kont.</string>
<string name="settings_debrid_add_key_first">Najpierw dodaj klucz API.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_torbox_description">Połącz swoje konto Torbox.</string>
<string name="settings_debrid_dialog_title">Klucz API Torbox</string>
<string name="settings_debrid_dialog_subtitle">Wprowadź swój klucz API Torbox.</string>
<string name="settings_debrid_dialog_placeholder">Wprowadź klucz API Torbox</string>
<string name="settings_debrid_not_set">Nie ustawiono</string>
<string name="settings_debrid_section_instant_playback">Natychmiastowe odtwarzanie</string>
<string name="settings_debrid_prepare_instant_playback">Przygotuj linki</string>
<string name="settings_debrid_prepare_instant_playback_description">Rozwiąż pierwsze źródła przed rozpoczęciem odtwarzania.</string>
<string name="settings_debrid_prepare_stream_count">Źródła do przygotowania</string>
<string name="settings_debrid_prepare_stream_count_warning">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.</string>
<string name="settings_debrid_prepare_count_one">1 źródło</string>
<string name="settings_debrid_prepare_count_many">%1$d źródeł</string>
<string name="settings_debrid_section_formatting">Formatowanie</string>
<string name="settings_debrid_name_template">Szablon nazwy</string>
<string name="settings_debrid_name_template_description">Kontroluje sposób wyświetlania nazw źródeł.</string>
<string name="settings_debrid_description_template">Szablon opisu</string>
<string name="settings_debrid_description_template_description">Kontroluje metadane wyświetlane pod każdym źródłem.</string>
<string name="settings_debrid_formatter_reset_title">Resetuj formatowanie</string>
<string name="settings_debrid_formatter_reset_subtitle">Przywróć domyślne formatowanie źródeł.</string>
<string name="settings_debrid_key_valid">Klucz API zweryfikowany.</string>
<string name="settings_debrid_key_invalid">Nie udało się zweryfikować tego klucza API.</string>
<string name="settings_mdb_add_api_key_first">Dodaj klucz API MDBList poniżej przed włączeniem ocen.</string>
<string name="settings_mdb_api_key_description">Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj.</string>
<string name="settings_mdb_api_key_label">Klucz API</string>
@ -1125,6 +1154,9 @@
<string name="streams_resume_from_time">Wznów od %1$s</string>
<string name="streams_size">ROZMIAR %1$s</string>
<string name="streams_torrent_not_supported">Ten typ strumienia nie jest obsługiwany</string>
<string name="debrid_missing_api_key">Dodaj klucz API Debrid w Ustawieniach.</string>
<string name="debrid_stream_stale">Ten wynik Debrid wygasł. Odświeżanie strumieni.</string>
<string name="debrid_resolve_failed">Nie udało się rozwiązać tego strumienia Debrid.</string>
<string name="external_player_failed">Nie udało się otworzyć zewnętrznego odtwarzacza</string>
<string name="external_player_not_configured">Najpierw wybierz zewnętrzny odtwarzacz w ustawieniach</string>
<string name="external_player_unavailable">Brak dostępnego zewnętrznego odtwarzacza</string>

View file

@ -380,6 +380,7 @@
<string name="compose_settings_page_appearance">Layout</string>
<string name="compose_settings_page_content_discovery">Content &amp; Discovery</string>
<string name="compose_settings_page_continue_watching">Continue Watching</string>
<string name="compose_settings_page_debrid">Connected Services</string>
<string name="compose_settings_page_homescreen">Home Layout</string>
<string name="compose_settings_page_integrations">Integrations</string>
<string name="compose_settings_page_licenses_attributions">Licenses &amp; Attribution</string>
@ -588,6 +589,52 @@
<string name="settings_integrations_section_title">Integrations</string>
<string name="settings_integrations_tmdb_description">Metadata enrichment controls</string>
<string name="settings_integrations_mdblist_description">External ratings providers</string>
<string name="settings_integrations_debrid_description">Connect accounts for links and library access</string>
<string name="settings_debrid_section_title">Connected Services</string>
<string name="settings_debrid_experimental_notice">These integrations are experimental and may be kept, changed, or removed later.</string>
<string name="settings_debrid_cloud_library">Cloud library</string>
<string name="settings_debrid_cloud_library_description">Browse and play files already in your connected accounts.</string>
<string name="settings_debrid_enable">Resolve playable links</string>
<string name="settings_debrid_enable_description">Ask a connected service for playable links when a result needs it. This may add the item to that service.</string>
<string name="settings_debrid_resolve_with">Resolve with</string>
<string name="settings_debrid_resolve_with_description">Choose which connected account handles playable links.</string>
<string name="settings_debrid_add_key_first">Connect an account first.</string>
<string name="settings_debrid_section_providers">Accounts</string>
<string name="settings_debrid_provider_description">Connect your %1$s account.</string>
<string name="settings_debrid_provider_device_description">Link your %1$s account in the browser.</string>
<string name="settings_debrid_dialog_title">%1$s API Key</string>
<string name="settings_debrid_dialog_subtitle">Enter your %1$s API key.</string>
<string name="settings_debrid_dialog_placeholder">Enter %1$s API key</string>
<string name="settings_debrid_not_set">Not set</string>
<string name="settings_debrid_connected">Connected</string>
<string name="settings_debrid_connect_provider">Connect %1$s</string>
<string name="settings_debrid_disconnect_provider">Disconnect %1$s</string>
<string name="settings_debrid_disconnect">Disconnect</string>
<string name="settings_debrid_device_auth_connected">%1$s is connected on this device.</string>
<string name="settings_debrid_device_auth_starting">Starting secure sign-in...</string>
<string name="settings_debrid_device_auth_instructions">Open the link and enter this code to approve Nuvio.</string>
<string name="settings_debrid_device_auth_code_copied">Code copied.</string>
<string name="settings_debrid_device_auth_open">Open link</string>
<string name="settings_debrid_device_auth_waiting">Waiting for approval...</string>
<string name="settings_debrid_device_auth_failed">Could not start sign-in.</string>
<string name="settings_debrid_device_auth_missing_configuration">This sign-in method is not configured in this build.</string>
<string name="settings_debrid_device_auth_expired">This code expired. Try again.</string>
<string name="settings_debrid_section_instant_playback">Link Preparation</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve playable links before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Links to prepare</string>
<string name="settings_debrid_prepare_stream_count_warning">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.</string>
<string name="settings_debrid_prepare_count_one">1 link</string>
<string name="settings_debrid_prepare_count_many">%1$d links</string>
<string name="settings_debrid_section_formatting">Formatting</string>
<string name="settings_debrid_name_template">Name template</string>
<string name="settings_debrid_name_template_description">Controls how result names appear.</string>
<string name="settings_debrid_description_template">Description template</string>
<string name="settings_debrid_description_template_description">Controls the metadata shown under each result.</string>
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
<string name="settings_debrid_formatter_reset_subtitle">Restore default result formatting.</string>
<string name="settings_debrid_key_valid">API key validated.</string>
<string name="settings_debrid_key_invalid">Could not validate this API key.</string>
<string name="settings_mdb_add_api_key_first">Add your MDBList API key below before turning ratings on.</string>
<string name="settings_mdb_api_key_description">Required to fetch ratings from MDBList</string>
<string name="settings_mdb_api_key_label">API Key</string>
@ -1126,6 +1173,10 @@
<string name="streams_resume_from_time">Resume from %1$s</string>
<string name="streams_size">SIZE %1$s</string>
<string name="streams_torrent_not_supported">This stream type is not supported</string>
<string name="debrid_missing_api_key">Connect an account in Settings.</string>
<string name="debrid_not_cached">Not cached on Torbox.</string>
<string name="debrid_stream_stale">This link expired. Refreshing results.</string>
<string name="debrid_resolve_failed">Could not open this link.</string>
<string name="external_player_failed">Couldn&apos;t open external player</string>
<string name="external_player_not_configured">Choose an external player in settings first</string>
<string name="external_player_unavailable">No external player is available</string>
@ -1277,11 +1328,42 @@
<string name="library_empty_title">Your library is empty</string>
<string name="library_load_failed">Couldn't load library</string>
<string name="library_other">Other</string>
<string name="library_source_cloud">Cloud</string>
<string name="library_source_saved">Saved</string>
<string name="library_title">Library</string>
<string name="library_trakt_empty_message">Connect Trakt and save titles to your watchlist or personal lists.</string>
<string name="library_trakt_empty_title">Your Trakt library is empty</string>
<string name="library_trakt_load_failed">Couldn't load Trakt library</string>
<string name="library_trakt_title">Trakt Library</string>
<string name="cloud_library_connect_action">Connect account</string>
<string name="cloud_library_connect_message">Connect an account in Connected Services settings to browse playable files from your cloud library.</string>
<string name="cloud_library_connect_title">No cloud account connected</string>
<string name="cloud_library_disabled_action">Open Connected Services</string>
<string name="cloud_library_disabled_message">Turn on Cloud library in Connected Services settings to browse files from connected accounts.</string>
<string name="cloud_library_disabled_title">Cloud library is off</string>
<string name="cloud_library_empty_message">No playable cloud files match the current filters.</string>
<string name="cloud_library_empty_title">Nothing here yet</string>
<string name="cloud_library_file_picker_title">Choose a file to play</string>
<string name="cloud_library_load_failed">Couldn't load %1$s cloud library</string>
<string name="cloud_library_no_files_message">This item does not expose a playable video file.</string>
<string name="cloud_library_no_files_title">No playable files</string>
<string name="cloud_library_no_playable_files">No playable files</string>
<string name="cloud_library_play_disabled">Cloud library is off.</string>
<string name="cloud_library_play_failed">Couldn't play this cloud file.</string>
<string name="cloud_library_play_file">Play file</string>
<string name="cloud_library_play_not_connected">Cloud service is not connected.</string>
<string name="cloud_library_play_provider_not_connected">%1$s is not connected.</string>
<string name="cloud_library_playable_file_count">%1$d playable files</string>
<string name="cloud_library_provider_all">All</string>
<string name="cloud_library_refresh">Refresh cloud library</string>
<string name="cloud_library_select_provider">Select provider</string>
<string name="cloud_library_select_type">Select type</string>
<string name="cloud_library_status_ready">Ready to play</string>
<string name="cloud_library_type_all">All</string>
<string name="cloud_library_type_torrents">Torrents</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="cloud_library_type_files">Files</string>
<string name="media_anime">Anime</string>
<string name="media_channels">Channels</string>
<string name="media_movies">Movies</string>

View file

@ -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<PosterActionTarget?>(null) }
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var requestedSettingsPageName by rememberSaveable { mutableStateOf<String?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(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<PlayerRoute>(
@ -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,

View file

@ -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 = "",

View file

@ -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)
}

View file

@ -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)

View file

@ -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<NuvioDropdownOption>,
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<NuvioDropdownOption>,
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()
}
}
}
}
}
}

View file

@ -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<CloudLibraryFile> = emptyList(),
) {
val stableKey: String
get() = "$providerId:${type.name}:$id"
val playableFiles: List<CloudLibraryFile>
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<CloudLibraryItem> = 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<CloudLibraryProviderState> = emptyList(),
) {
val items: List<CloudLibraryItem>
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
}

View file

@ -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<List<CloudLibraryItem>>
suspend fun resolvePlayback(
apiKey: String,
item: CloudLibraryItem,
file: CloudLibraryFile,
): CloudLibraryPlaybackResult
}
internal object CloudLibraryProviderApis {
private val registered = listOf(
TorboxCloudLibraryProviderApi(),
PremiumizeCloudLibraryProviderApi(),
)
fun all(): List<CloudLibraryProviderApi> = registered
fun apiFor(providerId: String?): CloudLibraryProviderApi? {
val normalized = DebridProviders.byId(providerId)?.id ?: return null
return registered.firstOrNull { it.provider.id == normalized }
}
}

View file

@ -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<DebridServiceCredential>,
private val providerApis: List<CloudLibraryProviderApi>,
) {
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<CloudConnectionKey> = 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<DebridServiceCredential> =
DebridSettingsRepository.snapshot()
.takeIf { settings -> settings.cloudLibraryEnabled }
?.let(DebridProviders::configuredServices)
.orEmpty()
.filter { credential -> credential.provider.supports(DebridProviderCapability.CloudLibrary) }
private fun connectedCloudConnectionKeys(): List<CloudConnectionKey> =
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
}
}

View file

@ -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<List<CloudLibraryItem>> =
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<PremiumizeCloudFileDto>,
providerId: String,
providerName: String,
): List<CloudLibraryItem> {
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<CloudLibraryFile> { !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",
)

View file

@ -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<List<CloudLibraryItem>> =
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<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.itemsOrThrow(
type: CloudLibraryItemType,
): List<CloudLibraryItem> {
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<String?>.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",
)

View file

@ -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<T>(
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<TorboxEnvelopeDto<TorboxDeviceAuthorizationDto>> =
requestWithoutAuth(
method = "GET",
url = "$BASE_URL/v1/api/user/auth/device/start?${
queryString("app" to appName)
}",
)
suspend fun redeemDeviceAuthorization(
deviceCode: String,
): DebridApiResponse<TorboxEnvelopeDto<TorboxDeviceTokenDto>> =
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<String>,
): DebridApiResponse<TorboxEnvelopeDto<Map<String, TorboxCachedItemDto>>> {
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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>> {
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<TorboxEnvelopeDto<TorboxTorrentDataDto>> =
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<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/torrents/mylist",
apiKey = apiKey,
)
suspend fun listCloudUsenet(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/usenet/mylist",
apiKey = apiKey,
)
suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
request(
method = "GET",
url = "$BASE_URL/v1/api/webdl/mylist",
apiKey = apiKey,
)
suspend fun requestDownloadLink(
apiKey: String,
torrentId: Int,
fileId: Int?,
): DebridApiResponse<TorboxEnvelopeDto<String>> =
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<TorboxEnvelopeDto<String>> =
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<TorboxEnvelopeDto<String>> =
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<TorboxEnvelopeDto<String>> =
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 <reified T> request(
method: String,
url: String,
apiKey: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
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<T>(),
rawBody = response.body,
)
}
private suspend inline fun <reified T> requestWithoutAuth(
method: String,
url: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
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<T>(),
rawBody = response.body,
)
}
private fun authHeaders(apiKey: String): Map<String, String> =
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<RealDebridAddTorrentDto> =
formRequest(
method = "POST",
url = "$BASE_URL/torrents/addMagnet",
apiKey = apiKey,
fields = listOf("magnet" to magnet),
)
suspend fun getTorrentInfo(apiKey: String, id: String): DebridApiResponse<RealDebridTorrentInfoDto> =
request(
method = "GET",
url = "$BASE_URL/torrents/info/${encodePathSegment(id)}",
apiKey = apiKey,
)
suspend fun selectFiles(apiKey: String, id: String, files: String): DebridApiResponse<Unit> =
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<RealDebridUnrestrictLinkDto> =
formRequest(
method = "POST",
url = "$BASE_URL/unrestrict/link",
apiKey = apiKey,
fields = listOf("link" to link),
)
suspend fun deleteTorrent(apiKey: String, id: String): DebridApiResponse<Unit> =
request(
method = "DELETE",
url = "$BASE_URL/torrents/delete/${encodePathSegment(id)}",
apiKey = apiKey,
)
private suspend inline fun <reified T> formRequest(
method: String,
url: String,
apiKey: String,
fields: List<Pair<String, String>>,
): DebridApiResponse<T> {
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 <reified T> request(
method: String,
url: String,
apiKey: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
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<T>(),
rawBody = response.body,
)
}
private fun authHeaders(apiKey: String): Map<String, String> =
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<PremiumizeDeviceAuthorizationDto> =
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<PremiumizeDeviceTokenDto> =
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<PremiumizeAccountInfoDto> =
request(
method = "GET",
url = "$BASE_URL/api/account/info",
apiKey = apiKey,
)
suspend fun listAllItems(apiKey: String): DebridApiResponse<PremiumizeItemListAllDto> =
request(
method = "GET",
url = "$BASE_URL/api/item/listall",
apiKey = apiKey,
)
suspend fun itemDetails(
apiKey: String,
itemId: String,
): DebridApiResponse<PremiumizeItemDetailsDto> =
request(
method = "GET",
url = "$BASE_URL/api/item/details?${queryString("id" to itemId)}",
apiKey = apiKey,
)
suspend fun directDownload(
apiKey: String,
source: String,
): DebridApiResponse<PremiumizeDirectDownloadDto> =
formRequest(
method = "POST",
url = "$BASE_URL/api/transfer/directdl",
apiKey = apiKey,
fields = listOf("src" to source),
)
suspend fun checkCache(
apiKey: String,
items: List<String>,
): DebridApiResponse<PremiumizeCacheCheckDto> {
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 <reified T> formRequestWithoutAuth(
method: String,
url: String,
fields: List<Pair<String, String>>,
): DebridApiResponse<T> =
requestWithoutAuth(
method = method,
url = url,
body = formBody(fields),
contentType = "application/x-www-form-urlencoded",
)
private suspend inline fun <reified T> formRequest(
method: String,
url: String,
apiKey: String,
fields: List<Pair<String, String>>,
): DebridApiResponse<T> =
request(
method = method,
url = url,
apiKey = apiKey,
body = formBody(fields),
contentType = "application/x-www-form-urlencoded",
)
private suspend inline fun <reified T> requestWithoutAuth(
method: String,
url: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
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<T>(),
rawBody = response.body,
)
}
private suspend inline fun <reified T> request(
method: String,
url: String,
apiKey: String,
body: String = "",
contentType: String? = null,
): DebridApiResponse<T> {
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<T>(),
rawBody = response.body,
)
}
private fun formBody(fields: List<Pair<String, String>>): String =
fields.joinToString("&") { (key, value) ->
"${encodeFormValue(key)}=${encodeFormValue(value)}"
}
private fun authHeaders(apiKey: String): Map<String, String> =
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 <reified T> RawHttpResponse.decodeBody(): T? {
if (body.isBlank() || T::class == Unit::class) return null
return try {
DebridApiJson.json.decodeFromString<T>(body)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private fun multipartFormBody(boundary: String, vararg fields: Pair<String, String>): 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")
}

View file

@ -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<T>(
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<TorboxTorrentFileDto>? = 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<TorboxCloudFileDto>? = 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<String>,
)
@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<RealDebridTorrentFileDto>? = null,
val links: List<String>? = 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<PremiumizeDirectDownloadFileDto>? = 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<Boolean>? = null,
val filename: List<String?>? = null,
val filesize: List<JsonElement?>? = null,
)
@Serializable
internal data class PremiumizeItemListAllDto(
val status: String? = null,
val message: String? = null,
val code: String? = null,
val files: List<PremiumizeCloudFileDto>? = 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,
)

View file

@ -0,0 +1,220 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
internal class TorboxFileSelector {
fun selectFile(
files: List<TorboxTorrentFileDto>,
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<RealDebridTorrentFileDto>,
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<PremiumizeDirectDownloadFileDto>,
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<String>): List<String> {
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<String>): Boolean {
val lower = lowercase()
return lower.hasVideoExtension() || episodePatterns.any { pattern -> lower.contains(pattern) }
}
private fun <T> List<T>.firstNameMatch(
names: List<String>,
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<String> {
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",
)

View file

@ -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() }
}
}

View file

@ -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<DebridProviderCapability> = 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<DebridProvider> = registered
fun visible(): List<DebridProvider> = 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<DebridServiceCredential> =
registered.mapNotNull { provider ->
settings.apiKeyFor(provider.id)
.trim()
.takeIf { provider.visibleInUi && it.isNotBlank() }
?.let { apiKey -> DebridServiceCredential(provider, apiKey) }
}
fun configuredResolverServices(settings: DebridSettings): List<DebridServiceCredential> =
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<String> =
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

View file

@ -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<PremiumizeDeviceAuthorizationDto>,
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<TorboxEnvelopeDto<TorboxDeviceTokenDto>>,
): 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<PremiumizeDeviceTokenDto>,
): 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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
409 -> DirectDebridResolveResult.NotCached
else -> DirectDebridResolveResult.Stale
}
private fun DebridApiResponse<RealDebridAddTorrentDto>.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() }
}

View file

@ -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<String, String> = 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<DebridServiceCredential>
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> = DebridStreamResolution.defaultOrder,
val requiredResolutions: List<DebridStreamResolution> = emptyList(),
val excludedResolutions: List<DebridStreamResolution> = emptyList(),
val preferredQualities: List<DebridStreamQuality> = DebridStreamQuality.defaultOrder,
val requiredQualities: List<DebridStreamQuality> = emptyList(),
val excludedQualities: List<DebridStreamQuality> = emptyList(),
val preferredVisualTags: List<DebridStreamVisualTag> = DebridStreamVisualTag.defaultOrder,
val requiredVisualTags: List<DebridStreamVisualTag> = emptyList(),
val excludedVisualTags: List<DebridStreamVisualTag> = emptyList(),
val preferredAudioTags: List<DebridStreamAudioTag> = DebridStreamAudioTag.defaultOrder,
val requiredAudioTags: List<DebridStreamAudioTag> = emptyList(),
val excludedAudioTags: List<DebridStreamAudioTag> = emptyList(),
val preferredAudioChannels: List<DebridStreamAudioChannel> = DebridStreamAudioChannel.defaultOrder,
val requiredAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
val excludedAudioChannels: List<DebridStreamAudioChannel> = emptyList(),
val preferredEncodes: List<DebridStreamEncode> = DebridStreamEncode.defaultOrder,
val requiredEncodes: List<DebridStreamEncode> = emptyList(),
val excludedEncodes: List<DebridStreamEncode> = emptyList(),
val preferredLanguages: List<DebridStreamLanguage> = emptyList(),
val requiredLanguages: List<DebridStreamLanguage> = emptyList(),
val excludedLanguages: List<DebridStreamLanguage> = emptyList(),
val requiredReleaseGroups: List<String> = emptyList(),
val excludedReleaseGroups: List<String> = emptyList(),
val sortCriteria: List<DebridStreamSortCriterion> = 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)

View file

@ -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<DebridSettings> = _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<String, String>()
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<String> =
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 <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
runCatching { enumValueOf<T>(value.orEmpty()) }.getOrDefault(default)
private fun parseStreamPreferences(value: String?): DebridStreamPreferences? {
if (value.isNullOrBlank()) return null
return try {
json.decodeFromString<DebridStreamPreferences>(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> =
DebridStreamResolution.defaultOrder.filter {
it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN
}
private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List<DebridStreamSortCriterion> =
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,
)

View file

@ -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)
}

View file

@ -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<String, Any?> {
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<Int>,
episodes: List<Int>,
): List<String> {
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<Int>): String =
episodes.joinToString(" | ") { "E${it.twoDigits()}" }
private fun formatSeasons(seasons: List<Int>): String =
seasons.joinToString(" | ") { "S${it.twoDigits()}" }
private fun List<Int>.singleOrFirstOrNull(): Int? =
singleOrNull() ?: firstOrNull()
private fun Int.twoDigits(): String = toString().padStart(2, '0')
private fun languageValues(parsed: StreamClientResolveParsed?, facts: DebridStreamFacts): List<String> =
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 <T> List<T>.mapNotUnknown(label: (T) -> String): List<String> =
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 }
}

View file

@ -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}\"||\"\"]}"
}

View file

@ -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<AddonStreamGroup>, settings: DebridSettings): List<AddonStreamGroup> {
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<StreamItem>, settings: DebridSettings): List<StreamItem> {
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<Pair<StreamItem, DebridStreamFacts>>,
preferences: DebridStreamPreferences,
): List<Pair<StreamItem, DebridStreamFacts>> {
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
val result = mutableListOf<Pair<StreamItem, DebridStreamFacts>>()
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<DebridStreamSortCriterion>,
): 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<String>, searchText: String): List<DebridStreamVisualTag> {
val text = (parsedHdr + searchText).joinToString(" ").lowercase()
val tags = mutableListOf<DebridStreamVisualTag>()
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<String>, searchText: String): List<DebridStreamAudioTag> {
val text = (parsedAudio + searchText).joinToString(" ").lowercase()
val tags = mutableListOf<DebridStreamAudioTag>()
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<String>, searchText: String): List<DebridStreamAudioChannel> {
val text = (parsedChannels + searchText).joinToString(" ").lowercase()
val channels = mutableListOf<DebridStreamAudioChannel>()
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 <T> rank(value: T, preferred: List<T>): Int {
val index = preferred.indexOf(value)
return if (index >= 0) index else Int.MAX_VALUE
}
private fun <T> rankAny(values: List<T>, preferred: List<T>): 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<DebridStreamVisualTag>,
val audioTags: List<DebridStreamAudioTag>,
val audioChannels: List<DebridStreamAudioChannel>,
val encode: DebridStreamEncode,
val languages: List<DebridStreamLanguage>,
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

View file

@ -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, Any?>): 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, Any?>): 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<String, Any?>): Boolean {
val tokens = splitOps(expression).filter { it.isNotBlank() }
if (tokens.isEmpty()) return false
val groups = mutableListOf<MutableList<Boolean>>()
var currentGroup = mutableListOf<Boolean>()
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<String>()
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<String>): 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<String> {
val tokens = mutableListOf<String>()
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<String, String> {
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<String> {
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<String>()
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"
}
}
}

View file

@ -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, String?>): 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])
}
}
}
}

View file

@ -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<String, CachedDirectDebridResolve>()
private val inFlightResolves = mutableMapOf<String, Deferred<DirectDebridResolveResult>>()
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<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.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,
)

View file

@ -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<Long>()
private val hourStarts = ArrayDeque<Long>()
suspend fun prepare(
streams: List<StreamItem>,
season: Int?,
episode: Int?,
playerSettings: PlayerSettingsUiState,
installedAddonNames: Set<String>,
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<StreamItem>,
limit: Int,
playerSettings: PlayerSettingsUiState,
installedAddonNames: Set<String>,
): List<StreamItem> {
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<StreamItem>()
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<AddonStreamGroup>,
original: StreamItem,
prepared: StreamItem,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
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<Long>.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())
}

View file

@ -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<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
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<AddonStreamGroup>,
eligibleGroupIds: Set<String>? = null,
): List<AddonStreamGroup> {
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<AddonStreamGroup>.updateAvailabilityStatus(
eligibleGroupIds: Set<String>?,
transform: (StreamItem) -> StreamItem,
): List<AddonStreamGroup> =
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
}

View file

@ -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<String>,
): Map<String, LocalDebridCachedItem>? =
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<String>,
): Map<String, LocalDebridCachedItem>? =
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<String>,
): Map<String, LocalDebridCachedItem>? =
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()
}

View file

@ -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)

View file

@ -142,14 +142,33 @@ internal fun MetaDetails.seriesPrimaryAction(
watchedItems: List<WatchedItem>,
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<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
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 =

View file

@ -117,7 +117,7 @@ object DownloadsRepository {
): DownloadEnqueueResult {
ensureLoaded()
val sourceUrl = stream.directPlaybackUrl
val sourceUrl = stream.playableDirectUrl
?.trim()
?.takeIf { it.isNotBlank() }
?: return DownloadEnqueueResult.MissingUrl

View file

@ -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<Set<String>>(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<CompletedSeriesCandidate>,
isTraktProgressActive: Boolean,
daysCap: Int,
nowEpochMs: Long,
): List<CompletedSeriesCandidate> {
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<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
isTraktProgressActive: Boolean,
preferFurthestEpisode: Boolean,
nowEpochMs: Long,
): List<WatchProgressEntry> {
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<CompletedSeriesCandidate> {
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<CompletedSeriesCandidate> { candidate -> candidate.markedAtEpochMs }
.thenByDescending { candidate -> candidate.seasonNumber }
.thenByDescending { candidate -> candidate.episodeNumber },
)
}
internal fun filterNextUpItemsByCurrentSeeds(
nextUpItemsBySeries: Map<String, Pair<Long, ContinueWatchingItem>>,
activeSeedContentIds: Set<String>,
currentSeedByContentId: Map<String, Pair<Int, Int>>,
shouldDropItemsWithoutActiveSeed: Boolean,
): Map<String, Pair<Long, ContinueWatchingItem>> =
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<WatchProgressEntry>): List<WatchProgressEntry> {
val merged = linkedMapOf<String, WatchProgressEntry>()
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<WatchProgressEntry>,
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<WatchProgressEntry>(
{ it.seasonNumber ?: -1 },
{ it.episodeNumber ?: -1 },
{ it.lastUpdatedEpochMs },
)
} else {
compareBy<WatchProgressEntry>(
{ 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<String>? = null,
sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT,
todayIsoDate: String = "",
cloudLibraryUiState: CloudLibraryUiState? = null,
): List<ContinueWatchingItem> {
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)

View file

@ -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,
)
}
}

View file

@ -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),

View file

@ -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<String?>(null) }
var selectedTypeName by rememberSaveable { mutableStateOf<String?>(null) }
val selectedType = remember(selectedTypeName) {
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
}
var selectedCloudItemKey by rememberSaveable { mutableStateOf<String?>(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<CloudLibraryItemType>,
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<LibrarySection>,
watchedKeys: Set<String>,
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 {

View file

@ -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),

View file

@ -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<Unit>()
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>): 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

View file

@ -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
}

View file

@ -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<Job>()
fun emptyStateReason(groups: List<AddonStreamGroup>, 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()

View file

@ -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<DiscoverOptionItem>,
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<DiscoverOptionItem>,
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<MetaPreview>,
@ -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()) {

View file

@ -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,
)
}
}
}

View file

@ -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,
)
}
}
}

View file

@ -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,

View file

@ -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<Unit> = 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,

View file

@ -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<AddonStreamWarmupKey, CachedAddonStreamWarmup>()
private val inFlight = mutableMapOf<AddonStreamWarmupKey, Deferred<List<AddonStreamGroup>>>()
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<AddonStreamGroup>? {
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<AddonStreamGroup> {
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<AddonStreamGroup> {
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<AddonStreamGroup>? {
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<AddonStreamWarmupTarget>,
)
private data class AddonStreamWarmupTarget(
val addonName: String,
val addonId: String,
val manifest: AddonManifest,
val fingerprint: String,
)
private data class CachedAddonStreamWarmup(
val groups: List<AddonStreamGroup>,
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 <T> runCatchingUnlessCancelled(block: suspend () -> T): Result<T> =
try {
Result.success(block())
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
Result.failure(error)
}

View file

@ -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<StreamItem>,
mode: StreamAutoPlayMode,
regexPattern: String,
source: StreamAutoPlaySource,
installedAddonNames: Set<String>,
selectedAddons: Set<String>,
selectedPlugins: Set<String>,
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<StreamItem> = emptyList(),
val hasPendingDebridCandidate: Boolean = false,
)

View file

@ -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<String, String>? = 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<String> = 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<Int> = emptyList(),
val episodes: List<Int> = emptyList(),
val quality: String? = null,
val hdr: List<String> = emptyList(),
val codec: String? = null,
val audio: List<String> = emptyList(),
val channels: List<String> = emptyList(),
val languages: List<String> = 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<StreamItem> = emptyList(),
val isDirectAutoPlayFlow: Boolean = false,
val showDirectAutoPlayOverlay: Boolean = false,
) {

View file

@ -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<Int> =
(this[name] as? JsonArray)
?.mapNotNull { it.jsonPrimitive.intOrNull }
.orEmpty()
private fun JsonObject.stringMap(): Map<String, String> =
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"),
)
}

View file

@ -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<StreamLoadCompletion>(capacity = Channel.BUFFERED)
val pluginRemainingByAddonId = pluginProviderGroups
.associate { it.addonId to it.scrapers.size }
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
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<Job>()
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

View file

@ -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<StreamItem?>(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

View file

@ -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,

View file

@ -41,6 +41,7 @@ fun nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
showUnairedNextUp: Boolean = false,
): WatchingReleasedEpisode? {
val sortedEpisodes = episodes.sortedWith(
compareBy<WatchingReleasedEpisode>({ 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<WatchingWatchedRecord>,
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

View file

@ -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 {

View file

@ -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<CloudLibraryItem>,
) : CloudLibraryProviderApi {
var resolvePlaybackCalls: Int = 0
private set
override suspend fun listItems(apiKey: String): Result<List<CloudLibraryItem>> =
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,
),
),
)

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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,
)
}

View file

@ -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))
}
}

View file

@ -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)
}
}

View file

@ -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",
),
),
),
),
)
}

View file

@ -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",
),
)
}

View file

@ -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<PremiumizeDeviceTokenDto> =
DebridApiResponse(
status = 400,
body = PremiumizeDeviceTokenDto(error = error, errorDescription = error),
rawBody = """{"error":"$error"}""",
)
}

View file

@ -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<TorboxDeviceTokenDto>(
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 = "",
),
),
)
}
}

View file

@ -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(

View file

@ -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
}

View file

@ -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,
),

View file

@ -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)
}
}

View file

@ -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(

View file

@ -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<String> =
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"
}
}
}

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=64
MARKETING_VERSION=0.1.22
MARKETING_VERSION=0.1.0