removal: debrid integration

This commit is contained in:
tapframe 2026-05-20 21:44:27 +05:30
parent 88ea47d2f0
commit 4e5a32510b
47 changed files with 19 additions and 6176 deletions

View file

@ -90,19 +90,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
)
}
outDir.resolve("com/nuvio/app/features/debrid").apply {
mkdirs()
resolve("DebridConfig.kt").writeText(
"""
|package com.nuvio.app.features.debrid
|
|object DebridConfig {
| const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}"
|}
""".trimMargin()
)
}
outDir.resolve("com/nuvio/app/core/build").apply {
mkdirs()
resolve("AppVersionConfig.kt").writeText(

View file

@ -15,7 +15,6 @@ import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
import com.nuvio.app.features.addons.AddonStorage
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.collection.CollectionStorage
import com.nuvio.app.features.debrid.DebridSettingsStorage
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
import com.nuvio.app.features.downloads.DownloadsStorage
@ -75,7 +74,6 @@ class MainActivity : AppCompatActivity() {
SearchHistoryStorage.initialize(applicationContext)
SeasonViewModeStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
DebridSettingsStorage.initialize(applicationContext)
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext)

View file

@ -1,210 +0,0 @@
package com.nuvio.app.features.debrid
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncInt
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncInt
import com.nuvio.app.core.sync.encodeSyncString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
actual object DebridSettingsStorage {
private const val preferencesName = "nuvio_debrid_settings"
private const val enabledKey = "debrid_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
private const val streamMaxResultsKey = "debrid_stream_max_results"
private const val streamSortModeKey = "debrid_stream_sort_mode"
private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
private const val streamCodecFilterKey = "debrid_stream_codec_filter"
private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
actual fun saveStreamMaxResults(maxResults: Int) {
saveInt(streamMaxResultsKey, maxResults)
}
actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
actual fun saveStreamSortMode(mode: String) {
saveString(streamSortModeKey, mode)
}
actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
actual fun saveStreamMinimumQuality(quality: String) {
saveString(streamMinimumQualityKey, quality)
}
actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
actual fun saveStreamDolbyVisionFilter(filter: String) {
saveString(streamDolbyVisionFilterKey, filter)
}
actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
actual fun saveStreamHdrFilter(filter: String) {
saveString(streamHdrFilterKey, filter)
}
actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
actual fun saveStreamCodecFilter(filter: String) {
saveString(streamCodecFilterKey, filter)
}
actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
actual fun saveStreamPreferences(preferences: String) {
saveString(streamPreferencesKey, preferences)
}
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
actual fun saveStreamNameTemplate(template: String) {
saveString(streamNameTemplateKey, template)
}
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
actual fun saveStreamDescriptionTemplate(template: String) {
saveString(streamDescriptionTemplateKey, template)
}
private fun loadBoolean(key: String): Boolean? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
if (sharedPreferences.contains(scopedKey)) {
sharedPreferences.getBoolean(scopedKey, false)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(key), enabled)
?.apply()
}
private fun loadInt(key: String): Int? =
preferences?.let { sharedPreferences ->
val scopedKey = ProfileScopedKey.of(key)
if (sharedPreferences.contains(scopedKey)) {
sharedPreferences.getInt(scopedKey, 0)
} else {
null
}
}
private fun saveInt(key: String, value: Int) {
preferences
?.edit()
?.putInt(ProfileScopedKey.of(key), value)
?.apply()
}
private fun loadString(key: String): String? =
preferences?.getString(ProfileScopedKey.of(key), null)
private fun saveString(key: String, value: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(key), value)
?.apply()
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
preferences?.edit()?.apply {
syncKeys.forEach { remove(ProfileScopedKey.of(it)) }
}?.apply()
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}

View file

@ -379,7 +379,6 @@
<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">Debrid</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>
@ -588,27 +587,6 @@
<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">Eksperimentelle sky-konto-kilder</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Debrid-støtte er eksperimentell og kan endres eller fjernes senere.</string>
<string name="settings_debrid_enable">Aktiver kilder</string>
<string name="settings_debrid_enable_description">Vis spillbare resultater fra tilkoblede kontoer.</string>
<string name="settings_debrid_add_key_first">Legg til en API-nøkkel først.</string>
<string name="settings_debrid_section_providers">Konto</string>
<string name="settings_debrid_provider_torbox_description">Koble til Torbox-kontoen din.</string>
<string name="settings_debrid_section_instant_playback">Umiddelbar avspilling</string>
<string name="settings_debrid_prepare_instant_playback">Forbered lenker</string>
<string name="settings_debrid_prepare_instant_playback_description">Løs første kilder før avspilling starter.</string>
<string name="settings_debrid_prepare_stream_count">Kilder å forberede</string>
<string name="settings_debrid_prepare_count_one">1 kilde</string>
<string name="settings_debrid_prepare_count_many">%1$d kilder</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 kildenavn vises.</string>
<string name="settings_debrid_description_template">Beskrivelsesmal</string>
<string name="settings_debrid_description_template_description">Styrer metadata vist under hver kilde.</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>
@ -1144,9 +1122,6 @@
<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">Legg til en Debrid API-nøkkel i Innstillinger.</string>
<string name="debrid_stream_stale">Dette Debrid-resultatet er utgått. Oppdaterer strømmer.</string>
<string name="debrid_resolve_failed">Kunne ikke løse denne Debrid-strømmen.</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,7 +380,6 @@
<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>
@ -589,34 +588,6 @@
<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>
@ -1154,9 +1125,6 @@
<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,7 +380,6 @@
<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">Debrid</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>
@ -589,34 +588,6 @@
<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">Experimental cloud account sources</string>
<string name="settings_debrid_section_title">Debrid</string>
<string name="settings_debrid_experimental_notice">Debrid support is experimental and may be kept, changed, or removed later.</string>
<string name="settings_debrid_enable">Enable sources</string>
<string name="settings_debrid_enable_description">Show playable results from connected accounts.</string>
<string name="settings_debrid_add_key_first">Add an API key first.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_torbox_description">Connect your Torbox account.</string>
<string name="settings_debrid_dialog_title">Torbox API Key</string>
<string name="settings_debrid_dialog_subtitle">Enter your Torbox API key.</string>
<string name="settings_debrid_dialog_placeholder">Enter Torbox API key</string>
<string name="settings_debrid_not_set">Not set</string>
<string name="settings_debrid_section_instant_playback">Instant Playback</string>
<string name="settings_debrid_prepare_instant_playback">Prepare links</string>
<string name="settings_debrid_prepare_instant_playback_description">Resolve the first sources before playback starts.</string>
<string name="settings_debrid_prepare_stream_count">Sources to prepare</string>
<string name="settings_debrid_prepare_stream_count_warning">Use a lower count when possible. Debrid services may rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time.</string>
<string name="settings_debrid_prepare_count_one">1 source</string>
<string name="settings_debrid_prepare_count_many">%1$d sources</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 source 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 source.</string>
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
<string name="settings_debrid_formatter_reset_subtitle">Restore default source 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>
@ -1154,9 +1125,6 @@
<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">Add a Debrid API key in Settings.</string>
<string name="debrid_stream_stale">This Debrid result expired. Refreshing streams.</string>
<string name="debrid_resolve_failed">Could not resolve this Debrid stream.</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>

View file

@ -106,9 +106,6 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.downloads.DownloadsRepository
import com.nuvio.app.features.downloads.DownloadsScreen
import com.nuvio.app.features.details.MetaDetailsRepository
@ -1360,8 +1357,6 @@ private fun MainAppContent(
return@composable
}
val pauseDescription = launch.pauseDescription
val streamRouteScope = rememberCoroutineScope()
var resolvingDebridStream by rememberSaveable(route.launchId) { mutableStateOf(false) }
val lifecycleOwner = backStackEntry
DisposableEffect(lifecycleOwner, route.launchId) {
val observer = LifecycleEventObserver { _, event ->
@ -1511,31 +1506,7 @@ private fun MainAppContent(
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val selectedStream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val stream = when (
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = selectedStream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
) {
is DirectDebridPlayableResult.Success -> resolved.stream
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
StreamsRepository.consumeAutoPlay()
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
return@LaunchedEffect
}
}
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
@ -1613,41 +1584,6 @@ private fun MainAppContent(
forceExternal: Boolean,
forceInternal: Boolean,
) {
if (stream.isDirectDebridStream && stream.directPlaybackUrl == null) {
if (resolvingDebridStream) return
streamRouteScope.launch {
resolvingDebridStream = true
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = launch.seasonNumber,
episode = launch.episodeNumber,
)
resolvingDebridStream = false
when (resolved) {
is DirectDebridPlayableResult.Success -> openSelectedStream(
stream = resolved.stream,
resolvedResumePositionMs = resolvedResumePositionMs,
resolvedResumeProgressFraction = resolvedResumeProgressFraction,
forceExternal = forceExternal,
forceInternal = forceInternal,
)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
StreamsRepository.reload(
type = launch.type,
videoId = effectiveVideoId,
parentMetaId = launch.parentMetaId,
season = launch.seasonNumber,
episode = launch.episodeNumber,
manualSelection = launch.manualSelection,
)
}
}
}
}
return
}
val sourceUrl = stream.directPlaybackUrl ?: return
if (playerSettings.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
@ -1751,26 +1687,6 @@ private fun MainAppContent(
},
modifier = Modifier.fillMaxSize(),
)
if (resolvingDebridStream) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.82f)),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator(color = Color.White)
Text(
text = stringResource(Res.string.streams_finding_source),
color = Color.White.copy(alpha = 0.82f),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
composable<PlayerRoute>(

View file

@ -6,8 +6,6 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsStorage
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.mdblist.MdbListMetadataService
@ -159,7 +157,6 @@ object ProfileSettingsSync {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
PosterCardStyleRepository.uiState.map { "poster_card_style" },
PlayerSettingsRepository.uiState.map { "player" },
DebridSettingsRepository.uiState.map { "debrid" },
TmdbSettingsRepository.uiState.map { "tmdb" },
MdbListSettingsRepository.uiState.map { "mdblist" },
MetaScreenSettingsRepository.uiState.map { "meta" },
@ -205,7 +202,6 @@ object ProfileSettingsSync {
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(),
mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(),
metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(),
@ -230,9 +226,6 @@ object ProfileSettingsSync {
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
PlayerSettingsRepository.onProfileChanged()
DebridSettingsStorage.replaceFromSyncPayload(blob.features.debridSettings)
DebridSettingsRepository.onProfileChanged()
TmdbSettingsStorage.replaceFromSyncPayload(blob.features.tmdbSettings)
TmdbSettingsRepository.onProfileChanged()
@ -262,7 +255,6 @@ object ProfileSettingsSync {
ThemeSettingsRepository.ensureLoaded()
PosterCardStyleRepository.ensureLoaded()
PlayerSettingsRepository.ensureLoaded()
DebridSettingsRepository.ensureLoaded()
TmdbSettingsRepository.ensureLoaded()
MdbListSettingsRepository.ensureLoaded()
MetaScreenSettingsRepository.ensureLoaded()
@ -285,7 +277,6 @@ object ProfileSettingsSync {
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
"player=${PlayerSettingsRepository.uiState.value}",
"debrid=${DebridSettingsRepository.uiState.value}",
"tmdb=${TmdbSettingsRepository.uiState.value}",
"mdblist=${MdbListSettingsRepository.uiState.value}",
"meta=${MetaScreenSettingsRepository.uiState.value}",
@ -308,7 +299,6 @@ private data class MobileProfileSettingsFeatures(
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()),
@SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "",

View file

@ -1,244 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.addons.RawHttpResponse
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
internal data class DebridApiResponse<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 validateApiKey(apiKey: String): Boolean =
getUser(apiKey.trim()).status in 200..299
private suspend fun getUser(apiKey: String): RawHttpResponse =
httpRequestRaw(
method = "GET",
url = "$BASE_URL/v1/api/user/me",
headers = authHeaders(apiKey),
body = "",
)
suspend fun createTorrent(apiKey: String, magnet: String): DebridApiResponse<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 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,
)
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 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")
}
object DebridCredentialValidator {
suspend fun validateProvider(providerId: String, apiKey: String): Boolean {
val normalized = apiKey.trim()
if (normalized.isBlank()) return false
return when (DebridProviders.byId(providerId)?.id) {
DebridProviders.TORBOX_ID -> TorboxApiClient.validateApiKey(normalized)
DebridProviders.REAL_DEBRID_ID -> RealDebridApiClient.validateApiKey(normalized)
else -> false
}
}
}
private inline fun <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

@ -1,94 +0,0 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@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 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,
)

View file

@ -1,169 +0,0 @@
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()
}
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

@ -1,83 +0,0 @@
package com.nuvio.app.features.debrid
data class DebridProvider(
val id: String,
val displayName: String,
val shortName: String,
val visibleInUi: Boolean = true,
)
data class DebridServiceCredential(
val provider: DebridProvider,
val apiKey: String,
)
object DebridProviders {
const val TORBOX_ID = "torbox"
const val REAL_DEBRID_ID = "realdebrid"
val Torbox = DebridProvider(
id = TORBOX_ID,
displayName = "Torbox",
shortName = "TB",
)
val RealDebrid = DebridProvider(
id = REAL_DEBRID_ID,
displayName = "Real-Debrid",
shortName = "RD",
visibleInUi = false,
)
private val registered = listOf(Torbox, RealDebrid)
fun all(): List<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> =
buildList {
settings.torboxApiKey.trim().takeIf { Torbox.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(Torbox, apiKey))
}
settings.realDebridApiKey.trim().takeIf { RealDebrid.visibleInUi && it.isNotBlank() }?.let { apiKey ->
add(DebridServiceCredential(RealDebrid, apiKey))
}
}
fun configuredSourceNames(settings: DebridSettings): List<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" }
}
}

View file

@ -1,256 +0,0 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.Serializable
data class DebridSettings(
val enabled: Boolean = false,
val torboxApiKey: String = "",
val realDebridApiKey: String = "",
val instantPlaybackPreparationLimit: Int = 0,
val streamMaxResults: Int = 0,
val streamSortMode: DebridStreamSortMode = DebridStreamSortMode.DEFAULT,
val streamMinimumQuality: DebridStreamMinimumQuality = DebridStreamMinimumQuality.ANY,
val streamDolbyVisionFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
val streamHdrFilter: DebridStreamFeatureFilter = DebridStreamFeatureFilter.ANY,
val streamCodecFilter: DebridStreamCodecFilter = DebridStreamCodecFilter.ANY,
val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
) {
val hasAnyApiKey: Boolean
get() = DebridProviders.configuredServices(this).isNotEmpty()
}
const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2
const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5
enum class DebridStreamSortMode {
DEFAULT,
QUALITY_DESC,
SIZE_DESC,
SIZE_ASC,
}
enum class DebridStreamMinimumQuality(val minResolution: Int) {
ANY(0),
P720(720),
P1080(1080),
P2160(2160),
}
enum class DebridStreamFeatureFilter {
ANY,
EXCLUDE,
ONLY,
}
enum class DebridStreamCodecFilter {
ANY,
H264,
HEVC,
AV1,
}
@Serializable
data class DebridStreamPreferences(
val maxResults: Int = 0,
val maxPerResolution: Int = 0,
val maxPerQuality: Int = 0,
val sizeMinGb: Int = 0,
val sizeMaxGb: Int = 0,
val preferredResolutions: List<DebridStreamResolution> = 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

@ -1,419 +0,0 @@
package com.nuvio.app.features.debrid
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
object DebridSettingsRepository {
private val _uiState = MutableStateFlow(DebridSettings())
val uiState: StateFlow<DebridSettings> = _uiState.asStateFlow()
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
private var hasLoaded = false
private var enabled = false
private var torboxApiKey = ""
private var realDebridApiKey = ""
private var instantPlaybackPreparationLimit = 0
private var streamMaxResults = 0
private var streamSortMode = DebridStreamSortMode.DEFAULT
private var streamMinimumQuality = DebridStreamMinimumQuality.ANY
private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY
private var streamHdrFilter = DebridStreamFeatureFilter.ANY
private var streamCodecFilter = DebridStreamCodecFilter.ANY
private var streamPreferences = DebridStreamPreferences()
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
fun ensureLoaded() {
if (hasLoaded) return
loadFromDisk()
}
fun onProfileChanged() {
loadFromDisk()
}
fun snapshot(): DebridSettings {
ensureLoaded()
return _uiState.value
}
fun setEnabled(value: Boolean) {
ensureLoaded()
if (value && !hasVisibleApiKey()) return
if (enabled == value) return
enabled = value
publish()
DebridSettingsStorage.saveEnabled(value)
}
fun setTorboxApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (torboxApiKey == normalized) return
torboxApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveTorboxApiKey(normalized)
}
fun setRealDebridApiKey(value: String) {
ensureLoaded()
val normalized = value.trim()
if (realDebridApiKey == normalized) return
realDebridApiKey = normalized
disableIfNoKeys()
publish()
DebridSettingsStorage.saveRealDebridApiKey(normalized)
}
fun setInstantPlaybackPreparationLimit(value: Int) {
ensureLoaded()
val normalized = normalizeDebridInstantPlaybackPreparationLimit(value)
if (instantPlaybackPreparationLimit == normalized) return
instantPlaybackPreparationLimit = normalized
publish()
DebridSettingsStorage.saveInstantPlaybackPreparationLimit(normalized)
}
fun setStreamMaxResults(value: Int) {
ensureLoaded()
val normalized = normalizeDebridStreamMaxResults(value)
if (streamMaxResults == normalized && streamPreferences.maxResults == normalized) return
streamMaxResults = normalized
streamPreferences = streamPreferences.copy(maxResults = normalized).normalized()
publish()
DebridSettingsStorage.saveStreamMaxResults(normalized)
saveStreamPreferences()
}
fun setStreamSortMode(value: DebridStreamSortMode) {
ensureLoaded()
if (streamSortMode == value && streamPreferences.sortCriteria == sortCriteriaForLegacyMode(value)) return
streamSortMode = value
streamPreferences = streamPreferences.copy(sortCriteria = sortCriteriaForLegacyMode(value)).normalized()
publish()
DebridSettingsStorage.saveStreamSortMode(value.name)
saveStreamPreferences()
}
fun setStreamMinimumQuality(value: DebridStreamMinimumQuality) {
ensureLoaded()
if (streamMinimumQuality == value && streamPreferences.requiredResolutions == resolutionsForMinimumQuality(value)) return
streamMinimumQuality = value
streamPreferences = streamPreferences.copy(requiredResolutions = resolutionsForMinimumQuality(value)).normalized()
publish()
DebridSettingsStorage.saveStreamMinimumQuality(value.name)
saveStreamPreferences()
}
fun setStreamDolbyVisionFilter(value: DebridStreamFeatureFilter) {
ensureLoaded()
if (streamDolbyVisionFilter == value) return
streamDolbyVisionFilter = value
streamPreferences = streamPreferences.applyDolbyVisionFilter(value).normalized()
publish()
DebridSettingsStorage.saveStreamDolbyVisionFilter(value.name)
saveStreamPreferences()
}
fun setStreamHdrFilter(value: DebridStreamFeatureFilter) {
ensureLoaded()
if (streamHdrFilter == value) return
streamHdrFilter = value
streamPreferences = streamPreferences.applyHdrFilter(value).normalized()
publish()
DebridSettingsStorage.saveStreamHdrFilter(value.name)
saveStreamPreferences()
}
fun setStreamCodecFilter(value: DebridStreamCodecFilter) {
ensureLoaded()
if (streamCodecFilter == value) return
streamCodecFilter = value
streamPreferences = streamPreferences.applyCodecFilter(value).normalized()
publish()
DebridSettingsStorage.saveStreamCodecFilter(value.name)
saveStreamPreferences()
}
fun setStreamPreferences(value: DebridStreamPreferences) {
ensureLoaded()
val normalized = value.normalized()
if (streamPreferences == normalized) return
streamPreferences = normalized
streamMaxResults = normalized.maxResults
publish()
DebridSettingsStorage.saveStreamMaxResults(streamMaxResults)
saveStreamPreferences()
}
fun setStreamNameTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
if (streamNameTemplate == normalized) return
streamNameTemplate = normalized
publish()
DebridSettingsStorage.saveStreamNameTemplate(normalized)
}
fun setStreamDescriptionTemplate(value: String) {
ensureLoaded()
val normalized = value.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
if (streamDescriptionTemplate == normalized) return
streamDescriptionTemplate = normalized
publish()
DebridSettingsStorage.saveStreamDescriptionTemplate(normalized)
}
fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) {
ensureLoaded()
streamNameTemplate = nameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
streamDescriptionTemplate = descriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
publish()
DebridSettingsStorage.saveStreamNameTemplate(streamNameTemplate)
DebridSettingsStorage.saveStreamDescriptionTemplate(streamDescriptionTemplate)
}
fun resetStreamTemplates() {
setStreamTemplates(
nameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE,
descriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
)
}
private fun disableIfNoKeys() {
if (!hasVisibleApiKey()) {
enabled = false
DebridSettingsStorage.saveEnabled(false)
}
}
private fun hasVisibleApiKey(): Boolean =
(DebridProviders.isVisible(DebridProviders.TORBOX_ID) && torboxApiKey.isNotBlank()) ||
(DebridProviders.isVisible(DebridProviders.REAL_DEBRID_ID) && realDebridApiKey.isNotBlank())
private fun loadFromDisk() {
hasLoaded = true
torboxApiKey = DebridSettingsStorage.loadTorboxApiKey()?.trim().orEmpty()
realDebridApiKey = DebridSettingsStorage.loadRealDebridApiKey()?.trim().orEmpty()
enabled = (DebridSettingsStorage.loadEnabled() ?: false) && hasVisibleApiKey()
instantPlaybackPreparationLimit = normalizeDebridInstantPlaybackPreparationLimit(
DebridSettingsStorage.loadInstantPlaybackPreparationLimit() ?: 0,
)
streamMaxResults = normalizeDebridStreamMaxResults(DebridSettingsStorage.loadStreamMaxResults() ?: 0)
streamSortMode = enumValueOrDefault(
DebridSettingsStorage.loadStreamSortMode(),
DebridStreamSortMode.DEFAULT,
)
streamMinimumQuality = enumValueOrDefault(
DebridSettingsStorage.loadStreamMinimumQuality(),
DebridStreamMinimumQuality.ANY,
)
streamDolbyVisionFilter = enumValueOrDefault(
DebridSettingsStorage.loadStreamDolbyVisionFilter(),
DebridStreamFeatureFilter.ANY,
)
streamHdrFilter = enumValueOrDefault(
DebridSettingsStorage.loadStreamHdrFilter(),
DebridStreamFeatureFilter.ANY,
)
streamCodecFilter = enumValueOrDefault(
DebridSettingsStorage.loadStreamCodecFilter(),
DebridStreamCodecFilter.ANY,
)
streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
?: legacyStreamPreferences(
maxResults = streamMaxResults,
sortMode = streamSortMode,
minimumQuality = streamMinimumQuality,
dolbyVisionFilter = streamDolbyVisionFilter,
hdrFilter = streamHdrFilter,
codecFilter = streamCodecFilter,
)
streamNameTemplate = DebridSettingsStorage.loadStreamNameTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.NAME_TEMPLATE
streamDescriptionTemplate = DebridSettingsStorage.loadStreamDescriptionTemplate()
?.takeIf { it.isNotBlank() }
?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
publish()
}
private fun publish() {
_uiState.value = DebridSettings(
enabled = enabled,
torboxApiKey = torboxApiKey,
realDebridApiKey = realDebridApiKey,
instantPlaybackPreparationLimit = instantPlaybackPreparationLimit,
streamMaxResults = streamMaxResults,
streamSortMode = streamSortMode,
streamMinimumQuality = streamMinimumQuality,
streamDolbyVisionFilter = streamDolbyVisionFilter,
streamHdrFilter = streamHdrFilter,
streamCodecFilter = streamCodecFilter,
streamPreferences = streamPreferences,
streamNameTemplate = streamNameTemplate,
streamDescriptionTemplate = streamDescriptionTemplate,
)
}
private fun saveStreamPreferences() {
DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized()))
}
private inline fun <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
}
}
}
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

@ -1,34 +0,0 @@
package com.nuvio.app.features.debrid
import kotlinx.serialization.json.JsonObject
internal expect object DebridSettingsStorage {
fun loadEnabled(): Boolean?
fun saveEnabled(enabled: Boolean)
fun loadTorboxApiKey(): String?
fun saveTorboxApiKey(apiKey: String)
fun loadRealDebridApiKey(): String?
fun saveRealDebridApiKey(apiKey: String)
fun loadInstantPlaybackPreparationLimit(): Int?
fun saveInstantPlaybackPreparationLimit(limit: Int)
fun loadStreamMaxResults(): Int?
fun saveStreamMaxResults(maxResults: Int)
fun loadStreamSortMode(): String?
fun saveStreamSortMode(mode: String)
fun loadStreamMinimumQuality(): String?
fun saveStreamMinimumQuality(quality: String)
fun loadStreamDolbyVisionFilter(): String?
fun saveStreamDolbyVisionFilter(filter: String)
fun loadStreamHdrFilter(): String?
fun saveStreamHdrFilter(filter: String)
fun loadStreamCodecFilter(): String?
fun saveStreamCodecFilter(filter: String)
fun loadStreamPreferences(): String?
fun saveStreamPreferences(preferences: String)
fun loadStreamNameTemplate(): String?
fun saveStreamNameTemplate(template: String)
fun loadStreamDescriptionTemplate(): String?
fun saveStreamDescriptionTemplate(template: String)
fun exportToSyncPayload(): JsonObject
fun replaceFromSyncPayload(payload: JsonObject)
}

View file

@ -1,143 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamClientResolveParsed
import com.nuvio.app.features.streams.StreamItem
class DebridStreamFormatter(
private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(),
) {
fun format(stream: StreamItem, settings: DebridSettings): StreamItem {
if (!stream.isDirectDebridStream) return stream
val values = buildValues(stream)
val formattedName = engine.render(settings.streamNameTemplate, values)
.lineSequence()
.joinToString(" ") { it.trim() }
.replace(Regex("\\s+"), " ")
.trim()
val formattedDescription = engine.render(settings.streamDescriptionTemplate, values)
.lineSequence()
.map { it.trim() }
.filter { it.isNotBlank() }
.joinToString("\n")
.trim()
return stream.copy(
name = formattedName.ifBlank { stream.name ?: DebridProviders.instantName(stream.clientResolve?.service) },
description = formattedDescription.ifBlank { stream.description ?: stream.title },
)
}
private fun buildValues(stream: StreamItem): Map<String, Any?> {
val resolve = stream.clientResolve
val raw = resolve?.stream?.raw
val parsed = raw?.parsed
val seasons = parsed?.seasons.orEmpty()
val episodes = parsed?.episodes.orEmpty()
val season = resolve?.season ?: seasons.singleOrFirstOrNull()
val episode = resolve?.episode ?: episodes.singleOrFirstOrNull()
val visualTags = buildList {
addAll(parsed?.hdr.orEmpty())
parsed?.bitDepth?.takeIf { it.isNotBlank() }?.let { add(it) }
}
val edition = parsed?.edition ?: buildEdition(parsed)
return linkedMapOf(
"stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title),
"stream.year" to parsed?.year,
"stream.season" to season,
"stream.episode" to episode,
"stream.seasons" to seasons,
"stream.episodes" to episodes,
"stream.seasonEpisode" to buildSeasonEpisodeList(season, episode, seasons, episodes),
"stream.formattedEpisodes" to formatEpisodes(episodes),
"stream.formattedSeasons" to formatSeasons(seasons),
"stream.resolution" to parsed?.resolution,
"stream.library" to false,
"stream.quality" to parsed?.quality,
"stream.visualTags" to visualTags,
"stream.audioTags" to parsed?.audio.orEmpty(),
"stream.audioChannels" to parsed?.channels.orEmpty(),
"stream.languages" to parsed?.languages.orEmpty(),
"stream.languageEmojis" to parsed?.languages.orEmpty().map { languageEmoji(it) },
"stream.size" to (raw?.size ?: stream.behaviorHints.videoSize)?.let(::DebridTemplateBytes),
"stream.folderSize" to raw?.folderSize?.let(::DebridTemplateBytes),
"stream.encode" to parsed?.codec?.uppercase(),
"stream.indexer" to (raw?.indexer ?: raw?.tracker),
"stream.network" to (parsed?.network ?: raw?.network),
"stream.releaseGroup" to parsed?.group,
"stream.duration" to parsed?.duration,
"stream.edition" to edition,
"stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename),
"stream.regexMatched" to null,
"stream.type" to streamType(resolve),
"service.cached" to resolve?.isCached,
"service.shortName" to serviceShortName(resolve),
"service.name" to serviceName(resolve),
"addon.name" to "Nuvio Direct Debrid",
)
}
private fun streamType(resolve: StreamClientResolve?): String =
when {
resolve?.type.equals("debrid", ignoreCase = true) -> "Debrid"
resolve?.type.equals("torrent", ignoreCase = true) -> "p2p"
else -> resolve?.type.orEmpty()
}
private fun serviceShortName(resolve: StreamClientResolve?): String =
resolve?.serviceExtension?.takeIf { it.isNotBlank() }
?: DebridProviders.shortName(resolve?.service)
private fun serviceName(resolve: StreamClientResolve?): String =
DebridProviders.displayName(resolve?.service)
private fun buildEdition(parsed: StreamClientResolveParsed?): String? {
if (parsed == null) return null
return buildList {
if (parsed.extended == true) add("extended")
if (parsed.theatrical == true) add("theatrical")
if (parsed.remastered == true) add("remastered")
if (parsed.unrated == true) add("unrated")
}.joinToString(" ").takeIf { it.isNotBlank() }
}
private fun buildSeasonEpisodeList(
season: Int?,
episode: Int?,
seasons: List<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 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
}
}

View file

@ -1,8 +0,0 @@
package com.nuvio.app.features.debrid
object DebridStreamFormatterDefaults {
const val NAME_TEMPLATE = "{stream.resolution::=2160p[\"4K \"||\"\"]}{stream.resolution::=1440p[\"QHD \"||\"\"]}{stream.resolution::=1080p[\"FHD \"||\"\"]}{stream.resolution::=720p[\"HD \"||\"\"]}{stream.resolution::exists[\"\"||\"Direct \"]}{service.shortName::exists[\"{service.shortName} \"||\"Debrid \"]}Instant"
const val DESCRIPTION_TEMPLATE = "{stream.title::exists[\"{stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year})\"||\"\"]}\n{stream.quality::exists[\"{stream.quality} \"||\"\"]}{stream.visualTags::exists[\"{stream.visualTags::join(' | ')} \"||\"\"]}{stream.encode::exists[\"{stream.encode} \"||\"\"]}\n{stream.audioTags::exists[\"{stream.audioTags::join(' | ')}\"||\"\"]}{stream.audioTags::exists::and::stream.audioChannels::exists[\" | \"||\"\"]}{stream.audioChannels::exists[\"{stream.audioChannels::join(' | ')}\"||\"\"]}\n{stream.size::>0[\"{stream.size::bytes} \"||\"\"]}{stream.releaseGroup::exists[\"{stream.releaseGroup} \"||\"\"]}{stream.indexer::exists[\"{stream.indexer}\"||\"\"]}\n{service.cached::istrue[\"Ready\"||\"Not Ready\"]}{service.shortName::exists[\" ({service.shortName})\"||\"\"]}{stream.filename::exists[\"\n{stream.filename}\"||\"\"]}"
}

View file

