mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-22 17:52:06 +00:00
removal: debrid integration
This commit is contained in:
parent
88ea47d2f0
commit
4e5a32510b
47 changed files with 19 additions and 6176 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +379,6 @@
|
|||
<string name="compose_settings_page_appearance">Utseende</string>
|
||||
<string name="compose_settings_page_content_discovery">Innhold & 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 & 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -380,7 +380,6 @@
|
|||
<string name="compose_settings_page_appearance">Layout</string>
|
||||
<string name="compose_settings_page_content_discovery">Content & 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 & 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'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>
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}\"||\"\"]}"
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue