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