@ -1,394 +0,0 @@
package com.nuvio.app.features.debrid
import kotlin.math.abs
import kotlin.math.roundToLong
internal data class DebridTemplateBytes(val value: Long)
class DebridStreamTemplateEngine {
fun render(template: String, values: Map<String, 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

@ -1,38 +0,0 @@
package com.nuvio.app.features.debrid
internal fun encodePathSegment(value: String): String =
percentEncode(value, spaceAsPlus = false)
internal fun encodeFormValue(value: String): String =
percentEncode(value, spaceAsPlus = true)
internal fun queryString(vararg pairs: Pair<String, 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

@ -1,39 +0,0 @@
package com.nuvio.app.features.debrid
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
class DirectDebridConfigEncoder {
@OptIn(ExperimentalEncodingApi::class)
fun encode(service: DebridServiceCredential): String {
val servicesJson = """{"service":"${service.provider.id.jsonEscaped()}","apiKey":"${service.apiKey.jsonEscaped()}"}"""
val json = """{"cachedOnly":true,"debridServices":[$servicesJson],"enableTorrent":false}"""
return Base64.Default.encode(json.encodeToByteArray())
}
fun encodeTorbox(apiKey: String): String =
encode(DebridServiceCredential(DebridProviders.Torbox, apiKey))
}
private fun String.jsonEscaped(): String = buildString {
this@jsonEscaped.forEach { char ->
when (char) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> {
if (char.code < 0x20) {
append("\\u")
append(char.code.toString(16).padStart(4, '0'))
} else {
append(char)
}
}
}
}
}

View file

@ -1,375 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamBehaviorHints
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.debrid_missing_api_key
import nuvio.composeapp.generated.resources.debrid_resolve_failed
import nuvio.composeapp.generated.resources.debrid_stream_stale
import org.jetbrains.compose.resources.getString
object DirectDebridPlaybackResolver {
private val torboxResolver = TorboxDirectDebridResolver()
private val realDebridResolver = RealDebridDirectDebridResolver()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val resolvedCache = mutableMapOf<String, CachedDirectDebridResolve>()
private val inFlightResolves = mutableMapOf<String, Deferred<DirectDebridResolveResult>>()
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val cacheKey = stream.directDebridResolveCacheKey(season, episode)
if (cacheKey == null) {
return resolveUncached(stream, season, episode)
}
getCachedResult(cacheKey)?.let {
return it
}
var ownsResolve = false
val newResolve = scope.async(start = CoroutineStart.LAZY) {
resolveUncached(stream, season, episode)
}
val activeResolve = mutex.withLock {
getCachedResultLocked(cacheKey)?.let { cached ->
return@withLock null to cached
}
val existing = inFlightResolves[cacheKey]
if (existing != null) {
existing to null
} else {
inFlightResolves[cacheKey] = newResolve
ownsResolve = true
newResolve to null
}
}
activeResolve.second?.let {
newResolve.cancel()
return it
}
val deferred = activeResolve.first ?: return DirectDebridResolveResult.Error
if (!ownsResolve) newResolve.cancel()
if (ownsResolve) deferred.start()
return try {
val result = deferred.await()
if (ownsResolve && result is DirectDebridResolveResult.Success) {
mutex.withLock {
resolvedCache[cacheKey] = CachedDirectDebridResolve(
result = result,
cachedAtMs = epochMs(),
)
}
}
result
} finally {
if (ownsResolve) {
mutex.withLock {
if (inFlightResolves[cacheKey] === deferred) {
inFlightResolves.remove(cacheKey)
}
}
}
}
}
suspend fun cachedPlayableStream(stream: StreamItem, season: Int?, episode: Int?): StreamItem? {
val cacheKey = stream.directDebridResolveCacheKey(season, episode) ?: return null
return getCachedResult(cacheKey)
?.let { result -> stream.withResolvedDebridUrl(result) }
}
private suspend fun getCachedResult(cacheKey: String): DirectDebridResolveResult.Success? =
mutex.withLock { getCachedResultLocked(cacheKey) }
private fun getCachedResultLocked(cacheKey: String): DirectDebridResolveResult.Success? {
val cached = resolvedCache[cacheKey] ?: return null
val age = epochMs() - cached.cachedAtMs
return if (age in 0..DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS) {
cached.result
} else {
resolvedCache.remove(cacheKey)
null
}
}
private suspend fun resolveUncached(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult =
when (DebridProviders.byId(stream.clientResolve?.service)?.id) {
DebridProviders.TORBOX_ID -> torboxResolver.resolve(stream, season, episode)
DebridProviders.REAL_DEBRID_ID -> realDebridResolver.resolve(stream, season, episode)
else -> DirectDebridResolveResult.Error
}
suspend fun resolveToPlayableStream(
stream: StreamItem,
season: Int?,
episode: Int?,
): DirectDebridPlayableResult {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) {
return DirectDebridPlayableResult.Success(stream)
}
return when (val result = resolve(stream, season, episode)) {
is DirectDebridResolveResult.Success -> DirectDebridPlayableResult.Success(stream.withResolvedDebridUrl(result))
DirectDebridResolveResult.MissingApiKey -> DirectDebridPlayableResult.MissingApiKey
DirectDebridResolveResult.Stale -> DirectDebridPlayableResult.Stale
DirectDebridResolveResult.Error -> DirectDebridPlayableResult.Error
}
}
}
private const val DIRECT_DEBRID_RESOLVE_CACHE_TTL_MS = 15L * 60L * 1000L
private data class CachedDirectDebridResolve(
val result: DirectDebridResolveResult.Success,
val cachedAtMs: Long,
)
sealed class DirectDebridPlayableResult {
data class Success(val stream: StreamItem) : DirectDebridPlayableResult()
data object MissingApiKey : DirectDebridPlayableResult()
data object Stale : DirectDebridPlayableResult()
data object Error : DirectDebridPlayableResult()
}
sealed class DirectDebridResolveResult {
data class Success(
val url: String,
val filename: String?,
val videoSize: Long?,
) : DirectDebridResolveResult()
data object MissingApiKey : DirectDebridResolveResult()
data object Stale : DirectDebridResolveResult()
data object Error : DirectDebridResolveResult()
}
fun DirectDebridPlayableResult.toastMessage(): String? =
when (this) {
is DirectDebridPlayableResult.Success -> null
DirectDebridPlayableResult.MissingApiKey -> runBlocking { getString(Res.string.debrid_missing_api_key) }
DirectDebridPlayableResult.Stale -> runBlocking { getString(Res.string.debrid_stream_stale) }
DirectDebridPlayableResult.Error -> runBlocking { getString(Res.string.debrid_resolve_failed) }
}
private class TorboxDirectDebridResolver(
private val fileSelector: TorboxFileSelector = TorboxFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().torboxApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val create = TorboxApiClient.createTorrent(apiKey = apiKey, magnet = magnet)
val torrentId = create.body?.takeIf { it.success != false }?.data?.resolvedTorrentId()
?: return create.toFailureForCreate()
val torrent = TorboxApiClient.getTorrent(apiKey = apiKey, id = torrentId)
if (!torrent.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val files = torrent.body?.data?.files.orEmpty()
val file = fileSelector.selectFile(files, resolve, season, episode)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val link = TorboxApiClient.requestDownloadLink(
apiKey = apiKey,
torrentId = torrentId,
fileId = fileId,
)
if (!link.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = link.body?.data?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
DirectDebridResolveResult.Success(
url = url,
filename = file.displayName().takeIf { it.isNotBlank() },
videoSize = file.size,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
private fun DebridApiResponse<TorboxEnvelopeDto<TorboxCreateTorrentDataDto>>.toFailureForCreate(): DirectDebridResolveResult =
when (status) {
401, 403 -> DirectDebridResolveResult.Error
else -> DirectDebridResolveResult.Stale
}
}
private class RealDebridDirectDebridResolver(
private val fileSelector: RealDebridFileSelector = RealDebridFileSelector(),
) {
suspend fun resolve(stream: StreamItem, season: Int?, episode: Int?): DirectDebridResolveResult {
val resolve = stream.clientResolve ?: return DirectDebridResolveResult.Error
val apiKey = DebridSettingsRepository.snapshot().realDebridApiKey.trim()
if (apiKey.isBlank()) {
return DirectDebridResolveResult.MissingApiKey
}
val magnet = resolve.magnetUri?.takeIf { it.isNotBlank() }
?: buildMagnetUri(resolve)
?: run {
return DirectDebridResolveResult.Stale
}
return try {
val add = RealDebridApiClient.addMagnet(apiKey, magnet)
val torrentId = add.body?.id?.takeIf { add.isSuccessful && it.isNotBlank() }
?: return add.toFailureForAdd()
var resolved = false
try {
val infoBefore = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoBefore.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val filesBefore = infoBefore.body?.files.orEmpty()
val file = fileSelector.selectFile(
files = filesBefore,
resolve = resolve,
season = season,
episode = episode,
)
?: run {
return DirectDebridResolveResult.Stale
}
val fileId = file.id
?: run {
return DirectDebridResolveResult.Stale
}
val select = RealDebridApiClient.selectFiles(apiKey, torrentId, fileId.toString())
if (!select.isSuccessful && select.status != 202) {
return DirectDebridResolveResult.Stale
}
val infoAfter = RealDebridApiClient.getTorrentInfo(apiKey, torrentId)
if (!infoAfter.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val link = infoAfter.body?.firstDownloadLink()
?: run {
return DirectDebridResolveResult.Stale
}
val unrestrict = RealDebridApiClient.unrestrictLink(apiKey, link)
if (!unrestrict.isSuccessful) {
return DirectDebridResolveResult.Stale
}
val url = unrestrict.body?.download?.takeIf { it.isNotBlank() }
?: run {
return DirectDebridResolveResult.Stale
}
resolved = true
DirectDebridResolveResult.Success(
url = url,
filename = unrestrict.body.filename?.takeIf { it.isNotBlank() }
?: file.displayName().takeIf { it.isNotBlank() },
videoSize = unrestrict.body.filesize ?: file.bytes,
)
} finally {
if (!resolved) {
runCatching { RealDebridApiClient.deleteTorrent(apiKey, torrentId) }
}
}
} catch (error: Exception) {
if (error is CancellationException) throw error
DirectDebridResolveResult.Error
}
}
private fun DebridApiResponse<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() }
}
}
private fun buildMagnetUri(resolve: StreamClientResolve): String? {
val hash = resolve.infoHash?.takeIf { it.isNotBlank() } ?: return null
return buildString {
append("magnet:?xt=urn:btih:")
append(hash)
resolve.sources
.filter { it.isNotBlank() }
.forEach { source ->
append("&tr=")
append(encodePathSegment(source))
}
}
}
private fun StreamItem.directDebridResolveCacheKey(season: Int?, episode: Int?): String? {
val resolve = clientResolve ?: return null
val providerId = DebridProviders.byId(resolve.service)?.id ?: return null
val apiKey = when (providerId) {
DebridProviders.TORBOX_ID -> DebridSettingsRepository.snapshot().torboxApiKey
DebridProviders.REAL_DEBRID_ID -> DebridSettingsRepository.snapshot().realDebridApiKey
else -> ""
}.trim().takeIf { it.isNotBlank() } ?: return null
val identity = resolve.infoHash
?: resolve.magnetUri
?: resolve.torrentName
?: resolve.filename
?: return null
return listOf(
providerId,
apiKey.stableFingerprint(),
identity.trim().lowercase(),
resolve.fileIdx?.toString().orEmpty(),
(resolve.filename ?: behaviorHints.filename).orEmpty().trim().lowercase(),
(season ?: resolve.season)?.toString().orEmpty(),
(episode ?: resolve.episode)?.toString().orEmpty(),
).joinToString("|")
}
private fun String.stableFingerprint(): String {
val hash = fold(1125899906842597L) { acc, char -> (acc * 31L) + char.code }
return hash.toULong().toString(16)
}
private fun StreamItem.withResolvedDebridUrl(result: DirectDebridResolveResult.Success): StreamItem =
copy(
url = result.url,
externalUrl = null,
behaviorHints = behaviorHints.mergeResolvedDebridHints(result),
)
private fun StreamBehaviorHints.mergeResolvedDebridHints(result: DirectDebridResolveResult.Success): StreamBehaviorHints =
copy(
filename = result.filename ?: filename,
videoSize = result.videoSize ?: videoSize,
)

View file

@ -1,425 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamItem
object DirectDebridStreamFilter {
const val FALLBACK_SOURCE_NAME = "Direct Debrid"
fun filterInstant(streams: List<StreamItem>, settings: DebridSettings? = null): List<StreamItem> {
val instantStreams = streams
.filter(::isInstantCandidate)
.map { stream ->
val providerId = stream.clientResolve?.service
val sourceName = DebridProviders.instantName(providerId)
stream.copy(
name = stream.name ?: sourceName,
addonName = sourceName,
addonId = DebridProviders.addonId(providerId),
sourceName = stream.sourceName ?: FALLBACK_SOURCE_NAME,
)
}
.distinctBy { stream ->
listOf(
stream.clientResolve?.infoHash?.lowercase(),
stream.clientResolve?.fileIdx?.toString(),
stream.clientResolve?.filename,
stream.name,
stream.title,
).joinToString("|")
}
return if (settings == null) instantStreams else applyPreferences(instantStreams, settings)
}
fun isInstantCandidate(stream: StreamItem): Boolean {
val resolve = stream.clientResolve ?: return false
return resolve.type.equals("debrid", ignoreCase = true) &&
DebridProviders.isSupported(resolve.service) &&
resolve.isCached == true
}
fun isDirectDebridSourceName(addonName: String): Boolean =
DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) }
private fun applyPreferences(streams: List<StreamItem>, settings: DebridSettings): List<StreamItem> {
val preferences = effectivePreferences(settings)
return streams.map { it to streamFacts(it, preferences) }
.filter { (_, facts) -> facts.matchesFilters(preferences) }
.sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) }
.let { sorted -> applyLimits(sorted, preferences) }
.map { it.first }
}
private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences {
val default = DebridStreamPreferences()
if (settings.streamPreferences != default) return settings.streamPreferences.normalized()
if (
settings.streamMaxResults == 0 &&
settings.streamSortMode == DebridStreamSortMode.DEFAULT &&
settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY &&
settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY &&
settings.streamHdrFilter == DebridStreamFeatureFilter.ANY &&
settings.streamCodecFilter == DebridStreamCodecFilter.ANY
) {
return default
}
var preferences = default.copy(
maxResults = settings.streamMaxResults,
sortCriteria = when (settings.streamSortMode) {
DebridStreamSortMode.DEFAULT -> default.sortCriteria
DebridStreamSortMode.QUALITY_DESC -> listOf(
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC),
)
DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC))
DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
},
requiredResolutions = DebridStreamResolution.defaultOrder.filter {
it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN
},
)
preferences = when (settings.streamDolbyVisionFilter) {
DebridStreamFeatureFilter.ANY -> preferences
DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
excludedVisualTags = preferences.excludedVisualTags + listOf(
DebridStreamVisualTag.DV,
DebridStreamVisualTag.DV_ONLY,
DebridStreamVisualTag.HDR_DV,
),
)
DebridStreamFeatureFilter.ONLY -> preferences.copy(
requiredVisualTags = preferences.requiredVisualTags + listOf(
DebridStreamVisualTag.DV,
DebridStreamVisualTag.DV_ONLY,
DebridStreamVisualTag.HDR_DV,
),
)
}
preferences = when (settings.streamHdrFilter) {
DebridStreamFeatureFilter.ANY -> preferences
DebridStreamFeatureFilter.EXCLUDE -> preferences.copy(
excludedVisualTags = preferences.excludedVisualTags + listOf(
DebridStreamVisualTag.HDR,
DebridStreamVisualTag.HDR10,
DebridStreamVisualTag.HDR10_PLUS,
DebridStreamVisualTag.HLG,
DebridStreamVisualTag.HDR_ONLY,
DebridStreamVisualTag.HDR_DV,
),
)
DebridStreamFeatureFilter.ONLY -> preferences.copy(
requiredVisualTags = preferences.requiredVisualTags + listOf(
DebridStreamVisualTag.HDR,
DebridStreamVisualTag.HDR10,
DebridStreamVisualTag.HDR10_PLUS,
DebridStreamVisualTag.HLG,
DebridStreamVisualTag.HDR_ONLY,
DebridStreamVisualTag.HDR_DV,
),
)
}
return when (settings.streamCodecFilter) {
DebridStreamCodecFilter.ANY -> preferences
DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC))
DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC))
DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1))
}.normalized()
}
private fun applyLimits(
streams: List<Pair<StreamItem, StreamFacts>>,
preferences: DebridStreamPreferences,
): List<Pair<StreamItem, StreamFacts>> {
val resolutionCounts = mutableMapOf<DebridStreamResolution, Int>()
val qualityCounts = mutableMapOf<DebridStreamQuality, Int>()
val result = mutableListOf<Pair<StreamItem, StreamFacts>>()
for (stream in streams) {
if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break
if (preferences.maxPerResolution > 0) {
val count = resolutionCounts[stream.second.resolution] ?: 0
if (count >= preferences.maxPerResolution) continue
}
if (preferences.maxPerQuality > 0) {
val count = qualityCounts[stream.second.quality] ?: 0
if (count >= preferences.maxPerQuality) continue
}
resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1
qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1
result += stream
}
return result
}
private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean {
if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false
if (resolution in preferences.excludedResolutions) return false
if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false
if (quality in preferences.excludedQualities) return false
if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false
if (visualTags.any { it in preferences.excludedVisualTags }) return false
if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false
if (audioTags.any { it in preferences.excludedAudioTags }) return false
if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false
if (audioChannels.any { it in preferences.excludedAudioChannels }) return false
if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false
if (encode in preferences.excludedEncodes) return false
if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false
if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false
if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false
if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false
if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false
if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false
return true
}
private fun compareFacts(
left: StreamFacts,
right: StreamFacts,
criteria: List<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: StreamFacts,
right: StreamFacts,
criterion: DebridStreamSortCriterion,
): Int {
val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1
return when (criterion.key) {
DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction
DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction
DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction
DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction
DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction
DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction
DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction
DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction
DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true)
}
}
private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts {
val parsed = stream.clientResolve?.stream?.raw?.parsed
val searchText = streamSearchText(stream)
val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText)
val quality = streamQuality(parsed?.quality, searchText)
val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText)
val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText)
val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText)
val encode = streamEncode(parsed?.codec, searchText)
val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty {
DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) }
}
val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText)
return StreamFacts(
resolution = resolution,
quality = quality,
visualTags = visualTags,
audioTags = audioTags,
audioChannels = audioChannels,
encode = encode,
languages = languages,
releaseGroup = releaseGroup,
size = streamSize(stream),
resolutionRank = rank(resolution, preferences.preferredResolutions),
qualityRank = rank(quality, preferences.preferredQualities),
visualRank = rankAny(visualTags, preferences.preferredVisualTags),
audioRank = rankAny(audioTags, preferences.preferredAudioTags),
channelRank = rankAny(audioChannels, preferences.preferredAudioChannels),
encodeRank = rank(encode, preferences.preferredEncodes),
languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) },
)
}
private fun streamResolution(vararg values: String?): DebridStreamResolution =
values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN
private fun resolutionValue(value: String?): DebridStreamResolution? {
val normalized = value?.lowercase().orEmpty()
return when {
normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160
normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440
normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080
normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720
normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576
normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480
normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360
else -> null
}
}
private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality {
val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase()
return when {
text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX
text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY
text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL
text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP
text.contains("hdrip") -> DebridStreamQuality.HDRIP
text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP
text.contains("dvdrip") -> DebridStreamQuality.DVDRIP
text.contains("hdtv") -> DebridStreamQuality.HDTV
text.hasToken("cam") -> DebridStreamQuality.CAM
text.hasToken("ts") -> DebridStreamQuality.TS
text.hasToken("tc") -> DebridStreamQuality.TC
text.hasToken("scr") -> DebridStreamQuality.SCR
else -> DebridStreamQuality.UNKNOWN
}
}
private fun streamVisualTags(parsedHdr: List<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
private fun streamSearchText(stream: StreamItem): String {
val resolve = stream.clientResolve
val raw = resolve?.stream?.raw
val parsed = raw?.parsed
return listOfNotNull(
stream.name,
stream.title,
stream.description,
resolve?.torrentName,
resolve?.filename,
raw?.torrentName,
raw?.filename,
parsed?.resolution,
parsed?.quality,
parsed?.codec,
parsed?.hdr?.joinToString(" "),
parsed?.audio?.joinToString(" "),
).joinToString(" ").lowercase()
}
private fun Int.gigabytes(): Long = this * 1_000_000_000L
private data class StreamFacts(
val resolution: DebridStreamResolution,
val quality: DebridStreamQuality,
val visualTags: List<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,
)
}

View file

@ -1,196 +0,0 @@
package com.nuvio.app.features.debrid
import co.touchlab.kermit.Logger
import com.nuvio.app.features.player.PlayerSettingsUiState
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
object DirectDebridStreamPreparer {
private val log = Logger.withTag("DirectDebridPreparer")
private val budgetMutex = Mutex()
private val minuteStarts = ArrayDeque<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.enabled || limit <= 0 || !settings.hasAnyApiKey) return
val candidates = prioritizeCandidates(
streams = streams,
limit = limit,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
)
for (stream in candidates) {
DirectDebridPlaybackResolver.cachedPlayableStream(stream, season, episode)?.let { cached ->
onPrepared(stream, cached)
continue
}
if (!consumeBackgroundBudget()) {
log.d { "Skipping instant playback preparation; local Torbox budget reached" }
return
}
try {
when (val result = DirectDebridPlaybackResolver.resolveToPlayableStream(stream, season, episode)) {
is DirectDebridPlayableResult.Success -> {
if (result.stream.directPlaybackUrl != null) {
onPrepared(stream, result.stream)
}
}
else -> Unit
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
log.d(error) { "Instant playback preparation failed" }
}
}
}
internal fun prioritizeCandidates(
streams: List<StreamItem>,
limit: Int,
playerSettings: PlayerSettingsUiState,
installedAddonNames: Set<String>,
): List<StreamItem> {
if (limit <= 0) return emptyList()
val candidates = streams
.filter { it.isDirectDebridStream && it.directPlaybackUrl == null }
.distinctBy { it.preparationKey() }
if (candidates.isEmpty()) return emptyList()
val prioritized = mutableListOf<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?.isDirectDebridStream == true) {
candidates.firstOrNull { it.preparationKey() == autoPlaySelection.preparationKey() }
?.let(prioritized::add)
}
if (playerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) {
val regex = runCatching {
Regex(playerSettings.streamAutoPlayRegex.trim(), RegexOption.IGNORE_CASE)
}.getOrNull()
if (regex != null) {
candidates
.filter { candidate ->
prioritized.none { it.preparationKey() == candidate.preparationKey() } &&
regex.containsMatchIn(candidate.searchableText())
}
.forEach(prioritized::add)
}
}
candidates
.filter { candidate -> prioritized.none { it.preparationKey() == candidate.preparationKey() } }
.forEach(prioritized::add)
return prioritized.take(limit)
}
fun replacePreparedStream(
groups: List<AddonStreamGroup>,
original: StreamItem,
prepared: StreamItem,
): List<AddonStreamGroup> {
val key = original.preparationKey()
return groups.map { group ->
var changed = false
val updatedStreams = group.streams.map { stream ->
if (stream.preparationKey() == key) {
changed = true
prepared.copy(
addonName = stream.addonName,
addonId = stream.addonId,
sourceName = stream.sourceName,
)
} else {
stream
}
}
if (changed) group.copy(streams = updatedStreams) else group
}
}
private suspend fun consumeBackgroundBudget(): Boolean {
val now = epochMs()
return budgetMutex.withLock {
minuteStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS)
hourStarts.removeOlderThan(now - BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS)
if (
minuteStarts.size >= MAX_BACKGROUND_PREPARES_PER_MINUTE ||
hourStarts.size >= MAX_BACKGROUND_PREPARES_PER_HOUR
) {
false
} else {
minuteStarts.addLast(now)
hourStarts.addLast(now)
true
}
}
}
}
private const val MAX_BACKGROUND_PREPARES_PER_MINUTE = 6
private const val MAX_BACKGROUND_PREPARES_PER_HOUR = 30
private const val BACKGROUND_PREPARES_PER_MINUTE_WINDOW_MS = 60L * 1000L
private const val BACKGROUND_PREPARES_PER_HOUR_WINDOW_MS = 60L * 60L * 1000L
private fun ArrayDeque<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(),
directPlaybackUrl.orEmpty().lowercase(),
name.orEmpty().lowercase(),
title.orEmpty().lowercase(),
).joinToString("|")
}
private fun StreamItem.searchableText(): String =
buildString {
append(addonName).append(' ')
append(name.orEmpty()).append(' ')
append(title.orEmpty()).append(' ')
append(description.orEmpty()).append(' ')
append(directPlaybackUrl.orEmpty())
}

View file

@ -1,253 +0,0 @@
package com.nuvio.app.features.debrid
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.epochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
private const val DIRECT_DEBRID_TAG = "DirectDebridStreams"
private const val STREAM_CACHE_TTL_MS = 5L * 60L * 1000L
data class DirectDebridStreamTarget(
val provider: DebridProvider,
val apiKey: String,
) {
val addonId: String = DebridProviders.addonId(provider.id)
val addonName: String = DebridProviders.instantName(provider.id)
}
object DirectDebridStreamSource {
private val log = Logger.withTag(DIRECT_DEBRID_TAG)
private val encoder = DirectDebridConfigEncoder()
private val formatter = DebridStreamFormatter()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val mutex = Mutex()
private val streamCache = mutableMapOf<DirectDebridStreamCacheKey, CachedDirectDebridStreams>()
private val inFlightFetches = mutableMapOf<DirectDebridStreamCacheKey, Deferred<AddonStreamGroup>>()
fun configuredTargets(): List<DirectDebridStreamTarget> {
DebridSettingsRepository.ensureLoaded()
val settings = DebridSettingsRepository.snapshot()
if (!settings.enabled || DebridConfig.DIRECT_DEBRID_API_BASE_URL.isBlank()) return emptyList()
return DebridProviders.configuredServices(settings).map { credential ->
DirectDebridStreamTarget(
provider = credential.provider,
apiKey = credential.apiKey,
)
}
}
fun sourceNames(): List<String> =
configuredTargets().map { it.addonName }
fun isEnabled(): Boolean =
sourceNames().isNotEmpty()
fun placeholders(): List<AddonStreamGroup> =
configuredTargets().map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}
fun preloadStreams(type: String, videoId: String) {
if (type.isBlank() || videoId.isBlank()) return
configuredTargets().forEach { target ->
scope.launch {
runCatching { fetchProviderStreams(type, videoId, target) }
}
}
}
suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult {
val targets = configuredTargets()
if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled
val results = mutableListOf<AddonStreamGroup>()
val errors = mutableListOf<String>()
targets.forEach { target ->
val group = fetchProviderStreams(type, videoId, target)
when {
group.streams.isNotEmpty() -> results += group
!group.error.isNullOrBlank() -> errors += group.error
}
}
return when {
results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results)
errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first())
else -> DirectDebridStreamFetchResult.Empty
}
}
suspend fun fetchProviderStreams(
type: String,
videoId: String,
target: DirectDebridStreamTarget,
): AddonStreamGroup {
val settings = DebridSettingsRepository.snapshot()
val baseUrl = DebridConfig.DIRECT_DEBRID_API_BASE_URL.trim().trimEnd('/')
if (!settings.enabled || baseUrl.isBlank()) {
return target.emptyGroup()
}
val cacheKey = DirectDebridStreamCacheKey(
providerId = target.provider.id,
type = type.trim().lowercase(),
videoId = videoId.trim(),
baseUrl = baseUrl,
settingsFingerprint = settings.toString(),
)
cachedGroup(cacheKey)?.let { return it }
var ownsFetch = false
val newFetch = scope.async(start = CoroutineStart.LAZY) {
fetchProviderStreamsUncached(
baseUrl = baseUrl,
type = type,
videoId = videoId,
target = target,
settings = settings,
)
}
val activeFetch = mutex.withLock {
cachedGroupLocked(cacheKey)?.let { cached ->
return@withLock null to cached
}
val existing = inFlightFetches[cacheKey]
if (existing != null) {
existing to null
} else {
inFlightFetches[cacheKey] = newFetch
ownsFetch = true
newFetch to null
}
}
activeFetch.second?.let {
newFetch.cancel()
return it
}
val deferred = activeFetch.first ?: return target.errorGroup("Could not start Direct Debrid fetch")
if (!ownsFetch) newFetch.cancel()
if (ownsFetch) deferred.start()
return try {
val result = deferred.await()
if (ownsFetch && result.streams.isNotEmpty() && result.error == null) {
mutex.withLock {
streamCache[cacheKey] = CachedDirectDebridStreams(
group = result,
createdAtMs = epochMs(),
)
}
}
result
} finally {
if (ownsFetch) {
mutex.withLock {
if (inFlightFetches[cacheKey] === deferred) {
inFlightFetches.remove(cacheKey)
}
}
}
}
}
private suspend fun cachedGroup(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? =
mutex.withLock { cachedGroupLocked(cacheKey) }
private fun cachedGroupLocked(cacheKey: DirectDebridStreamCacheKey): AddonStreamGroup? {
val cached = streamCache[cacheKey] ?: return null
val age = epochMs() - cached.createdAtMs
return if (age in 0..STREAM_CACHE_TTL_MS) {
cached.group
} else {
streamCache.remove(cacheKey)
null
}
}
private suspend fun fetchProviderStreamsUncached(
baseUrl: String,
type: String,
videoId: String,
target: DirectDebridStreamTarget,
settings: DebridSettings,
): AddonStreamGroup {
val credential = DebridServiceCredential(target.provider, target.apiKey)
val url = "$baseUrl/${encoder.encode(credential)}/client-stream/${encodePathSegment(type)}/${encodePathSegment(videoId)}.json"
return try {
val payload = httpGetText(url)
val streams = StreamParser.parse(
payload = payload,
addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME,
addonId = target.addonId,
)
.let { DirectDebridStreamFilter.filterInstant(it, settings) }
.filter { stream -> stream.clientResolve?.service.equals(target.provider.id, ignoreCase = true) }
.map { stream -> formatter.format(stream.copy(addonId = target.addonId), settings) }
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = streams,
isLoading = false,
)
} catch (error: Exception) {
if (error is CancellationException) throw error
log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" }
target.errorGroup(error.message)
}
}
private fun DirectDebridStreamTarget.emptyGroup(): AddonStreamGroup =
AddonStreamGroup(
addonName = addonName,
addonId = addonId,
streams = emptyList(),
isLoading = false,
)
private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup =
AddonStreamGroup(
addonName = addonName,
addonId = addonId,
streams = emptyList(),
isLoading = false,
error = message,
)
}
private data class DirectDebridStreamCacheKey(
val providerId: String,
val type: String,
val videoId: String,
val baseUrl: String,
val settingsFingerprint: String,
)
private data class CachedDirectDebridStreams(
val group: AddonStreamGroup,
val createdAtMs: Long,
)
sealed class DirectDebridStreamFetchResult {
data object Disabled : DirectDebridStreamFetchResult()
data object Empty : DirectDebridStreamFetchResult()
data class Success(val streams: List<AddonStreamGroup>) : DirectDebridStreamFetchResult()
data class Error(val message: String) : DirectDebridStreamFetchResult()
}

View file

@ -62,7 +62,6 @@ import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.components.DetailActionButtons
import com.nuvio.app.features.details.components.CommentDetailSheet
import com.nuvio.app.features.details.components.DetailAdditionalInfoSection
@ -373,16 +372,6 @@ fun MetaDetailsScreen(
seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId
}
val hasEpisodes = meta.videos.any { it.season != null || it.episode != null }
val debridPreloadVideoId = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction?.videoId) {
if (meta.isSeriesLikeForDebridPreload(hasEpisodes)) {
seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id
} else {
meta.id
}
}
LaunchedEffect(meta.type, debridPreloadVideoId) {
DirectDebridStreamSource.preloadStreams(meta.type, debridPreloadVideoId)
}
val hasProductionSection = remember(meta) {
meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty()
}
@ -1270,8 +1259,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp =
} else {
(maxWidth * 0.6f).coerceIn(520.dp, 680.dp)
}
private fun MetaDetails.isSeriesLikeForDebridPreload(hasEpisodes: Boolean): Boolean =
hasEpisodes || type.equals("series", ignoreCase = true) ||
type.equals("show", ignoreCase = true) ||
type.equals("tv", ignoreCase = true)

View file

@ -597,7 +597,7 @@ private fun EpisodeStreamsSubView(
) {
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
) { _, stream ->
EpisodeSourceStreamRow(
stream = stream,

View file

@ -38,10 +38,6 @@ import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.features.debrid.DirectDebridPlayableResult
import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver
import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
@ -861,55 +857,7 @@ fun PlayerScreen(
playerController?.seekTo(targetPositionMs)
}
fun resolveDebridForPlayer(
stream: StreamItem,
season: Int?,
episode: Int?,
onResolved: (StreamItem) -> Unit,
onStale: () -> Unit,
): Boolean {
if (!stream.isDirectDebridStream || stream.directPlaybackUrl != null) return false
scope.launch {
val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream(
stream = stream,
season = season,
episode = episode,
)
when (resolved) {
is DirectDebridPlayableResult.Success -> onResolved(resolved.stream)
else -> {
resolved.toastMessage()?.let { NuvioToastController.show(it) }
if (resolved == DirectDebridPlayableResult.Stale) {
onStale()
}
}
}
}
return true
}
fun switchToSource(stream: StreamItem) {
if (
resolveDebridForPlayer(
stream = stream,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
onResolved = ::switchToSource,
onStale = {
val type = contentType ?: parentMetaType
val vid = activeVideoId
if (vid != null) {
PlayerStreamsRepository.loadSources(
type = type,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
forceRefresh = true,
)
}
},
)
) return
val url = stream.directPlaybackUrl ?: return
if (url == activeSourceUrl) return
val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L)
@ -951,26 +899,6 @@ fun PlayerScreen(
}
fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) {
if (
resolveDebridForPlayer(
stream = stream,
season = episode.season,
episode = episode.episode,
onResolved = { resolvedStream ->
switchToEpisodeStream(resolvedStream, episode)
},
onStale = {
val type = contentType ?: parentMetaType
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
forceRefresh = true,
)
},
)
) return
val url = stream.directPlaybackUrl ?: return
showNextEpisodeCard = false
showSourcesPanel = false

View file

@ -203,7 +203,7 @@ fun PlayerSourcesPanel(
) {
itemsIndexed(
items = streams,
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.name}" },
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.name}" },
) { _, stream ->
val isCurrent = isCurrentStream(
stream = stream,

View file

@ -5,8 +5,6 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.pluginContentId
@ -156,10 +154,6 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.initialize()
PluginRepository.getEnabledScrapersForType(type)
@ -167,7 +161,7 @@ object PlayerStreamsRepository {
emptyList()
}
if (installedAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@ -193,7 +187,7 @@ object PlayerStreamsRepository {
)
}
if (streamAddons.isEmpty() && pluginScrapers.isEmpty() && debridTargets.isEmpty()) {
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@ -216,13 +210,6 @@ object PlayerStreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}, installedAddonOrder)
stateFlow.value = StreamsUiState(
groups = initialGroups,
@ -291,24 +278,13 @@ object PlayerStreamsRepository {
}
}
val debridJobs = debridTargets.map { target ->
async {
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
)
}
}
val jobs = addonJobs + pluginJobs + debridJobs
val jobs = addonJobs + pluginJobs
val completions = Channel<AddonStreamGroup>(capacity = Channel.BUFFERED)
jobs.forEach { deferred ->
launch {
completions.send(deferred.await())
}
}
var debridPreparationLaunched = false
repeat(jobs.size) {
val result = completions.receive()
stateFlow.update { current ->
@ -329,28 +305,6 @@ object PlayerStreamsRepository {
} else null,
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = stateFlow.value.groups.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
) { original, prepared ->
stateFlow.update { current ->
current.copy(
groups = DirectDebridStreamPreparer.replacePreparedStream(
groups = current.groups,
original = original,
prepared = prepared,
),
)
}
}
}
}
}
completions.close()
}

View file

@ -1,14 +1,10 @@
package com.nuvio.app.features.settings
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.CloudQueue
import androidx.compose.foundation.lazy.LazyListScope
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings
import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment
import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description
import nuvio.composeapp.generated.resources.settings_integrations_debrid_description
import nuvio.composeapp.generated.resources.settings_integrations_section_title
import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description
import org.jetbrains.compose.resources.stringResource
@ -17,7 +13,6 @@ internal fun LazyListScope.integrationsContent(
isTablet: Boolean,
onTmdbClick: () -> Unit,
onMdbListClick: () -> Unit,
onDebridClick: () -> Unit,
) {
item {
SettingsSection(
@ -40,14 +35,6 @@ internal fun LazyListScope.integrationsContent(
isTablet = isTablet,
onClick = onMdbListClick,
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.compose_settings_page_debrid),
description = stringResource(Res.string.settings_integrations_debrid_description),
icon = Icons.Rounded.CloudQueue,
isTablet = isTablet,
onClick = onDebridClick,
)
}
}
}

View file

@ -13,7 +13,6 @@ import nuvio.composeapp.generated.resources.compose_settings_page_account
import nuvio.composeapp.generated.resources.compose_settings_page_addons
import nuvio.composeapp.generated.resources.compose_settings_page_appearance
import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery
import nuvio.composeapp.generated.resources.compose_settings_page_debrid
import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching
import nuvio.composeapp.generated.resources.compose_settings_page_homescreen
import nuvio.composeapp.generated.resources.compose_settings_page_integrations
@ -130,11 +129,6 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = Integrations,
),
Debrid(
titleRes = Res.string.compose_settings_page_debrid,
category = SettingsCategory.General,
parentPage = Integrations,
),
TraktAuthentication(
titleRes = Res.string.compose_settings_page_trakt,
category = SettingsCategory.Account,

View file

@ -59,8 +59,6 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.mdblist.MdbListSettings
@ -134,10 +132,6 @@ fun SettingsScreen(
MdbListSettingsRepository.ensureLoaded()
MdbListSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val debridSettings by remember {
DebridSettingsRepository.ensureLoaded()
DebridSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val traktAuthUiState by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.uiState
@ -257,7 +251,6 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -306,7 +299,6 @@ fun SettingsScreen(
episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState,
tmdbSettings = tmdbSettings,
mdbListSettings = mdbListSettings,
debridSettings = debridSettings,
traktAuthUiState = traktAuthUiState,
traktCommentsEnabled = traktCommentsEnabled,
traktSettingsUiState = traktSettingsUiState,
@ -365,7 +357,6 @@ private fun MobileSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -580,7 +571,6 @@ private fun MobileSettingsScreen(
isTablet = false,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = false,
@ -590,10 +580,6 @@ private fun MobileSettingsScreen(
isTablet = false,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = false,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = false,
uiState = traktAuthUiState,
@ -679,7 +665,6 @@ private fun TabletSettingsScreen(
episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState,
tmdbSettings: TmdbSettings,
mdbListSettings: MdbListSettings,
debridSettings: DebridSettings,
traktAuthUiState: TraktAuthUiState,
traktCommentsEnabled: Boolean,
traktSettingsUiState: TraktSettingsUiState,
@ -952,7 +937,6 @@ private fun TabletSettingsScreen(
isTablet = true,
onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) },
onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) },
onDebridClick = { onPageChange(SettingsPage.Debrid) },
)
SettingsPage.TmdbEnrichment -> tmdbSettingsContent(
isTablet = true,
@ -962,10 +946,6 @@ private fun TabletSettingsScreen(
isTablet = true,
settings = mdbListSettings,
)
SettingsPage.Debrid -> debridSettingsContent(
isTablet = true,
settings = debridSettings,
)
SettingsPage.TraktAuthentication -> traktSettingsContent(
isTablet = true,
uiState = traktAuthUiState,

View file

@ -15,19 +15,15 @@ object StreamAutoPlaySelector {
}
}
val (directDebridEntries, remainingEntries) = groups.partition { group ->
group.addonId.startsWith("debrid:") ||
group.streams.any { stream -> stream.isDirectDebridStream }
}
if (installedOrder.isEmpty()) return directDebridEntries + remainingEntries
if (installedOrder.isEmpty()) return groups
val (addonEntries, pluginEntries) = remainingEntries.partition { group ->
val (addonEntries, pluginEntries) = groups.partition { group ->
group.addonName in addonRankByName
}
val orderedAddons = addonEntries.sortedBy { group ->
addonRankByName.getValue(group.addonName)
}
return directDebridEntries + orderedAddons + pluginEntries
return orderedAddons + pluginEntries
}
fun selectAutoPlayStream(
@ -123,5 +119,5 @@ object StreamAutoPlaySelector {
}
private fun StreamItem.isAutoPlayable(): Boolean =
directPlaybackUrl != null || isDirectDebridStream
directPlaybackUrl != null
}

View file

@ -17,7 +17,6 @@ data class StreamItem(
val addonName: String,
val addonId: String,
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
) {
val streamLabel: String
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
@ -28,18 +27,13 @@ data class StreamItem(
val directPlaybackUrl: String?
get() = url ?: externalUrl
val isDirectDebridStream: Boolean
get() = clientResolve?.isDirectDebridCandidate == true
val isTorrentStream: Boolean
get() = !isDirectDebridStream && (
!infoHash.isNullOrBlank() ||
get() = !infoHash.isNullOrBlank() ||
url.isMagnetLink() ||
externalUrl.isMagnetLink()
)
val hasPlayableSource: Boolean
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
get() = url != null || infoHash != null || externalUrl != null
}
private fun String?.isMagnetLink(): Boolean =
@ -59,71 +53,6 @@ data class StreamProxyHeaders(
val response: Map<String, String>? = 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,

View file

@ -26,10 +26,8 @@ object StreamParser {
val url = obj.string("url")
val infoHash = obj.string("infoHash")
val externalUrl = obj.string("externalUrl")
val clientResolve = obj.objectValue("clientResolve")?.toClientResolve()
// Must have at least one playable source
if (url == null && infoHash == null && externalUrl == null && clientResolve == null) return@mapNotNull null
if (url == null && infoHash == null && externalUrl == null) return@mapNotNull null
val hintsObj = obj["behaviorHints"] as? JsonObject
val proxyHeaders = hintsObj
@ -46,7 +44,6 @@ object StreamParser {
sources = obj.stringList("sources"),
addonName = addonName,
addonId = addonId,
clientResolve = clientResolve,
behaviorHints = StreamBehaviorHints(
bingeGroup = hintsObj?.string("bingeGroup"),
notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null,
@ -83,11 +80,6 @@ object StreamParser {
?.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
.orEmpty()
private fun JsonObject.intList(name: String): List<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
@ -107,67 +99,4 @@ object StreamParser {
)
}
private fun JsonObject.toClientResolve(): StreamClientResolve =
StreamClientResolve(
type = string("type"),
infoHash = string("infoHash"),
fileIdx = int("fileIdx"),
magnetUri = string("magnetUri"),
sources = stringList("sources"),
torrentName = string("torrentName"),
filename = string("filename"),
mediaType = string("mediaType"),
mediaId = string("mediaId"),
mediaOnlyId = string("mediaOnlyId"),
title = string("title"),
season = int("season"),
episode = int("episode"),
service = string("service"),
serviceIndex = int("serviceIndex"),
serviceExtension = string("serviceExtension"),
isCached = boolean("isCached"),
stream = objectValue("stream")?.toClientResolveStream(),
)
private fun JsonObject.toClientResolveStream(): StreamClientResolveStream =
StreamClientResolveStream(
raw = objectValue("raw")?.toClientResolveRaw(),
)
private fun JsonObject.toClientResolveRaw(): StreamClientResolveRaw =
StreamClientResolveRaw(
torrentName = string("torrentName"),
filename = string("filename"),
size = long("size"),
folderSize = long("folderSize"),
tracker = string("tracker"),
indexer = string("indexer"),
network = string("network"),
parsed = objectValue("parsed")?.toClientResolveParsed(),
)
private fun JsonObject.toClientResolveParsed(): StreamClientResolveParsed =
StreamClientResolveParsed(
rawTitle = string("raw_title"),
parsedTitle = string("parsed_title"),
year = int("year"),
resolution = string("resolution"),
seasons = intList("seasons"),
episodes = intList("episodes"),
quality = string("quality"),
hdr = stringList("hdr"),
codec = string("codec"),
audio = stringList("audio"),
channels = stringList("channels"),
languages = stringList("languages"),
group = string("group"),
network = string("network"),
edition = string("edition"),
duration = long("duration"),
bitDepth = string("bit_depth"),
extended = boolean("extended"),
theatrical = boolean("theatrical"),
remastered = boolean("remastered"),
unrated = boolean("unrated"),
)
}

View file

@ -5,8 +5,6 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DirectDebridStreamSource
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -150,7 +148,6 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
val debridTargets = DirectDebridStreamSource.configuredTargets()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {
@ -161,7 +158,7 @@ object StreamsRepository {
groupByRepository = pluginUiState.groupStreamsByRepository,
)
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -190,7 +187,7 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty() && debridTargets.isEmpty()) {
if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
requestToken = requestToken,
isAnyLoading = false,
@ -215,13 +212,6 @@ object StreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + debridTargets.map { target ->
AddonStreamGroup(
addonName = target.addonName,
addonId = target.addonId,
streams = emptyList(),
isLoading = true,
)
}, installedAddonOrder)
_uiState.value = StreamsUiState(
requestToken = requestToken,
@ -240,13 +230,11 @@ object StreamsRepository {
.toMutableMap()
val pluginFirstErrorByAddonId = mutableMapOf<String, String>()
val totalTasks = streamAddons.size +
pluginProviderGroups.sumOf { it.scrapers.size } +
debridTargets.size
pluginProviderGroups.sumOf { it.scrapers.size }
val installedAddonNames = installedAddonOrder.toSet()
var autoSelectTriggered = false
var timeoutElapsed = false
var debridPreparationLaunched = false
fun publishCompletion(completion: StreamLoadCompletion) {
if (completions.trySend(completion).isFailure) {
log.d { "Ignoring late stream load completion after channel close" }
@ -422,20 +410,6 @@ object StreamsRepository {
}
}
debridTargets.forEach { target ->
launch {
publishCompletion(
StreamLoadCompletion.Debrid(
DirectDebridStreamSource.fetchProviderStreams(
type = type,
videoId = videoId,
target = target,
),
),
)
}
}
repeat(totalTasks) {
when (val completion = completions.receive()) {
is StreamLoadCompletion.Addon -> {
@ -498,45 +472,6 @@ object StreamsRepository {
}
}
is StreamLoadCompletion.Debrid -> {
val result = completion.group
_uiState.update { current ->
val updated = StreamAutoPlaySelector.orderAddonStreams(
groups = current.groups.map { group ->
if (group.addonId == result.addonId) result else group
},
installedOrder = installedAddonOrder,
)
val anyLoading = updated.any { it.isLoading }
current.copy(
groups = updated,
isAnyLoading = anyLoading,
emptyStateReason = updated.toEmptyStateReason(anyLoading),
)
}
if (!debridPreparationLaunched && result.streams.any { it.isDirectDebridStream }) {
debridPreparationLaunched = true
launch {
DirectDebridStreamPreparer.prepare(
streams = _uiState.value.groups.flatMap { it.streams },
season = season,
episode = episode,
playerSettings = playerSettings,
installedAddonNames = installedAddonNames,
) { original, prepared ->
_uiState.update { current ->
current.copy(
groups = DirectDebridStreamPreparer.replacePreparedStream(
groups = current.groups,
original = original,
prepared = prepared,
),
)
}
}
}
}
}
}
// Early match / timeout-elapsed auto-select on each addon response
@ -677,7 +612,6 @@ private data class PluginProviderGroup(
private sealed interface StreamLoadCompletion {
data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion
data class Debrid(val group: AddonStreamGroup) : StreamLoadCompletion
data class PluginScraper(
val addonId: String,
val streams: List<StreamItem>,

View file

@ -866,7 +866,7 @@ private fun LazyListScope.streamSection(
StreamCard(
stream = stream,
onClick = {
if (stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream) {
if (stream.directPlaybackUrl != null || stream.isTorrentStream) {
onStreamSelected(stream, resumePositionMs, resumeProgressFraction)
}
},
@ -898,7 +898,7 @@ internal fun streamCardRenderKey(
append(':')
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
append(stream.url ?: stream.infoHash ?: stream.streamLabel)
}
// ---------------------------------------------------------------------------
@ -972,7 +972,7 @@ private fun StreamCard(
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream || stream.isDirectDebridStream
val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = modifier

View file

@ -1,148 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import kotlin.test.Test
import kotlin.test.assertEquals
class DebridFileSelectorTest {
@Test
fun `Torbox selector prefers exact file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "small.mkv", size = 1),
TorboxTorrentFileDto(id = 8, name = "target.mkv", size = 2),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(fileIdx = 8),
season = null,
episode = null,
)
assertEquals(8, selected?.id)
}
@Test
fun `Torbox selector prefers filename match before provider file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
TorboxTorrentFileDto(
id = 85,
name = "The Office US S01-S09/The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
size = 5_303_936_915,
),
TorboxTorrentFileDto(
id = 1,
name = "The Office US S01-S09/The.Office.US.S08E13.Jury.Duty.1080p.BluRay.Remux.mkv",
size = 5_859_312_140,
),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(
fileIdx = 1,
season = 1,
episode = 1,
filename = "The.Office.US.S01E01.Pilot.1080p.BluRay.Remux.mkv",
),
season = 1,
episode = 1,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector treats fileIdx as source list index before provider file id`() {
val files = listOf(
TorboxTorrentFileDto(id = 0, name = "Request High Bitrate Stuff in Here.txt", size = 1),
TorboxTorrentFileDto(id = 85, name = "Show.S01E01.mkv", size = 500),
TorboxTorrentFileDto(id = 1, name = "Show.S08E13.mkv", size = 900),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(fileIdx = 1),
season = null,
episode = null,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector uses episode pattern before broad title`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "The.Office.US.S08E13.Jury.Duty.mkv", size = 900),
TorboxTorrentFileDto(id = 85, name = "The.Office.US.S01E01.Pilot.mkv", size = 500),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(
season = 1,
episode = 1,
title = "The Office",
),
season = 1,
episode = 1,
)
assertEquals(85, selected?.id)
}
@Test
fun `Torbox selector falls back to largest playable video`() {
val files = listOf(
TorboxTorrentFileDto(id = 1, name = "sample.txt", size = 999),
TorboxTorrentFileDto(id = 2, name = "episode.mkv", size = 200),
TorboxTorrentFileDto(id = 3, name = "episode-1080p.mp4", size = 500),
)
val selected = TorboxFileSelector().selectFile(
files = files,
resolve = resolve(),
season = null,
episode = null,
)
assertEquals(3, selected?.id)
}
@Test
fun `Real-Debrid selector matches episode pattern before largest file`() {
val files = listOf(
RealDebridTorrentFileDto(id = 1, path = "/Show.S01E01.mkv", bytes = 1_000),
RealDebridTorrentFileDto(id = 2, path = "/Show.S01E02.mkv", bytes = 2_000),
)
val selected = RealDebridFileSelector().selectFile(
files = files,
resolve = resolve(season = 1, episode = 1),
season = null,
episode = null,
)
assertEquals(1, selected?.id)
}
private fun resolve(
fileIdx: Int? = null,
season: Int? = null,
episode: Int? = null,
filename: String? = null,
title: String? = null,
): StreamClientResolve =
StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = "hash",
fileIdx = fileIdx,
filename = filename,
title = title,
season = season,
episode = episode,
)
}

View file

@ -1,122 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamParser
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
class DebridStreamFormatterTest {
private val formatter = DebridStreamFormatter()
@Test
fun `formats real client stream episode fields and behavior size`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertEquals(0, stream.clientResolve?.fileIdx)
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
assertFalse(description.contains("6761331156"))
}
@Test
fun `formats season episode from parsed fields when top level resolve omits them`() {
val stream = StreamParser.parse(
payload = clientStreamPayload(includeTopLevelSeasonEpisode = false),
addonName = "Torbox Instant",
addonId = "debrid:torbox",
).single()
val formatted = formatter.format(
stream = stream,
settings = DebridSettings(
enabled = true,
torboxApiKey = "key",
streamDescriptionTemplate = CLIENT_TEMPLATE,
),
)
val description = formatted.description.orEmpty()
assertContains(description, "S05")
assertContains(description, "E02")
assertContains(description, "6.3 GB")
}
private fun clientStreamPayload(includeTopLevelSeasonEpisode: Boolean = true): String {
val seasonEpisode = if (includeTopLevelSeasonEpisode) {
"""
"season": 5,
"episode": 2,
""".trimIndent()
} else {
""
}
return """
{
"streams": [
{
"name": "TB 2160p cached",
"description": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"clientResolve": {
"type": "debrid",
"service": "torbox",
"isCached": true,
"infoHash": "cb7286fb422ed0643037523e7b09446734e9dbc4",
"sources": [],
"fileIdx": "0",
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"title": "The Boys",
"torrentName": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
$seasonEpisode
"stream": {
"raw": {
"parsed": {
"resolution": "2160p",
"quality": "WEB-DL",
"codec": "hevc",
"audio": ["Atmos", "Dolby Digital Plus"],
"channels": ["5.1"],
"hdr": ["DV", "HDR10+"],
"group": "Kitsune",
"seasons": [5],
"episodes": [2],
"raw_title": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv"
}
}
}
},
"behaviorHints": {
"filename": "The Boys S05E02 Teenage Kix 2160p AMZN WEB-DL DDP5 1 Atmos DV HDR10Plus H 265-Kitsune.mkv",
"videoSize": 6761331156
}
}
]
}
""".trimIndent()
}
private companion object {
private const val CLIENT_TEMPLATE =
"{stream.title::exists[\"🍿 {stream.title::title} \"||\"\"]}{stream.year::exists[\"({stream.year}) \"||\"\"]}\n" +
"{stream.season::>=0[\"🍂 S\"||\"\"]}{stream.season::<=9[\"0\"||\"\"]}{stream.season::>0[\"{stream.season} \"||\"\"]}{stream.episode::>=0[\"🎞️ E\"||\"\"]}{stream.episode::<=9[\"0\"||\"\"]}{stream.episode::>0[\"{stream.episode} \"||\"\"]}\n" +
"{stream.quality::exists[\"🎥 {stream.quality} \"||\"\"]}{stream.visualTags::exists[\"📺 {stream.visualTags::join(' | ')} \"||\"\"]}\n" +
"{stream.audioTags::exists[\"🎧 {stream.audioTags::join(' | ')} \"||\"\"]}{stream.audioChannels::exists[\"🔊 {stream.audioChannels::join(' | ')}\"||\"\"]}\n" +
"{stream.size::>0[\"📦 {stream.size::bytes} \"||\"\"]}{stream.encode::exists[\"🎞️ {stream.encode} \"||\"\"]}{stream.indexer::exists[\"📡{stream.indexer}\"||\"\"]}\n" +
"{service.cached::istrue[\"⚡Ready \"||\"\"]}{service.cached::isfalse[\"❌ Not Ready \"||\"\"]}{service.shortName::exists[\"({service.shortName}) \"||\"\"]}{stream.type::=Debrid[\"☁️ Debrid \"||\"\"]}🔍{addon.name}"
}
}

View file

@ -1,45 +0,0 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DebridStreamTemplateEngineTest {
private val engine = DebridStreamTemplateEngine()
@Test
fun `renders nested condition branches and transforms`() {
val rendered = engine.render(
"{stream.resolution::=2160p[\"4K {service.shortName} \"||\"\"]}{stream.title::title}",
mapOf(
"stream.resolution" to "2160p",
"stream.title" to "sample movie",
"service.shortName" to "RD",
),
)
assertEquals("4K RD Sample Movie", rendered)
}
@Test
fun `formats bytes and joins list values`() {
val rendered = engine.render(
"{stream.size::bytes} {stream.audioTags::join(' | ')}",
mapOf(
"stream.size" to 1_610_612_736L,
"stream.audioTags" to listOf("DTS", "Atmos"),
),
)
assertEquals("1.5 GB DTS | Atmos", rendered)
}
@Test
fun `renders Debrid size values as readable text while keeping numeric comparisons`() {
val rendered = engine.render(
"{stream.size::>0[\"{stream.size}\"||\"\"]}",
mapOf("stream.size" to DebridTemplateBytes(7_361_184_308L)),
)
assertEquals("6.9 GB", rendered)
}
}

View file

@ -1,27 +0,0 @@
package com.nuvio.app.features.debrid
import kotlin.test.Test
import kotlin.test.assertEquals
class DirectDebridConfigEncoderTest {
@Test
fun `encodes Torbox config exactly like TV`() {
val encoded = DirectDebridConfigEncoder().encodeTorbox("tb_key")
assertEquals(
"eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InRvcmJveCIsImFwaUtleSI6InRiX2tleSJ9XSwiZW5hYmxlVG9ycmVudCI6ZmFsc2V9",
encoded,
)
}
@Test
fun `escapes API key before base64 encoding`() {
val encoded = DirectDebridConfigEncoder().encode(
DebridServiceCredential(DebridProviders.RealDebrid, "rd\"key\\line"),
)
val expected = "eyJjYWNoZWRPbmx5Ijp0cnVlLCJkZWJyaWRTZXJ2aWNlcyI6W3sic2VydmljZSI6InJlYWxkZWJyaWQiLCJhcGlLZXkiOiJyZFwia2V5XFxsaW5lIn1dLCJlbmFibGVUb3JyZW50IjpmYWxzZX0="
assertEquals(expected, encoded)
}
}

View file

@ -1,210 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamClientResolveParsed
import com.nuvio.app.features.streams.StreamClientResolveRaw
import com.nuvio.app.features.streams.StreamClientResolveStream
import com.nuvio.app.features.streams.StreamItem
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DirectDebridStreamFilterTest {
@Test
fun `keeps only cached supported debrid streams`() {
val torbox = stream(service = DebridProviders.TORBOX_ID, cached = true)
val uncached = stream(service = DebridProviders.TORBOX_ID, cached = false)
val unsupported = stream(service = "other", cached = true)
val torrent = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, type = "torrent")
val filtered = DirectDebridStreamFilter.filterInstant(listOf(torbox, uncached, unsupported, torrent))
assertEquals(1, filtered.size)
assertEquals("Torbox Instant", filtered.single().addonName)
assertEquals("debrid:torbox", filtered.single().addonId)
}
@Test
fun `dedupes by hash file and filename identity`() {
val first = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "ABC", fileIdx = 2)
val duplicate = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 2)
val otherFile = stream(service = DebridProviders.REAL_DEBRID_ID, cached = true, infoHash = "abc", fileIdx = 3)
val filtered = DirectDebridStreamFilter.filterInstant(listOf(first, duplicate, otherFile))
assertEquals(2, filtered.size)
}
@Test
fun `direct debrid stream is not treated as unsupported torrent`() {
val direct = stream(service = DebridProviders.TORBOX_ID, cached = true, infoHash = "hash")
val plainTorrent = StreamItem(
name = "Torrent",
infoHash = "hash",
addonName = "Addon",
addonId = "addon",
)
assertTrue(direct.isDirectDebridStream)
assertFalse(direct.isTorrentStream)
assertTrue(plainTorrent.isTorrentStream)
}
@Test
fun `sorts and limits streams by quality and size`() {
val streams = listOf(
stream(resolution = "1080p", size = 20),
stream(resolution = "2160p", size = 10),
stream(resolution = "2160p", size = 30),
stream(resolution = "720p", size = 40),
)
val filtered = DirectDebridStreamFilter.filterInstant(
streams,
DebridSettings(
streamMaxResults = 2,
streamSortMode = DebridStreamSortMode.QUALITY_DESC,
),
)
assertEquals(listOf(30L, 10L), filtered.map { it.clientResolve?.stream?.raw?.size })
}
@Test
fun `filters minimum quality dolby vision hdr and codec`() {
val hdrHevc = stream(resolution = "2160p", hdr = listOf("HDR10"), codec = "HEVC", size = 10)
val dvHevc = stream(resolution = "2160p", hdr = listOf("DV", "HDR10"), codec = "HEVC", size = 20)
val sdrAvc = stream(resolution = "1080p", codec = "AVC", size = 30)
val hdHevc = stream(resolution = "720p", codec = "HEVC", size = 40)
val noDvHdrHevc4k = DirectDebridStreamFilter.filterInstant(
listOf(hdrHevc, dvHevc, sdrAvc, hdHevc),
DebridSettings(
streamMinimumQuality = DebridStreamMinimumQuality.P2160,
streamDolbyVisionFilter = DebridStreamFeatureFilter.EXCLUDE,
streamHdrFilter = DebridStreamFeatureFilter.ONLY,
streamCodecFilter = DebridStreamCodecFilter.HEVC,
),
)
assertEquals(listOf(10L), noDvHdrHevc4k.map { it.clientResolve?.stream?.raw?.size })
val dvOnly = DirectDebridStreamFilter.filterInstant(
listOf(hdrHevc, dvHevc, sdrAvc, hdHevc),
DebridSettings(streamDolbyVisionFilter = DebridStreamFeatureFilter.ONLY),
)
assertEquals(listOf(20L), dvOnly.map { it.clientResolve?.stream?.raw?.size })
}
@Test
fun `applies stream preference filters and sort criteria`() {
val remuxAtmos = stream(
resolution = "2160p",
quality = "BluRay REMUX",
codec = "HEVC",
audio = listOf("Atmos", "TrueHD"),
channels = listOf("7.1"),
languages = listOf("en"),
group = "GOOD",
size = 40_000_000_000,
)
val webAac = stream(
resolution = "2160p",
quality = "WEB-DL",
codec = "AVC",
audio = listOf("AAC"),
channels = listOf("2.0"),
languages = listOf("en"),
group = "NOPE",
size = 4_000_000_000,
)
val blurayDts = stream(
resolution = "1080p",
quality = "BluRay",
codec = "AVC",
audio = listOf("DTS"),
channels = listOf("5.1"),
languages = listOf("hi"),
group = "GOOD",
size = 12_000_000_000,
)
val filtered = DirectDebridStreamFilter.filterInstant(
listOf(webAac, blurayDts, remuxAtmos),
DebridSettings(
streamPreferences = DebridStreamPreferences(
maxResults = 2,
maxPerResolution = 1,
sizeMinGb = 5,
requiredResolutions = listOf(DebridStreamResolution.P2160, DebridStreamResolution.P1080),
excludedQualities = listOf(DebridStreamQuality.WEB_DL),
requiredAudioChannels = listOf(DebridStreamAudioChannel.CH_7_1, DebridStreamAudioChannel.CH_5_1),
excludedEncodes = listOf(DebridStreamEncode.UNKNOWN),
excludedLanguages = listOf(DebridStreamLanguage.IT),
requiredReleaseGroups = listOf("GOOD"),
sortCriteria = listOf(
DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC),
DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC),
),
),
),
)
assertEquals(listOf(40_000_000_000L, 12_000_000_000L), filtered.map { it.clientResolve?.stream?.raw?.size })
}
private fun stream(
service: String? = DebridProviders.TORBOX_ID,
cached: Boolean? = true,
type: String = "debrid",
infoHash: String = "hash",
fileIdx: Int = 1,
resolution: String? = null,
quality: String? = null,
hdr: List<String> = emptyList(),
codec: String? = null,
audio: List<String> = emptyList(),
channels: List<String> = emptyList(),
languages: List<String> = emptyList(),
group: String? = null,
size: Long? = null,
): StreamItem =
StreamItem(
name = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}",
description = "Stream ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}",
addonName = "Direct Debrid",
addonId = "debrid",
clientResolve = StreamClientResolve(
type = type,
service = service,
isCached = cached,
infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(),
fileIdx = fileIdx,
filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv",
torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}",
stream = StreamClientResolveStream(
raw = StreamClientResolveRaw(
torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}",
filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv",
size = size,
folderSize = size,
parsed = StreamClientResolveParsed(
resolution = resolution,
quality = quality,
hdr = hdr,
codec = codec,
audio = audio,
channels = channels,
languages = languages,
group = group,
),
),
),
),
)
}
private fun Long?.orEmptyHashPart(): String =
this?.toString().orEmpty()

View file

@ -1,70 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.features.player.PlayerSettingsUiState
import com.nuvio.app.features.streams.StreamAutoPlayMode
import com.nuvio.app.features.streams.StreamClientResolve
import com.nuvio.app.features.streams.StreamItem
import kotlin.test.Test
import kotlin.test.assertEquals
class DirectDebridStreamPreparerTest {
@Test
fun `prioritizes autoplay direct debrid match before display order`() {
val first = directDebridStream(name = "1080p", infoHash = "hash-1")
val autoPlayMatch = directDebridStream(name = "2160p WEB", infoHash = "hash-2")
val remaining = directDebridStream(name = "720p", infoHash = "hash-3")
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
streams = listOf(first, autoPlayMatch, remaining),
limit = 2,
playerSettings = PlayerSettingsUiState(
streamAutoPlayMode = StreamAutoPlayMode.REGEX_MATCH,
streamAutoPlayRegex = "2160p",
),
installedAddonNames = emptySet(),
)
assertEquals(listOf(autoPlayMatch, first), selected)
}
@Test
fun `skips already resolved and duplicate direct debrid candidates`() {
val unresolved = directDebridStream(name = "1080p", infoHash = "hash-1")
val duplicate = directDebridStream(name = "1080p Duplicate", infoHash = "HASH-1")
val alreadyResolved = directDebridStream(
name = "2160p",
infoHash = "hash-2",
url = "https://example.com/ready.mp4",
)
val selected = DirectDebridStreamPreparer.prioritizeCandidates(
streams = listOf(unresolved, duplicate, alreadyResolved),
limit = 5,
playerSettings = PlayerSettingsUiState(),
installedAddonNames = emptySet(),
)
assertEquals(listOf(unresolved), selected)
}
private fun directDebridStream(
name: String,
infoHash: String,
url: String? = null,
): StreamItem =
StreamItem(
name = name,
url = url,
addonName = "Torbox Instant",
addonId = "debrid:torbox",
clientResolve = StreamClientResolve(
type = "debrid",
service = DebridProviders.TORBOX_ID,
isCached = true,
infoHash = infoHash,
fileIdx = 1,
filename = "video.mkv",
),
)
}

View file

@ -145,49 +145,16 @@ class StreamAutoPlaySelectorTest {
assertNull(selected)
}
@Test
fun `first stream mode can select direct debrid candidate without resolved URL`() {
val directDebrid = stream(
addonName = "Torbox Instant",
url = null,
name = "TB Instant",
directDebrid = true,
)
val selected = StreamAutoPlaySelector.selectAutoPlayStream(
streams = listOf(directDebrid),
mode = StreamAutoPlayMode.FIRST_STREAM,
regexPattern = "",
source = StreamAutoPlaySource.ALL_SOURCES,
installedAddonNames = emptySet(),
selectedAddons = emptySet(),
selectedPlugins = emptySet(),
)
assertEquals(directDebrid, selected)
}
private fun stream(
addonName: String,
url: String? = null,
name: String? = null,
bingeGroup: String? = null,
directDebrid: Boolean = false,
): StreamItem = StreamItem(
name = name,
url = url,
addonName = addonName,
addonId = addonName,
clientResolve = if (directDebrid) {
StreamClientResolve(
type = "debrid",
service = "torbox",
isCached = true,
infoHash = "hash",
)
} else {
null
},
behaviorHints = StreamBehaviorHints(
bingeGroup = bingeGroup,
),

View file

@ -120,55 +120,4 @@ class StreamParserTest {
assertEquals("ok", responseHeaders["x-test"])
}
@Test
fun `parse keeps client resolve metadata without direct URL`() {
val streams = StreamParser.parse(
payload =
"""
{
"streams": [
{
"name": "Instant",
"clientResolve": {
"type": "debrid",
"infoHash": "abc123",
"fileIdx": 4,
"sources": ["udp://tracker.example"],
"torrentName": "Movie Pack",
"filename": "Movie.2024.2160p.mkv",
"service": "torbox",
"isCached": true,
"stream": {
"raw": {
"size": 1610612736,
"indexer": "Indexer",
"parsed": {
"parsed_title": "Movie",
"year": 2024,
"resolution": "2160p",
"hdr": ["DV"],
"audio": ["Atmos"],
"episodes": [1, 2],
"bit_depth": "10bit"
}
}
}
}
}
]
}
""".trimIndent(),
addonName = "Direct Debrid",
addonId = "debrid:torbox",
)
val stream = streams.single()
assertTrue(stream.isDirectDebridStream)
assertFalse(stream.isTorrentStream)
assertEquals("abc123", stream.clientResolve?.infoHash)
assertEquals(4, stream.clientResolve?.fileIdx)
assertEquals("udp://tracker.example", stream.clientResolve?.sources?.single())
assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution)
assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes)
}
}

View file

@ -1,193 +0,0 @@
package com.nuvio.app.features.debrid
import com.nuvio.app.core.storage.ProfileScopedKey
import com.nuvio.app.core.sync.decodeSyncBoolean
import com.nuvio.app.core.sync.decodeSyncInt
import com.nuvio.app.core.sync.decodeSyncString
import com.nuvio.app.core.sync.encodeSyncBoolean
import com.nuvio.app.core.sync.encodeSyncInt
import com.nuvio.app.core.sync.encodeSyncString
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import platform.Foundation.NSUserDefaults
actual object DebridSettingsStorage {
private const val enabledKey = "debrid_enabled"
private const val torboxApiKeyKey = "debrid_torbox_api_key"
private const val realDebridApiKeyKey = "debrid_real_debrid_api_key"
private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit"
private const val streamMaxResultsKey = "debrid_stream_max_results"
private const val streamSortModeKey = "debrid_stream_sort_mode"
private const val streamMinimumQualityKey = "debrid_stream_minimum_quality"
private const val streamDolbyVisionFilterKey = "debrid_stream_dolby_vision_filter"
private const val streamHdrFilterKey = "debrid_stream_hdr_filter"
private const val streamCodecFilterKey = "debrid_stream_codec_filter"
private const val streamPreferencesKey = "debrid_stream_preferences"
private const val streamNameTemplateKey = "debrid_stream_name_template"
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
private val syncKeys = listOf(
enabledKey,
torboxApiKeyKey,
realDebridApiKeyKey,
instantPlaybackPreparationLimitKey,
streamMaxResultsKey,
streamSortModeKey,
streamMinimumQualityKey,
streamDolbyVisionFilterKey,
streamHdrFilterKey,
streamCodecFilterKey,
streamPreferencesKey,
streamNameTemplateKey,
streamDescriptionTemplateKey,
)
actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey)
actual fun saveEnabled(enabled: Boolean) {
saveBoolean(enabledKey, enabled)
}
actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey)
actual fun saveTorboxApiKey(apiKey: String) {
saveString(torboxApiKeyKey, apiKey)
}
actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey)
actual fun saveRealDebridApiKey(apiKey: String) {
saveString(realDebridApiKeyKey, apiKey)
}
actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey)
actual fun saveInstantPlaybackPreparationLimit(limit: Int) {
saveInt(instantPlaybackPreparationLimitKey, limit)
}
actual fun loadStreamMaxResults(): Int? = loadInt(streamMaxResultsKey)
actual fun saveStreamMaxResults(maxResults: Int) {
saveInt(streamMaxResultsKey, maxResults)
}
actual fun loadStreamSortMode(): String? = loadString(streamSortModeKey)
actual fun saveStreamSortMode(mode: String) {
saveString(streamSortModeKey, mode)
}
actual fun loadStreamMinimumQuality(): String? = loadString(streamMinimumQualityKey)
actual fun saveStreamMinimumQuality(quality: String) {
saveString(streamMinimumQualityKey, quality)
}
actual fun loadStreamDolbyVisionFilter(): String? = loadString(streamDolbyVisionFilterKey)
actual fun saveStreamDolbyVisionFilter(filter: String) {
saveString(streamDolbyVisionFilterKey, filter)
}
actual fun loadStreamHdrFilter(): String? = loadString(streamHdrFilterKey)
actual fun saveStreamHdrFilter(filter: String) {
saveString(streamHdrFilterKey, filter)
}
actual fun loadStreamCodecFilter(): String? = loadString(streamCodecFilterKey)
actual fun saveStreamCodecFilter(filter: String) {
saveString(streamCodecFilterKey, filter)
}
actual fun loadStreamPreferences(): String? = loadString(streamPreferencesKey)
actual fun saveStreamPreferences(preferences: String) {
saveString(streamPreferencesKey, preferences)
}
actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey)
actual fun saveStreamNameTemplate(template: String) {
saveString(streamNameTemplateKey, template)
}
actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey)
actual fun saveStreamDescriptionTemplate(template: String) {
saveString(streamDescriptionTemplateKey, template)
}
private fun loadBoolean(key: String): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val scopedKey = ProfileScopedKey.of(key)
return if (defaults.objectForKey(scopedKey) != null) {
defaults.boolForKey(scopedKey)
} else {
null
}
}
private fun saveBoolean(key: String, enabled: Boolean) {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(key))
}
private fun loadInt(key: String): Int? {
val defaults = NSUserDefaults.standardUserDefaults
val scopedKey = ProfileScopedKey.of(key)
return if (defaults.objectForKey(scopedKey) != null) {
defaults.integerForKey(scopedKey).toInt()
} else {
null
}
}
private fun saveInt(key: String, value: Int) {
NSUserDefaults.standardUserDefaults.setInteger(value.toLong(), forKey = ProfileScopedKey.of(key))
}
private fun loadString(key: String): String? =
NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(key))
private fun saveString(key: String, value: String) {
NSUserDefaults.standardUserDefaults.setObject(value, forKey = ProfileScopedKey.of(key))
}
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) }
loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) }
loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) }
loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) }
loadStreamMaxResults()?.let { put(streamMaxResultsKey, encodeSyncInt(it)) }
loadStreamSortMode()?.let { put(streamSortModeKey, encodeSyncString(it)) }
loadStreamMinimumQuality()?.let { put(streamMinimumQualityKey, encodeSyncString(it)) }
loadStreamDolbyVisionFilter()?.let { put(streamDolbyVisionFilterKey, encodeSyncString(it)) }
loadStreamHdrFilter()?.let { put(streamHdrFilterKey, encodeSyncString(it)) }
loadStreamCodecFilter()?.let { put(streamCodecFilterKey, encodeSyncString(it)) }
loadStreamPreferences()?.let { put(streamPreferencesKey, encodeSyncString(it)) }
loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) }
loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) }
}
actual fun replaceFromSyncPayload(payload: JsonObject) {
syncKeys.forEach { key ->
NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key))
}
payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled)
payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey)
payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey)
payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit)
payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults)
payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode)
payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality)
payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter)
payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter)
payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter)
payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences)
payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate)
payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate)
}
}