From 3e40e47b78e9d11c68c383f4668a22e07a2985c7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 19 May 2026 02:21:25 +0530 Subject: [PATCH] feat(debrid): sorting and filtering --- .../debrid/DebridSettingsStorage.android.kt | 70 ++ .../composeResources/values/strings.xml | 7 + .../app/features/debrid/DebridSettings.kt | 243 ++++- .../debrid/DebridSettingsRepository.kt | 289 ++++- .../features/debrid/DebridSettingsStorage.kt | 14 + .../debrid/DirectDebridStreamFilter.kt | 390 ++++++- .../debrid/DirectDebridStreamSource.kt | 173 ++- .../features/settings/DebridSettingsPage.kt | 992 +++++++++++++++--- .../debrid/DirectDebridStreamFilterTest.kt | 148 ++- .../debrid/DebridSettingsStorage.ios.kt | 70 ++ 10 files changed, 2232 insertions(+), 164 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index 2ae1bccc..d1ff44e5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -19,6 +19,13 @@ actual object DebridSettingsStorage { 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( @@ -26,6 +33,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -60,6 +74,48 @@ actual object DebridSettingsStorage { 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) { @@ -121,6 +177,13 @@ actual object DebridSettingsStorage { 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)) } } @@ -134,6 +197,13 @@ actual object DebridSettingsStorage { payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncInt(streamMaxResultsKey)?.let(::saveStreamMaxResults) + payload.decodeSyncString(streamSortModeKey)?.let(::saveStreamSortMode) + payload.decodeSyncString(streamMinimumQualityKey)?.let(::saveStreamMinimumQuality) + payload.decodeSyncString(streamDolbyVisionFilterKey)?.let(::saveStreamDolbyVisionFilter) + payload.decodeSyncString(streamHdrFilterKey)?.let(::saveStreamHdrFilter) + payload.decodeSyncString(streamCodecFilterKey)?.let(::saveStreamCodecFilter) + payload.decodeSyncString(streamPreferencesKey)?.let(::saveStreamPreferences) payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) } diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 955e47cd..d5112ecd 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -18,6 +18,7 @@ Resume Retry Save + Saving… Validate Installing Addons @@ -596,6 +597,10 @@ Add an API key first. Account Connect your Torbox account. + Torbox API Key + Enter your Torbox API key. + Enter Torbox API key + Not set Instant Playback Prepare links Resolve the first sources before playback starts. @@ -607,6 +612,8 @@ Controls how source names appear. Description template Controls the metadata shown under each source. + Reset formatting + Restore default source formatting. API key validated. Could not validate this API key. Add your MDBList API key below before turning ratings on. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt index b2d40b0f..6e48cc07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettings.kt @@ -1,10 +1,19 @@ 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, ) { @@ -12,8 +21,236 @@ data class DebridSettings( get() = DebridProviders.configuredServices(this).isNotEmpty() } -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 -internal const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT = 2 +const val DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT = 5 -internal fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = +enum class DebridStreamSortMode { + DEFAULT, + QUALITY_DESC, + SIZE_DESC, + SIZE_ASC, +} + +enum class DebridStreamMinimumQuality(val minResolution: Int) { + ANY(0), + P720(720), + P1080(1080), + P2160(2160), +} + +enum class DebridStreamFeatureFilter { + ANY, + EXCLUDE, + ONLY, +} + +enum class DebridStreamCodecFilter { + ANY, + H264, + HEVC, + AV1, +} + +@Serializable +data class DebridStreamPreferences( + val maxResults: Int = 0, + val maxPerResolution: Int = 0, + val maxPerQuality: Int = 0, + val sizeMinGb: Int = 0, + val sizeMaxGb: Int = 0, + val preferredResolutions: List = DebridStreamResolution.defaultOrder, + val requiredResolutions: List = emptyList(), + val excludedResolutions: List = emptyList(), + val preferredQualities: List = DebridStreamQuality.defaultOrder, + val requiredQualities: List = emptyList(), + val excludedQualities: List = emptyList(), + val preferredVisualTags: List = DebridStreamVisualTag.defaultOrder, + val requiredVisualTags: List = emptyList(), + val excludedVisualTags: List = emptyList(), + val preferredAudioTags: List = DebridStreamAudioTag.defaultOrder, + val requiredAudioTags: List = emptyList(), + val excludedAudioTags: List = emptyList(), + val preferredAudioChannels: List = DebridStreamAudioChannel.defaultOrder, + val requiredAudioChannels: List = emptyList(), + val excludedAudioChannels: List = emptyList(), + val preferredEncodes: List = DebridStreamEncode.defaultOrder, + val requiredEncodes: List = emptyList(), + val excludedEncodes: List = emptyList(), + val preferredLanguages: List = emptyList(), + val requiredLanguages: List = emptyList(), + val excludedLanguages: List = emptyList(), + val requiredReleaseGroups: List = emptyList(), + val excludedReleaseGroups: List = emptyList(), + val sortCriteria: List = DebridStreamSortCriterion.defaultOrder, +) + +@Serializable +enum class DebridStreamResolution(val label: String, val value: Int) { + P2160("2160p", 2160), + P1440("1440p", 1440), + P1080("1080p", 1080), + P720("720p", 720), + P576("576p", 576), + P480("480p", 480), + P360("360p", 360), + UNKNOWN("Unknown", 0); + + companion object { + val defaultOrder = listOf(P2160, P1440, P1080, P720, P576, P480, P360, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamQuality(val label: String) { + BLURAY_REMUX("BluRay REMUX"), + BLURAY("BluRay"), + WEB_DL("WEB-DL"), + WEBRIP("WEBRip"), + HDRIP("HDRip"), + HD_RIP("HC HD-Rip"), + DVDRIP("DVDRip"), + HDTV("HDTV"), + CAM("CAM"), + TS("TS"), + TC("TC"), + SCR("SCR"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(BLURAY_REMUX, BLURAY, WEB_DL, WEBRIP, HDRIP, HD_RIP, DVDRIP, HDTV, CAM, TS, TC, SCR, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamVisualTag(val label: String) { + HDR_DV("HDR+DV"), + DV_ONLY("DV Only"), + HDR_ONLY("HDR Only"), + HDR10_PLUS("HDR10+"), + HDR10("HDR10"), + DV("DV"), + HDR("HDR"), + HLG("HLG"), + TEN_BIT("10bit"), + THREE_D("3D"), + IMAX("IMAX"), + AI("AI"), + SDR("SDR"), + H_OU("H-OU"), + H_SBS("H-SBS"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(HDR_DV, DV_ONLY, HDR_ONLY, HDR10_PLUS, HDR10, DV, HDR, HLG, TEN_BIT, IMAX, SDR, THREE_D, AI, H_OU, H_SBS, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioTag(val label: String) { + ATMOS("Atmos"), + DD_PLUS("DD+"), + DD("DD"), + DTS_X("DTS:X"), + DTS_HD_MA("DTS-HD MA"), + DTS_HD("DTS-HD"), + DTS_ES("DTS-ES"), + DTS("DTS"), + TRUEHD("TrueHD"), + OPUS("OPUS"), + FLAC("FLAC"), + AAC("AAC"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(ATMOS, DD_PLUS, DD, DTS_X, DTS_HD_MA, DTS_HD, DTS_ES, DTS, TRUEHD, OPUS, FLAC, AAC, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamAudioChannel(val label: String) { + CH_2_0("2.0"), + CH_5_1("5.1"), + CH_6_1("6.1"), + CH_7_1("7.1"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(CH_7_1, CH_6_1, CH_5_1, CH_2_0, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamEncode(val label: String) { + AV1("AV1"), + HEVC("HEVC"), + AVC("AVC"), + XVID("XviD"), + DIVX("DivX"), + UNKNOWN("Unknown"); + + companion object { + val defaultOrder = listOf(AV1, HEVC, AVC, XVID, DIVX, UNKNOWN) + } +} + +@Serializable +enum class DebridStreamLanguage(val code: String, val label: String) { + EN("en", "English"), + HI("hi", "Hindi"), + IT("it", "Italian"), + ES("es", "Spanish"), + FR("fr", "French"), + DE("de", "German"), + PT("pt", "Portuguese"), + PL("pl", "Polish"), + CS("cs", "Czech"), + LA("la", "Latino"), + JA("ja", "Japanese"), + KO("ko", "Korean"), + ZH("zh", "Chinese"), + MULTI("multi", "Multi"), + UNKNOWN("unknown", "Unknown"), +} + +@Serializable +data class DebridStreamSortCriterion( + val key: DebridStreamSortKey = DebridStreamSortKey.RESOLUTION, + val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC, +) { + companion object { + val defaultOrder = listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.VISUAL_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.ENCODE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } +} + +@Serializable +enum class DebridStreamSortKey(val label: String) { + RESOLUTION("Resolution"), + QUALITY("Quality"), + VISUAL_TAG("Visual tag"), + AUDIO_TAG("Audio"), + AUDIO_CHANNEL("Audio channel"), + ENCODE("Encode"), + SIZE("Size"), + LANGUAGE("Language"), + RELEASE_GROUP("Release group"), +} + +@Serializable +enum class DebridStreamSortDirection { + ASC, + DESC, +} + +fun normalizeDebridInstantPlaybackPreparationLimit(value: Int): Int = value.coerceIn(0, DEBRID_PREPARE_INSTANT_PLAYBACK_MAX_LIMIT) + +fun normalizeDebridStreamMaxResults(value: Int): Int = + if (value <= 0) 0 else value.coerceIn(1, 100) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt index 17938a41..d8c7625b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsRepository.kt @@ -3,16 +3,34 @@ package com.nuvio.app.features.debrid import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json object DebridSettingsRepository { private val _uiState = MutableStateFlow(DebridSettings()) val uiState: StateFlow = _uiState.asStateFlow() + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + private var hasLoaded = false private var enabled = false private var torboxApiKey = "" private var realDebridApiKey = "" private var instantPlaybackPreparationLimit = 0 + private var streamMaxResults = 0 + private var streamSortMode = DebridStreamSortMode.DEFAULT + private var streamMinimumQuality = DebridStreamMinimumQuality.ANY + private var streamDolbyVisionFilter = DebridStreamFeatureFilter.ANY + private var streamHdrFilter = DebridStreamFeatureFilter.ANY + private var streamCodecFilter = DebridStreamCodecFilter.ANY + private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE @@ -68,6 +86,78 @@ object DebridSettingsRepository { 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 } @@ -86,15 +176,22 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamDescriptionTemplate(normalized) } - fun resetStreamTemplates() { + fun setStreamTemplates(nameTemplate: String, descriptionTemplate: String) { ensureLoaded() - streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE - streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + 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 @@ -114,6 +211,36 @@ object DebridSettingsRepository { 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 @@ -129,8 +256,164 @@ object DebridSettingsRepository { torboxApiKey = torboxApiKey, realDebridApiKey = realDebridApiKey, instantPlaybackPreparationLimit = instantPlaybackPreparationLimit, + streamMaxResults = streamMaxResults, + streamSortMode = streamSortMode, + streamMinimumQuality = streamMinimumQuality, + streamDolbyVisionFilter = streamDolbyVisionFilter, + streamHdrFilter = streamHdrFilter, + streamCodecFilter = streamCodecFilter, + streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, ) } + + private fun saveStreamPreferences() { + DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) + } + + private inline fun > enumValueOrDefault(value: String?, default: T): T = + runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) + + private fun parseStreamPreferences(value: String?): DebridStreamPreferences? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value).normalized() + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } } + +internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences = + copy( + maxResults = normalizeDebridStreamMaxResults(maxResults), + maxPerResolution = maxPerResolution.coerceIn(0, 100), + maxPerQuality = maxPerQuality.coerceIn(0, 100), + sizeMinGb = sizeMinGb.coerceIn(0, 100), + sizeMaxGb = sizeMaxGb.coerceIn(0, 100), + preferredResolutions = preferredResolutions.ifEmpty { DebridStreamResolution.defaultOrder }, + requiredResolutions = requiredResolutions, + excludedResolutions = excludedResolutions, + preferredQualities = preferredQualities.ifEmpty { DebridStreamQuality.defaultOrder }, + requiredQualities = requiredQualities, + excludedQualities = excludedQualities, + preferredVisualTags = preferredVisualTags.ifEmpty { DebridStreamVisualTag.defaultOrder }, + requiredVisualTags = requiredVisualTags, + excludedVisualTags = excludedVisualTags, + preferredAudioTags = preferredAudioTags.ifEmpty { DebridStreamAudioTag.defaultOrder }, + requiredAudioTags = requiredAudioTags, + excludedAudioTags = excludedAudioTags, + preferredAudioChannels = preferredAudioChannels.ifEmpty { DebridStreamAudioChannel.defaultOrder }, + requiredAudioChannels = requiredAudioChannels, + excludedAudioChannels = excludedAudioChannels, + preferredEncodes = preferredEncodes.ifEmpty { DebridStreamEncode.defaultOrder }, + requiredEncodes = requiredEncodes, + excludedEncodes = excludedEncodes, + preferredLanguages = preferredLanguages, + requiredLanguages = requiredLanguages, + excludedLanguages = excludedLanguages, + requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(), + sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }, + ) + +private fun legacyStreamPreferences( + maxResults: Int, + sortMode: DebridStreamSortMode, + minimumQuality: DebridStreamMinimumQuality, + dolbyVisionFilter: DebridStreamFeatureFilter, + hdrFilter: DebridStreamFeatureFilter, + codecFilter: DebridStreamCodecFilter, +): DebridStreamPreferences = + DebridStreamPreferences( + maxResults = normalizeDebridStreamMaxResults(maxResults), + sortCriteria = sortCriteriaForLegacyMode(sortMode), + requiredResolutions = resolutionsForMinimumQuality(minimumQuality), + ) + .applyDolbyVisionFilter(dolbyVisionFilter) + .applyHdrFilter(hdrFilter) + .applyCodecFilter(codecFilter) + .normalized() + +private fun DebridStreamPreferences.applyDolbyVisionFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - dolbyVisionTags.toSet(), + excludedVisualTags = (excludedVisualTags + dolbyVisionTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + dolbyVisionTags).distinct(), + excludedVisualTags = excludedVisualTags - dolbyVisionTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyHdrFilter( + filter: DebridStreamFeatureFilter, +): DebridStreamPreferences = + when (filter) { + DebridStreamFeatureFilter.ANY -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + DebridStreamFeatureFilter.EXCLUDE -> copy( + requiredVisualTags = requiredVisualTags - hdrTags.toSet(), + excludedVisualTags = (excludedVisualTags + hdrTags).distinct(), + ) + DebridStreamFeatureFilter.ONLY -> copy( + requiredVisualTags = (requiredVisualTags + hdrTags).distinct(), + excludedVisualTags = excludedVisualTags - hdrTags.toSet(), + ) + } + +private fun DebridStreamPreferences.applyCodecFilter( + filter: DebridStreamCodecFilter, +): DebridStreamPreferences = + copy( + requiredEncodes = when (filter) { + DebridStreamCodecFilter.ANY -> emptyList() + DebridStreamCodecFilter.H264 -> listOf(DebridStreamEncode.AVC) + DebridStreamCodecFilter.HEVC -> listOf(DebridStreamEncode.HEVC) + DebridStreamCodecFilter.AV1 -> listOf(DebridStreamEncode.AV1) + }, + ) + +private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): List = + DebridStreamResolution.defaultOrder.filter { + it.value >= quality.minResolution && it != DebridStreamResolution.UNKNOWN + } + +private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List = + when (mode) { + DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + } + +private val dolbyVisionTags = listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, +) + +private val hdrTags = listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 6c4f238f..62fddac4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -11,6 +11,20 @@ internal expect object DebridSettingsStorage { 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? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt index 28c03dde..6647d607 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilter.kt @@ -5,8 +5,8 @@ import com.nuvio.app.features.streams.StreamItem object DirectDebridStreamFilter { const val FALLBACK_SOURCE_NAME = "Direct Debrid" - fun filterInstant(streams: List): List = - streams + fun filterInstant(streams: List, settings: DebridSettings? = null): List { + val instantStreams = streams .filter(::isInstantCandidate) .map { stream -> val providerId = stream.clientResolve?.service @@ -27,6 +27,8 @@ object DirectDebridStreamFilter { stream.title, ).joinToString("|") } + return if (settings == null) instantStreams else applyPreferences(instantStreams, settings) + } fun isInstantCandidate(stream: StreamItem): Boolean { val resolve = stream.clientResolve ?: return false @@ -37,5 +39,387 @@ object DirectDebridStreamFilter { fun isDirectDebridSourceName(addonName: String): Boolean = DebridProviders.all().any { addonName == DebridProviders.instantName(it.id) } -} + private fun applyPreferences(streams: List, settings: DebridSettings): List { + val preferences = effectivePreferences(settings) + return streams.map { it to streamFacts(it, preferences) } + .filter { (_, facts) -> facts.matchesFilters(preferences) } + .sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) } + .let { sorted -> applyLimits(sorted, preferences) } + .map { it.first } + } + + private fun effectivePreferences(settings: DebridSettings): DebridStreamPreferences { + val default = DebridStreamPreferences() + if (settings.streamPreferences != default) return settings.streamPreferences.normalized() + if ( + settings.streamMaxResults == 0 && + settings.streamSortMode == DebridStreamSortMode.DEFAULT && + settings.streamMinimumQuality == DebridStreamMinimumQuality.ANY && + settings.streamDolbyVisionFilter == DebridStreamFeatureFilter.ANY && + settings.streamHdrFilter == DebridStreamFeatureFilter.ANY && + settings.streamCodecFilter == DebridStreamCodecFilter.ANY + ) { + return default + } + var preferences = default.copy( + maxResults = settings.streamMaxResults, + sortCriteria = when (settings.streamSortMode) { + DebridStreamSortMode.DEFAULT -> default.sortCriteria + DebridStreamSortMode.QUALITY_DESC -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridStreamSortMode.SIZE_DESC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + }, + requiredResolutions = DebridStreamResolution.defaultOrder.filter { + it.value >= settings.streamMinimumQuality.minResolution && it != DebridStreamResolution.UNKNOWN + }, + ) + preferences = when (settings.streamDolbyVisionFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.DV, + DebridStreamVisualTag.DV_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + preferences = when (settings.streamHdrFilter) { + DebridStreamFeatureFilter.ANY -> preferences + DebridStreamFeatureFilter.EXCLUDE -> preferences.copy( + excludedVisualTags = preferences.excludedVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + DebridStreamFeatureFilter.ONLY -> preferences.copy( + requiredVisualTags = preferences.requiredVisualTags + listOf( + DebridStreamVisualTag.HDR, + DebridStreamVisualTag.HDR10, + DebridStreamVisualTag.HDR10_PLUS, + DebridStreamVisualTag.HLG, + DebridStreamVisualTag.HDR_ONLY, + DebridStreamVisualTag.HDR_DV, + ), + ) + } + return when (settings.streamCodecFilter) { + DebridStreamCodecFilter.ANY -> preferences + DebridStreamCodecFilter.H264 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AVC)) + DebridStreamCodecFilter.HEVC -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.HEVC)) + DebridStreamCodecFilter.AV1 -> preferences.copy(requiredEncodes = listOf(DebridStreamEncode.AV1)) + }.normalized() + } + + private fun applyLimits( + streams: List>, + preferences: DebridStreamPreferences, + ): List> { + val resolutionCounts = mutableMapOf() + val qualityCounts = mutableMapOf() + val result = mutableListOf>() + for (stream in streams) { + if (preferences.maxResults > 0 && result.size >= preferences.maxResults) break + if (preferences.maxPerResolution > 0) { + val count = resolutionCounts[stream.second.resolution] ?: 0 + if (count >= preferences.maxPerResolution) continue + } + if (preferences.maxPerQuality > 0) { + val count = qualityCounts[stream.second.quality] ?: 0 + if (count >= preferences.maxPerQuality) continue + } + resolutionCounts[stream.second.resolution] = (resolutionCounts[stream.second.resolution] ?: 0) + 1 + qualityCounts[stream.second.quality] = (qualityCounts[stream.second.quality] ?: 0) + 1 + result += stream + } + return result + } + + private fun StreamFacts.matchesFilters(preferences: DebridStreamPreferences): Boolean { + if (preferences.requiredResolutions.isNotEmpty() && resolution !in preferences.requiredResolutions) return false + if (resolution in preferences.excludedResolutions) return false + if (preferences.requiredQualities.isNotEmpty() && quality !in preferences.requiredQualities) return false + if (quality in preferences.excludedQualities) return false + if (preferences.requiredVisualTags.isNotEmpty() && visualTags.none { it in preferences.requiredVisualTags }) return false + if (visualTags.any { it in preferences.excludedVisualTags }) return false + if (preferences.requiredAudioTags.isNotEmpty() && audioTags.none { it in preferences.requiredAudioTags }) return false + if (audioTags.any { it in preferences.excludedAudioTags }) return false + if (preferences.requiredAudioChannels.isNotEmpty() && audioChannels.none { it in preferences.requiredAudioChannels }) return false + if (audioChannels.any { it in preferences.excludedAudioChannels }) return false + if (preferences.requiredEncodes.isNotEmpty() && encode !in preferences.requiredEncodes) return false + if (encode in preferences.excludedEncodes) return false + if (preferences.requiredLanguages.isNotEmpty() && languages.none { it in preferences.requiredLanguages }) return false + if (languages.isNotEmpty() && languages.all { it in preferences.excludedLanguages }) return false + if (preferences.requiredReleaseGroups.isNotEmpty() && preferences.requiredReleaseGroups.none { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.excludedReleaseGroups.any { releaseGroup.equals(it, ignoreCase = true) }) return false + if (preferences.sizeMinGb > 0 && size != null && size < preferences.sizeMinGb.gigabytes()) return false + if (preferences.sizeMaxGb > 0 && size != null && size > preferences.sizeMaxGb.gigabytes()) return false + return true + } + + private fun compareFacts( + left: StreamFacts, + right: StreamFacts, + criteria: List, + ): Int { + for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) { + val comparison = compareKey(left, right, criterion) + if (comparison != 0) return comparison + } + return 0 + } + + private fun compareKey( + left: StreamFacts, + right: StreamFacts, + criterion: DebridStreamSortCriterion, + ): Int { + val direction = if (criterion.direction == DebridStreamSortDirection.ASC) 1 else -1 + return when (criterion.key) { + DebridStreamSortKey.RESOLUTION -> left.resolutionRank.compareTo(right.resolutionRank) * -direction + DebridStreamSortKey.QUALITY -> left.qualityRank.compareTo(right.qualityRank) * -direction + DebridStreamSortKey.VISUAL_TAG -> left.visualRank.compareTo(right.visualRank) * -direction + DebridStreamSortKey.AUDIO_TAG -> left.audioRank.compareTo(right.audioRank) * -direction + DebridStreamSortKey.AUDIO_CHANNEL -> left.channelRank.compareTo(right.channelRank) * -direction + DebridStreamSortKey.ENCODE -> left.encodeRank.compareTo(right.encodeRank) * -direction + DebridStreamSortKey.SIZE -> (left.size ?: 0L).compareTo(right.size ?: 0L) * direction + DebridStreamSortKey.LANGUAGE -> left.languageRank.compareTo(right.languageRank) * -direction + DebridStreamSortKey.RELEASE_GROUP -> left.releaseGroup.compareTo(right.releaseGroup, ignoreCase = true) + } + } + + private fun streamFacts(stream: StreamItem, preferences: DebridStreamPreferences): StreamFacts { + val parsed = stream.clientResolve?.stream?.raw?.parsed + val searchText = streamSearchText(stream) + val resolution = streamResolution(parsed?.resolution, parsed?.quality, searchText) + val quality = streamQuality(parsed?.quality, searchText) + val visualTags = streamVisualTags(parsed?.hdr.orEmpty(), searchText) + val audioTags = streamAudioTags(parsed?.audio.orEmpty(), searchText) + val audioChannels = streamAudioChannels(parsed?.channels.orEmpty(), searchText) + val encode = streamEncode(parsed?.codec, searchText) + val languages = parsed?.languages.orEmpty().mapNotNull { languageFor(it) }.ifEmpty { + DebridStreamLanguage.entries.filter { searchText.hasToken(it.code) } + } + val releaseGroup = parsed?.group?.takeIf { it.isNotBlank() } ?: releaseGroupFromText(searchText) + return StreamFacts( + resolution = resolution, + quality = quality, + visualTags = visualTags, + audioTags = audioTags, + audioChannels = audioChannels, + encode = encode, + languages = languages, + releaseGroup = releaseGroup, + size = streamSize(stream), + resolutionRank = rank(resolution, preferences.preferredResolutions), + qualityRank = rank(quality, preferences.preferredQualities), + visualRank = rankAny(visualTags, preferences.preferredVisualTags), + audioRank = rankAny(audioTags, preferences.preferredAudioTags), + channelRank = rankAny(audioChannels, preferences.preferredAudioChannels), + encodeRank = rank(encode, preferences.preferredEncodes), + languageRank = if (languages.isEmpty()) Int.MAX_VALUE else languages.minOf { rank(it, preferences.preferredLanguages) }, + ) + } + + private fun streamResolution(vararg values: String?): DebridStreamResolution = + values.firstNotNullOfOrNull { resolutionValue(it) } ?: DebridStreamResolution.UNKNOWN + + private fun resolutionValue(value: String?): DebridStreamResolution? { + val normalized = value?.lowercase().orEmpty() + return when { + normalized.hasResolutionToken("2160p?", "4k", "uhd") -> DebridStreamResolution.P2160 + normalized.hasResolutionToken("1440p?", "2k") -> DebridStreamResolution.P1440 + normalized.hasResolutionToken("1080p?", "fhd") -> DebridStreamResolution.P1080 + normalized.hasResolutionToken("720p?", "hd") -> DebridStreamResolution.P720 + normalized.hasResolutionToken("576p?") -> DebridStreamResolution.P576 + normalized.hasResolutionToken("480p?", "sd") -> DebridStreamResolution.P480 + normalized.hasResolutionToken("360p?") -> DebridStreamResolution.P360 + else -> null + } + } + + private fun streamQuality(parsedQuality: String?, searchText: String): DebridStreamQuality { + val text = listOfNotNull(parsedQuality, searchText).joinToString(" ").lowercase() + return when { + text.contains("remux") -> DebridStreamQuality.BLURAY_REMUX + text.contains("blu-ray") || text.contains("bluray") || text.contains("bdrip") || text.contains("brrip") -> DebridStreamQuality.BLURAY + text.contains("web-dl") || text.contains("webdl") -> DebridStreamQuality.WEB_DL + text.contains("webrip") || text.contains("web-rip") -> DebridStreamQuality.WEBRIP + text.contains("hdrip") -> DebridStreamQuality.HDRIP + text.contains("hd-rip") || text.contains("hcrip") -> DebridStreamQuality.HD_RIP + text.contains("dvdrip") -> DebridStreamQuality.DVDRIP + text.contains("hdtv") -> DebridStreamQuality.HDTV + text.hasToken("cam") -> DebridStreamQuality.CAM + text.hasToken("ts") -> DebridStreamQuality.TS + text.hasToken("tc") -> DebridStreamQuality.TC + text.hasToken("scr") -> DebridStreamQuality.SCR + else -> DebridStreamQuality.UNKNOWN + } + } + + private fun streamVisualTags(parsedHdr: List, searchText: String): List { + val text = (parsedHdr + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + val hasDv = parsedHdr.any { it.isDolbyVisionToken() } || + Regex("(^|[^a-z0-9])(dv|dovi|dolby[ ._-]?vision)([^a-z0-9]|$)").containsMatchIn(searchText) + val hasHdr = parsedHdr.any { it.isHdrToken() } || + Regex("(^|[^a-z0-9])(hdr|hdr10|hdr10plus|hdr10\\+|hlg)([^a-z0-9]|$)").containsMatchIn(searchText) + if (hasDv && hasHdr) tags += DebridStreamVisualTag.HDR_DV + if (hasDv && !hasHdr) tags += DebridStreamVisualTag.DV_ONLY + if (hasHdr && !hasDv) tags += DebridStreamVisualTag.HDR_ONLY + if (text.contains("hdr10+") || text.contains("hdr10plus")) tags += DebridStreamVisualTag.HDR10_PLUS + if (text.contains("hdr10")) tags += DebridStreamVisualTag.HDR10 + if (hasDv) tags += DebridStreamVisualTag.DV + if (hasHdr) tags += DebridStreamVisualTag.HDR + if (text.hasToken("hlg")) tags += DebridStreamVisualTag.HLG + if (text.contains("10bit") || text.contains("10 bit")) tags += DebridStreamVisualTag.TEN_BIT + if (text.hasToken("3d")) tags += DebridStreamVisualTag.THREE_D + if (text.hasToken("imax")) tags += DebridStreamVisualTag.IMAX + if (text.hasToken("ai")) tags += DebridStreamVisualTag.AI + if (text.hasToken("sdr")) tags += DebridStreamVisualTag.SDR + if (text.contains("h-ou")) tags += DebridStreamVisualTag.H_OU + if (text.contains("h-sbs")) tags += DebridStreamVisualTag.H_SBS + return tags.distinct().ifEmpty { listOf(DebridStreamVisualTag.UNKNOWN) } + } + + private fun streamAudioTags(parsedAudio: List, searchText: String): List { + val text = (parsedAudio + searchText).joinToString(" ").lowercase() + val tags = mutableListOf() + if (text.hasToken("atmos")) tags += DebridStreamAudioTag.ATMOS + if (text.contains("dd+") || text.contains("ddp") || text.contains("dolby digital plus")) tags += DebridStreamAudioTag.DD_PLUS + if (text.hasToken("dd") || text.contains("ac3") || text.contains("dolby digital")) tags += DebridStreamAudioTag.DD + if (text.contains("dts:x") || text.contains("dtsx")) tags += DebridStreamAudioTag.DTS_X + if (text.contains("dts-hd ma") || text.contains("dtshd ma")) tags += DebridStreamAudioTag.DTS_HD_MA + if (text.contains("dts-hd") || text.contains("dtshd")) tags += DebridStreamAudioTag.DTS_HD + if (text.contains("dts-es") || text.contains("dtses")) tags += DebridStreamAudioTag.DTS_ES + if (text.hasToken("dts")) tags += DebridStreamAudioTag.DTS + if (text.contains("truehd") || text.contains("true hd")) tags += DebridStreamAudioTag.TRUEHD + if (text.hasToken("opus")) tags += DebridStreamAudioTag.OPUS + if (text.hasToken("flac")) tags += DebridStreamAudioTag.FLAC + if (text.hasToken("aac")) tags += DebridStreamAudioTag.AAC + return tags.distinct().ifEmpty { listOf(DebridStreamAudioTag.UNKNOWN) } + } + + private fun streamAudioChannels(parsedChannels: List, searchText: String): List { + val text = (parsedChannels + searchText).joinToString(" ").lowercase() + val channels = mutableListOf() + if (text.hasToken("7.1")) channels += DebridStreamAudioChannel.CH_7_1 + if (text.hasToken("6.1")) channels += DebridStreamAudioChannel.CH_6_1 + if (text.hasToken("5.1") || text.hasToken("6ch")) channels += DebridStreamAudioChannel.CH_5_1 + if (text.hasToken("2.0")) channels += DebridStreamAudioChannel.CH_2_0 + return channels.distinct().ifEmpty { listOf(DebridStreamAudioChannel.UNKNOWN) } + } + + private fun streamEncode(parsedCodec: String?, searchText: String): DebridStreamEncode { + val text = listOfNotNull(parsedCodec, searchText).joinToString(" ").lowercase() + return when { + text.hasToken("av1") -> DebridStreamEncode.AV1 + text.hasToken("hevc") || text.hasToken("h265") || text.hasToken("x265") -> DebridStreamEncode.HEVC + text.hasToken("avc") || text.hasToken("h264") || text.hasToken("x264") -> DebridStreamEncode.AVC + text.hasToken("xvid") -> DebridStreamEncode.XVID + text.hasToken("divx") -> DebridStreamEncode.DIVX + else -> DebridStreamEncode.UNKNOWN + } + } + + private fun languageFor(value: String): DebridStreamLanguage? { + val normalized = value.lowercase() + return DebridStreamLanguage.entries.firstOrNull { + normalized == it.code || normalized == it.label.lowercase() + } + } + + private fun releaseGroupFromText(text: String): String = + Regex("-([a-z0-9][a-z0-9._]{1,24})($|\\.)", RegexOption.IGNORE_CASE) + .find(text) + ?.groupValues + ?.getOrNull(1) + .orEmpty() + + private fun rank(value: T, preferred: List): Int { + val index = preferred.indexOf(value) + return if (index >= 0) index else Int.MAX_VALUE + } + + private fun rankAny(values: List, preferred: List): Int = + values.minOfOrNull { rank(it, preferred) } ?: Int.MAX_VALUE + + private fun String.hasResolutionToken(vararg tokens: String): Boolean = + Regex("(^|[^a-z0-9])(${tokens.joinToString("|")})([^a-z0-9]|\$)").containsMatchIn(this) + + private fun String.hasToken(token: String): Boolean = + Regex("(^|[^a-z0-9])${Regex.escape(token.lowercase())}([^a-z0-9]|\$)").containsMatchIn(lowercase()) + + private fun String.isDolbyVisionToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9]"), "") + return normalized == "dv" || normalized == "dovi" || normalized == "dolbyvision" + } + + private fun String.isHdrToken(): Boolean { + val normalized = lowercase().replace(Regex("[^a-z0-9+]"), "") + return normalized == "hdr" || + normalized == "hdr10" || + normalized == "hdr10+" || + normalized == "hdr10plus" || + normalized == "hlg" + } + + private fun streamSize(stream: StreamItem): Long? = + stream.clientResolve?.stream?.raw?.size ?: stream.behaviorHints.videoSize + + private fun streamSearchText(stream: StreamItem): String { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + return listOfNotNull( + stream.name, + stream.title, + stream.description, + resolve?.torrentName, + resolve?.filename, + raw?.torrentName, + raw?.filename, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.hdr?.joinToString(" "), + parsed?.audio?.joinToString(" "), + ).joinToString(" ").lowercase() + } + + private fun Int.gigabytes(): Long = this * 1_000_000_000L + + private data class StreamFacts( + val resolution: DebridStreamResolution, + val quality: DebridStreamQuality, + val visualTags: List, + val audioTags: List, + val audioChannels: List, + val encode: DebridStreamEncode, + val languages: List, + val releaseGroup: String, + val size: Long?, + val resolutionRank: Int, + val qualityRank: Int, + val visualRank: Int, + val audioRank: Int, + val channelRank: Int, + val encodeRank: Int, + val languageRank: Int, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt index 7cd0e03a..6cd5573d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamSource.kt @@ -4,9 +4,20 @@ 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, @@ -20,6 +31,10 @@ object DirectDebridStreamSource { private val log = Logger.withTag(DIRECT_DEBRID_TAG) private val encoder = DirectDebridConfigEncoder() private val formatter = DebridStreamFormatter() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val mutex = Mutex() + private val streamCache = mutableMapOf() + private val inFlightFetches = mutableMapOf>() fun configuredTargets(): List { DebridSettingsRepository.ensureLoaded() @@ -33,6 +48,12 @@ object DirectDebridStreamSource { } } + fun sourceNames(): List = + configuredTargets().map { it.addonName } + + fun isEnabled(): Boolean = + sourceNames().isNotEmpty() + fun placeholders(): List = configuredTargets().map { target -> AddonStreamGroup( @@ -43,6 +64,36 @@ object DirectDebridStreamSource { ) } + fun preloadStreams(type: String, videoId: String) { + if (type.isBlank() || videoId.isBlank()) return + configuredTargets().forEach { target -> + scope.launch { + runCatching { fetchProviderStreams(type, videoId, target) } + } + } + } + + suspend fun fetchStreams(type: String, videoId: String): DirectDebridStreamFetchResult { + val targets = configuredTargets() + if (targets.isEmpty()) return DirectDebridStreamFetchResult.Disabled + + val results = mutableListOf() + val errors = mutableListOf() + targets.forEach { target -> + val group = fetchProviderStreams(type, videoId, target) + when { + group.streams.isNotEmpty() -> results += group + !group.error.isNullOrBlank() -> errors += group.error + } + } + + return when { + results.isNotEmpty() -> DirectDebridStreamFetchResult.Success(results) + errors.isNotEmpty() -> DirectDebridStreamFetchResult.Error(errors.first()) + else -> DirectDebridStreamFetchResult.Empty + } + } + suspend fun fetchProviderStreams( type: String, videoId: String, @@ -54,6 +105,89 @@ object DirectDebridStreamSource { 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 { @@ -63,7 +197,7 @@ object DirectDebridStreamSource { addonName = DirectDebridStreamFilter.FALLBACK_SOURCE_NAME, addonId = target.addonId, ) - .let(DirectDebridStreamFilter::filterInstant) + .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) } @@ -76,13 +210,7 @@ object DirectDebridStreamSource { } catch (error: Exception) { if (error is CancellationException) throw error log.w(error) { "Direct debrid ${target.provider.id} stream fetch failed" } - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = false, - error = error.message, - ) + target.errorGroup(error.message) } } @@ -93,4 +221,33 @@ object DirectDebridStreamSource { streams = emptyList(), isLoading = false, ) + + private fun DirectDebridStreamTarget.errorGroup(message: String?): AddonStreamGroup = + AddonStreamGroup( + addonName = addonName, + addonId = addonId, + streams = emptyList(), + isLoading = false, + error = message, + ) +} + +private data class DirectDebridStreamCacheKey( + val providerId: String, + val type: String, + val videoId: String, + val baseUrl: String, + val settingsFingerprint: String, +) + +private data class CachedDirectDebridStreams( + val group: AddonStreamGroup, + val createdAtMs: Long, +) + +sealed class DirectDebridStreamFetchResult { + data object Disabled : DirectDebridStreamFetchResult() + data object Empty : DirectDebridStreamFetchResult() + data class Success(val streams: List) : DirectDebridStreamFetchResult() + data class Error(val message: String) : DirectDebridStreamFetchResult() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index a618b8ed..9c2fdfda 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -4,16 +4,23 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -25,6 +32,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -37,26 +45,40 @@ import com.nuvio.app.features.debrid.DebridCredentialValidator import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridStreamAudioChannel +import com.nuvio.app.features.debrid.DebridStreamAudioTag +import com.nuvio.app.features.debrid.DebridStreamEncode +import com.nuvio.app.features.debrid.DebridStreamLanguage +import com.nuvio.app.features.debrid.DebridStreamPreferences +import com.nuvio.app.features.debrid.DebridStreamQuality +import com.nuvio.app.features.debrid.DebridStreamResolution +import com.nuvio.app.features.debrid.DebridStreamSortCriterion +import com.nuvio.app.features.debrid.DebridStreamSortDirection +import com.nuvio.app.features.debrid.DebridStreamSortKey +import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.action_clear import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.action_save -import nuvio.composeapp.generated.resources.action_validate +import nuvio.composeapp.generated.resources.action_saving import nuvio.composeapp.generated.resources.settings_debrid_add_key_first -import nuvio.composeapp.generated.resources.settings_debrid_description_template -import nuvio.composeapp.generated.resources.settings_debrid_description_template_description +import nuvio.composeapp.generated.resources.settings_debrid_dialog_placeholder +import nuvio.composeapp.generated.resources.settings_debrid_dialog_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_dialog_title import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_subtitle +import nuvio.composeapp.generated.resources.settings_debrid_formatter_reset_title import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback_description import nuvio.composeapp.generated.resources.settings_debrid_prepare_stream_count -import nuvio.composeapp.generated.resources.settings_debrid_key_valid import nuvio.composeapp.generated.resources.settings_debrid_key_invalid -import nuvio.composeapp.generated.resources.settings_debrid_name_template -import nuvio.composeapp.generated.resources.settings_debrid_name_template_description +import nuvio.composeapp.generated.resources.settings_debrid_not_set import nuvio.composeapp.generated.resources.settings_debrid_provider_torbox_description import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback import nuvio.composeapp.generated.resources.settings_debrid_section_formatting @@ -82,7 +104,7 @@ internal fun LazyListScope.debridSettingsContent( SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), - checked = settings.enabled, + checked = settings.enabled && settings.hasAnyApiKey, enabled = settings.hasAnyApiKey, isTablet = isTablet, onCheckedChange = DebridSettingsRepository::setEnabled, @@ -99,21 +121,35 @@ internal fun LazyListScope.debridSettingsContent( } item { + var showApiKeyDialog by rememberSaveable { mutableStateOf(false) } + SettingsSection( title = stringResource(Res.string.settings_debrid_section_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridApiKeyRow( + DebridPreferenceRow( isTablet = isTablet, - providerId = DebridProviders.TORBOX_ID, title = DebridProviders.Torbox.displayName, description = stringResource(Res.string.settings_debrid_provider_torbox_description), - value = settings.torboxApiKey, - onApiKeyCommitted = DebridSettingsRepository::setTorboxApiKey, + value = maskDebridApiKey(settings.torboxApiKey, stringResource(Res.string.settings_debrid_not_set)), + enabled = true, + onClick = { showApiKeyDialog = true }, ) } } + + if (showApiKeyDialog) { + DebridApiKeyDialog( + providerId = DebridProviders.TORBOX_ID, + title = stringResource(Res.string.settings_debrid_dialog_title), + subtitle = stringResource(Res.string.settings_debrid_dialog_subtitle), + placeholder = stringResource(Res.string.settings_debrid_dialog_placeholder), + currentValue = settings.torboxApiKey, + onSave = DebridSettingsRepository::setTorboxApiKey, + onDismiss = { showApiKeyDialog = false }, + ) + } } item { @@ -162,39 +198,98 @@ internal fun LazyListScope.debridSettingsContent( } } + item { + var activeStreamPicker by rememberSaveable { mutableStateOf(null) } + val preferences = settings.streamPreferences + val rows = debridRuleRows(preferences) + + SettingsSection( + title = "Filters & Sorting", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + DebridPreferenceRow( + isTablet = isTablet, + title = "Max results", + description = "Limit how many Direct Debrid sources appear.", + value = streamMaxResultsLabel(preferences.maxResults), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Sort streams", + description = "Choose how Direct Debrid sources are ordered.", + value = sortProfileLabel(preferences.sortCriteria), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per resolution limit", + description = "Cap repeated 2160p, 1080p, 720p results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerResolution), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Per quality limit", + description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.", + value = streamMaxResultsLabel(preferences.maxPerQuality), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY }, + ) + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Size range", + description = "Filter streams by file size.", + value = sizeRangeLabel(preferences), + enabled = settings.enabled, + onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE }, + ) + rows.forEach { row -> + SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = row.title, + description = row.description, + value = row.value, + enabled = settings.enabled, + onClick = { activeStreamPicker = row.picker }, + ) + } + } + } + + activeStreamPicker?.let { picker -> + DebridStreamPreferenceDialog( + picker = picker, + preferences = preferences, + onPreferencesChanged = DebridSettingsRepository::setStreamPreferences, + onDismiss = { activeStreamPicker = null }, + ) + } + } + item { SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - DebridTemplateRow( + DebridPreferenceRow( isTablet = isTablet, - title = stringResource(Res.string.settings_debrid_name_template), - description = stringResource(Res.string.settings_debrid_name_template_description), - value = settings.streamNameTemplate, - singleLine = true, - onTemplateCommitted = DebridSettingsRepository::setStreamNameTemplate, + title = stringResource(Res.string.settings_debrid_formatter_reset_title), + description = stringResource(Res.string.settings_debrid_formatter_reset_subtitle), + value = stringResource(Res.string.action_reset), + enabled = settings.enabled, + onClick = DebridSettingsRepository::resetStreamTemplates, ) - SettingsGroupDivider(isTablet = isTablet) - DebridTemplateRow( - isTablet = isTablet, - title = stringResource(Res.string.settings_debrid_description_template), - description = stringResource(Res.string.settings_debrid_description_template_description), - value = settings.streamDescriptionTemplate, - singleLine = false, - onTemplateCommitted = DebridSettingsRepository::setStreamDescriptionTemplate, - ) - SettingsGroupDivider(isTablet = isTablet) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 12.dp), - ) { - TextButton(onClick = DebridSettingsRepository::resetStreamTemplates) { - Text(stringResource(Res.string.action_reset)) - } - } } } } @@ -286,31 +381,28 @@ private fun DebridPrepareCountDialog( } @Composable -private fun DebridApiKeyRow( +private fun DebridPreferenceRow( isTablet: Boolean, - providerId: String, title: String, description: String, value: String, - onApiKeyCommitted: (String) -> Unit, + enabled: Boolean, + onClick: () -> Unit, ) { val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 16.dp else 14.dp - val scope = rememberCoroutineScope() - var draft by rememberSaveable(value) { mutableStateOf(value) } - var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } - var validationMessage by rememberSaveable(providerId, value) { mutableStateOf(null) } - val normalizedDraft = draft.trim() - val validMessage = stringResource(Res.string.settings_debrid_key_valid) - val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) - - Column( + Row( modifier = Modifier .fillMaxWidth() + .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = title, style = MaterialTheme.typography.bodyLarge, @@ -323,116 +415,732 @@ private fun DebridApiKeyRow( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - - SettingsSecretTextField( - value = draft, - onValueChange = { - draft = it - validationMessage = null - }, - modifier = Modifier.fillMaxWidth(), - label = "$title API key", + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Medium, ) + } +} - validationMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } +@Composable +private fun DebridStreamPreferenceDialog( + picker: DebridStreamPicker, + preferences: DebridStreamPreferences, + onPreferencesChanged: (DebridStreamPreferences) -> Unit, + onDismiss: () -> Unit, +) { + when (picker) { + DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxResults, + options = listOf(0, 5, 10, 20, 50), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxResults = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerResolution, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerResolution = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog( + title = "Max results", + selectedValue = preferences.maxPerQuality, + options = listOf(0, 1, 2, 3, 5), + label = { streamMaxResultsLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(maxPerQuality = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog( + title = "Sort streams", + selectedValue = sortProfileFor(preferences.sortCriteria), + options = listOf( + DebridSortProfile.DEFAULT, + DebridSortProfile.LARGEST, + DebridSortProfile.SMALLEST, + DebridSortProfile.AUDIO, + DebridSortProfile.LANGUAGE, + ), + label = { sortProfileLabel(it) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sortCriteria = sortCriteriaForProfile(value))) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog( + title = "Size range", + selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb, + options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100), + label = { sizeRangeLabel(it.first, it.second) }, + onSelected = { value -> onPreferencesChanged(preferences.copy(sizeMinGb = value.first, sizeMaxGb = value.second)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Preferred resolutions", + selectedValues = preferences.preferredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredResolutions = value.ifEmpty { DebridStreamResolution.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Required resolutions", + selectedValues = preferences.requiredResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog( + title = "Excluded resolutions", + selectedValues = preferences.excludedResolutions, + values = DebridStreamResolution.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedResolutions = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Preferred qualities", + selectedValues = preferences.preferredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredQualities = value.ifEmpty { DebridStreamQuality.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog( + title = "Required qualities", + selectedValues = preferences.requiredQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog( + title = "Excluded qualities", + selectedValues = preferences.excludedQualities, + values = DebridStreamQuality.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedQualities = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Preferred visual tags", + selectedValues = preferences.preferredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredVisualTags = value.ifEmpty { DebridStreamVisualTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Required visual tags", + selectedValues = preferences.requiredVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog( + title = "Excluded visual tags", + selectedValues = preferences.excludedVisualTags, + values = DebridStreamVisualTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedVisualTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Preferred audio tags", + selectedValues = preferences.preferredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioTags = value.ifEmpty { DebridStreamAudioTag.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Required audio tags", + selectedValues = preferences.requiredAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog( + title = "Excluded audio tags", + selectedValues = preferences.excludedAudioTags, + values = DebridStreamAudioTag.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioTags = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Preferred channels", + selectedValues = preferences.preferredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredAudioChannels = value.ifEmpty { DebridStreamAudioChannel.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Required channels", + selectedValues = preferences.requiredAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog( + title = "Excluded channels", + selectedValues = preferences.excludedAudioChannels, + values = DebridStreamAudioChannel.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedAudioChannels = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog( + title = "Preferred encodes", + selectedValues = preferences.preferredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredEncodes = value.ifEmpty { DebridStreamEncode.defaultOrder })) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog( + title = "Required encodes", + selectedValues = preferences.requiredEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog( + title = "Excluded encodes", + selectedValues = preferences.excludedEncodes, + values = DebridStreamEncode.defaultOrder, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedEncodes = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Preferred languages", + selectedValues = preferences.preferredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(preferredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Required languages", + selectedValues = preferences.requiredLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog( + title = "Excluded languages", + selectedValues = preferences.excludedLanguages, + values = DebridStreamLanguage.entries, + label = { it.label }, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedLanguages = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Required release groups", + selectedValues = preferences.requiredReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog( + title = "Excluded release groups", + selectedValues = preferences.excludedReleaseGroups, + onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) }, + onDismiss = onDismiss, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - draft = normalizedDraft - onApiKeyCommitted(normalizedDraft) - }, - enabled = normalizedDraft != value && !isValidating, +@Composable +private fun DebridIntChoiceDialog( + title: String, + selectedValue: Int, + options: List, + label: @Composable (Int) -> String, + onSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + DebridSingleChoiceDialog( + title = title, + selectedValue = selectedValue, + options = options, + label = label, + onSelected = onSelected, + onDismiss = onDismiss, + ) +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridSingleChoiceDialog( + title: String, + selectedValue: T, + options: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(Res.string.action_save)) - } - TextButton( - onClick = { - scope.launch { - isValidating = true - val valid = runCatching { - DebridCredentialValidator.validateProvider(providerId, normalizedDraft) - }.getOrDefault(false) - validationMessage = if (valid) validMessage else invalidMessage - isValidating = false - } - }, - enabled = normalizedDraft.isNotBlank() && !isValidating, - ) { - Text(stringResource(Res.string.action_validate)) + items(options) { option -> + DebridDialogOptionRow( + text = label(option), + selected = option == selectedValue, + onClick = { + onSelected(option) + onDismiss() + }, + ) + } } } } } @Composable -private fun DebridTemplateRow( - isTablet: Boolean, +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridMultiChoiceDialog( title: String, - description: String, - value: String, - singleLine: Boolean, - onTemplateCommitted: (String) -> Unit, + selectedValues: List, + values: List, + label: @Composable (T) -> String, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, ) { - val horizontalPadding = if (isTablet) 20.dp else 16.dp - val verticalPadding = if (isTablet) 16.dp else 14.dp - var draft by rememberSaveable(value) { mutableStateOf(value) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - OutlinedTextField( - value = draft, - onValueChange = { draft = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = singleLine, - minLines = if (singleLine) 1 else 4, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), - ) - - Row(modifier = Modifier.fillMaxWidth()) { - Button( - onClick = { onTemplateCommitted(draft) }, - enabled = draft != value, + var draft by remember(selectedValues) { mutableStateOf(selectedValues) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(Res.string.action_save)) + items(values) { option -> + val selected = option in draft + DebridDialogOptionRow( + text = label(option), + selected = selected, + showCheckbox = true, + onClick = { + draft = if (selected) { + draft - option + } else { + draft + option + } + }, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { draft = emptyList() }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(draft) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } } } } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridTextListDialog( + title: String, + selectedValues: List, + onSelected: (List) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember(selectedValues) { mutableStateOf(selectedValues.joinToString("\n")) } + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = "Enter one group per line.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + minLines = 4, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = { value = "" }) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = { + onSelected(value.split('\n', ',').map { it.trim() }.filter { it.isNotBlank() }.distinct()) + onDismiss() + }, + ) { + Text(stringResource(Res.string.action_save)) + } + } + } + } +} + +@Composable +private fun DebridDialogSurface( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + content() + Spacer(modifier = Modifier.height(2.dp)) + } + } +} + +@Composable +private fun DebridDialogOptionRow( + text: String, + selected: Boolean, + showCheckbox: Boolean = false, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (showCheckbox) { + Checkbox( + checked = selected, + onCheckedChange = { onClick() }, + ) + } else { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } +} + +@Composable +private fun streamMaxResultsLabel(value: Int): String = + if (value <= 0) "All streams" else "$value streams" + +private fun sortProfileLabel(value: DebridSortProfile): String = + when (value) { + DebridSortProfile.DEFAULT -> "Default" + DebridSortProfile.LARGEST -> "Largest first" + DebridSortProfile.SMALLEST -> "Smallest first" + DebridSortProfile.AUDIO -> "Best audio first" + DebridSortProfile.LANGUAGE -> "Language first" + } + +private fun debridRuleRows(preferences: DebridStreamPreferences): List = + listOf( + DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)), + DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)), + DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected source qualities.", selectionCountLabel(preferences.requiredQualities)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected source qualities.", selectionCountLabel(preferences.excludedQualities)), + DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)), + DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)), + DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)), + DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)), + DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)), + DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show streams with selected languages.", selectionCountLabel(preferences.requiredLanguages)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide streams where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)), + DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)), + DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)), + ) + +private fun selectionCountLabel(values: List<*>): String = + if (values.isEmpty()) "Any" else "${values.size} selected" + +private fun sizeRangeLabel(preferences: DebridStreamPreferences): String = + sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb) + +private fun sizeRangeLabel(minGb: Int, maxGb: Int): String = + when { + minGb <= 0 && maxGb <= 0 -> "Any" + minGb <= 0 -> "Up to ${maxGb}GB" + maxGb <= 0 -> "${minGb}GB+" + else -> "${minGb}-${maxGb}GB" + } + +private fun sortProfileFor(criteria: List): DebridSortProfile { + val normalized = criteria.map { it.key to it.direction } + return when { + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.DESC) -> DebridSortProfile.LARGEST + normalized == listOf(DebridStreamSortKey.SIZE to DebridStreamSortDirection.ASC) -> DebridSortProfile.SMALLEST + normalized.take(2) == listOf( + DebridStreamSortKey.AUDIO_TAG to DebridStreamSortDirection.DESC, + DebridStreamSortKey.AUDIO_CHANNEL to DebridStreamSortDirection.DESC, + ) -> DebridSortProfile.AUDIO + normalized.firstOrNull() == DebridStreamSortKey.LANGUAGE to DebridStreamSortDirection.DESC -> DebridSortProfile.LANGUAGE + else -> DebridSortProfile.DEFAULT + } +} + +private fun sortProfileLabel(criteria: List): String = + sortProfileLabel(sortProfileFor(criteria)) + +private fun sortCriteriaForProfile(profile: DebridSortProfile): List = + when (profile) { + DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder + DebridSortProfile.LARGEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC)) + DebridSortProfile.SMALLEST -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC)) + DebridSortProfile.AUDIO -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_TAG, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.AUDIO_CHANNEL, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + DebridSortProfile.LANGUAGE -> listOf( + DebridStreamSortCriterion(DebridStreamSortKey.LANGUAGE, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC), + DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.DESC), + ) + } + +private data class DebridRuleRow( + val picker: DebridStreamPicker, + val title: String, + val description: String, + val value: String, +) + +private enum class DebridSortProfile { + DEFAULT, + LARGEST, + SMALLEST, + AUDIO, + LANGUAGE, +} + +private enum class DebridStreamPicker { + MAX_RESULTS, + MAX_PER_RESOLUTION, + MAX_PER_QUALITY, + SORT_MODE, + SIZE_RANGE, + PREFERRED_RESOLUTIONS, + REQUIRED_RESOLUTIONS, + EXCLUDED_RESOLUTIONS, + PREFERRED_QUALITIES, + REQUIRED_QUALITIES, + EXCLUDED_QUALITIES, + PREFERRED_VISUAL_TAGS, + REQUIRED_VISUAL_TAGS, + EXCLUDED_VISUAL_TAGS, + PREFERRED_AUDIO_TAGS, + REQUIRED_AUDIO_TAGS, + EXCLUDED_AUDIO_TAGS, + PREFERRED_AUDIO_CHANNELS, + REQUIRED_AUDIO_CHANNELS, + EXCLUDED_AUDIO_CHANNELS, + PREFERRED_ENCODES, + REQUIRED_ENCODES, + EXCLUDED_ENCODES, + PREFERRED_LANGUAGES, + REQUIRED_LANGUAGES, + EXCLUDED_LANGUAGES, + REQUIRED_RELEASE_GROUPS, + EXCLUDED_RELEASE_GROUPS, +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridApiKeyDialog( + providerId: String, + title: String, + subtitle: String, + placeholder: String, + currentValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(currentValue) { mutableStateOf(currentValue) } + var isValidating by rememberSaveable(providerId) { mutableStateOf(false) } + var validationMessage by rememberSaveable(providerId, currentValue) { mutableStateOf(null) } + val normalizedDraft = draft.trim() + val invalidMessage = stringResource(Res.string.settings_debrid_key_invalid) + val saveAndDismiss: () -> Unit = { + scope.launch { + isValidating = true + validationMessage = null + val valid = normalizedDraft.isNotBlank() && runCatching { + DebridCredentialValidator.validateProvider(providerId, normalizedDraft) + }.getOrDefault(false) + if (valid) { + onSave(normalizedDraft) + isValidating = false + onDismiss() + } else { + validationMessage = invalidMessage + isValidating = false + } + } + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = title) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draft, + onValueChange = { + draft = it + validationMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(placeholder) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + validationMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + onSave("") + onDismiss() + }, + enabled = !isValidating, + ) { + Text(stringResource(Res.string.action_clear)) + } + Button( + onClick = saveAndDismiss, + enabled = normalizedDraft.isNotBlank() && !isValidating, + ) { + Text( + if (isValidating) { + stringResource(Res.string.action_saving) + } else { + stringResource(Res.string.action_save) + }, + ) + } + } + } + } +} + +private fun maskDebridApiKey(key: String, notSetLabel: String): String { + val trimmed = key.trim() + if (trimmed.isBlank()) return notSetLabel + return if (trimmed.length <= 4) "****" else "******${trimmed.takeLast(4)}" +} + @Composable private fun DebridInfoRow( isTablet: Boolean, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt index 271f6f42..593fa6af 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DirectDebridStreamFilterTest.kt @@ -1,6 +1,9 @@ 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 @@ -48,25 +51,160 @@ class DirectDebridStreamFilterTest { 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?, - cached: Boolean?, + service: String? = DebridProviders.TORBOX_ID, + cached: Boolean? = true, type: String = "debrid", infoHash: String = "hash", fileIdx: Int = 1, + resolution: String? = null, + quality: String? = null, + hdr: List = emptyList(), + codec: String? = null, + audio: List = emptyList(), + channels: List = emptyList(), + languages: List = emptyList(), + group: String? = null, + size: Long? = null, ): StreamItem = StreamItem( - name = "Stream", + 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, + infoHash = infoHash + size.orEmptyHashPart() + resolution.orEmpty() + quality.orEmpty() + codec.orEmpty(), fileIdx = fileIdx, - filename = "video.mkv", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + stream = StreamClientResolveStream( + raw = StreamClientResolveRaw( + torrentName = "Torrent ${resolution.orEmpty()} ${quality.orEmpty()}", + filename = "video ${resolution.orEmpty()} ${quality.orEmpty()} ${codec.orEmpty()}.mkv", + size = size, + folderSize = size, + parsed = StreamClientResolveParsed( + resolution = resolution, + quality = quality, + hdr = hdr, + codec = codec, + audio = audio, + channels = channels, + languages = languages, + group = group, + ), + ), + ), ), ) } +private fun Long?.orEmptyHashPart(): String = + this?.toString().orEmpty() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index 0ac46039..dc85c449 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -17,6 +17,13 @@ actual object DebridSettingsStorage { 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( @@ -24,6 +31,13 @@ actual object DebridSettingsStorage { torboxApiKeyKey, realDebridApiKeyKey, instantPlaybackPreparationLimitKey, + streamMaxResultsKey, + streamSortModeKey, + streamMinimumQualityKey, + streamDolbyVisionFilterKey, + streamHdrFilterKey, + streamCodecFilterKey, + streamPreferencesKey, streamNameTemplateKey, streamDescriptionTemplateKey, ) @@ -52,6 +66,48 @@ actual object DebridSettingsStorage { 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) { @@ -104,6 +160,13 @@ actual object DebridSettingsStorage { 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)) } } @@ -117,6 +180,13 @@ actual object DebridSettingsStorage { 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) }