From e36baa60ffb6175b2fdaea0f9a480915b45f1f67 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 29 May 2026 01:27:43 +0530 Subject: [PATCH] feat: support for fusion filter tags for cloud streams --- .../debrid/DebridSettingsStorage.android.kt | 7 + .../app/features/debrid/DebridSettings.kt | 1 + .../debrid/DebridSettingsRepository.kt | 56 +++++ .../features/debrid/DebridSettingsStorage.kt | 2 + .../features/debrid/DebridStreamBadgeRules.kt | 229 ++++++++++++++++++ .../features/debrid/DebridStreamFormatter.kt | 22 +- .../debrid/DebridStreamPresentation.kt | 6 +- .../features/settings/DebridSettingsPage.kt | 130 ++++++++++ .../app/features/streams/StreamModels.kt | 10 + .../app/features/streams/StreamsScreen.kt | 70 +++++- .../debrid/DebridStreamPresentationTest.kt | 119 +++++++++ .../debrid/DebridSettingsStorage.ios.kt | 7 + 12 files changed, 646 insertions(+), 13 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt 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 de8e76b1e..969bc8eb5 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 @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,12 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) 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 8774316f8..f352058ee 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 @@ -17,6 +17,7 @@ data class DebridSettings( val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(), val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE, val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE, + val streamBadgeRules: DebridStreamBadgeRules = DebridStreamBadgeRules(), ) { val torboxApiKey: String get() = apiKeyFor(DebridProviders.TORBOX_ID) 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 a23c52f64..15c4b49c6 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 @@ -1,5 +1,7 @@ package com.nuvio.app.features.debrid +import com.nuvio.app.features.addons.httpGetText +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -34,6 +36,7 @@ object DebridSettingsRepository { private var streamPreferences = DebridStreamPreferences() private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE + private var streamBadgeRules = DebridStreamBadgeRules() fun ensureLoaded() { if (hasLoaded) return @@ -226,6 +229,42 @@ object DebridSettingsRepository { ) } + suspend fun importStreamBadgeRulesFromUrl(url: String): DebridStreamBadgeImportResult { + ensureLoaded() + val normalizedUrl = url.trim() + if (normalizedUrl.isBlank()) { + return DebridStreamBadgeImportResult.Error("Enter a badge JSON URL.") + } + if (!normalizedUrl.startsWith("https://", ignoreCase = true) && + !normalizedUrl.startsWith("http://", ignoreCase = true) + ) { + return DebridStreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") + } + + return try { + val payload = httpGetText(normalizedUrl) + val parsed = DebridStreamBadgeRulesParser.parse( + sourceUrl = normalizedUrl, + payload = payload, + ) + streamBadgeRules = parsed + publish() + saveStreamBadgeRules() + DebridStreamBadgeImportResult.Success(parsed) + } catch (error: Exception) { + if (error is CancellationException) throw error + DebridStreamBadgeImportResult.Error(error.message ?: "Badge import failed.") + } + } + + fun clearStreamBadgeRules() { + ensureLoaded() + if (streamBadgeRules == DebridStreamBadgeRules()) return + streamBadgeRules = DebridStreamBadgeRules() + publish() + DebridSettingsStorage.saveStreamBadgeRules("") + } + private fun disableIfNoResolver() { if (!hasResolverProvider()) { enabled = false @@ -324,6 +363,7 @@ object DebridSettingsRepository { DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(), DebridTemplateKind.DESCRIPTION, ) + streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: DebridStreamBadgeRules() publish() } @@ -343,6 +383,7 @@ object DebridSettingsRepository { streamPreferences = streamPreferences, streamNameTemplate = streamNameTemplate, streamDescriptionTemplate = streamDescriptionTemplate, + streamBadgeRules = streamBadgeRules, ) } @@ -350,6 +391,10 @@ object DebridSettingsRepository { DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized())) } + private fun saveStreamBadgeRules() { + DebridSettingsStorage.saveStreamBadgeRules(json.encodeToString(streamBadgeRules)) + } + private inline fun > enumValueOrDefault(value: String?, default: T): T = runCatching { enumValueOf(value.orEmpty()) }.getOrDefault(default) @@ -364,6 +409,17 @@ object DebridSettingsRepository { } } + private fun parseStreamBadgeRules(value: String?): DebridStreamBadgeRules? { + if (value.isNullOrBlank()) return null + return try { + json.decodeFromString(value) + } catch (_: SerializationException) { + null + } catch (_: IllegalArgumentException) { + null + } + } + private enum class DebridTemplateKind { NAME, DESCRIPTION, 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 7d68db489..a64cca0f5 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 @@ -35,6 +35,8 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadStreamBadgeRules(): String? + fun saveStreamBadgeRules(rules: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt new file mode 100644 index 000000000..f243126fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamBadgeRules.kt @@ -0,0 +1,229 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamBadge +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +@Serializable +data class DebridStreamBadgeRules( + val sourceUrl: String = "", + val filters: List = emptyList(), + val groups: List = emptyList(), +) { + val hasImport: Boolean + get() = filters.isNotEmpty() + + override fun toString(): String = + "DebridStreamBadgeRules(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size})" +} + +@Serializable +data class DebridStreamBadgeFilter( + val id: String = "", + val groupId: String = "", + val name: String = "", + val pattern: String = "", + val imageURL: String = "", + val isEnabled: Boolean = true, + val tagColor: String = "", + val tagStyle: String = "", + val textColor: String = "", + val borderColor: String = "", +) + +@Serializable +data class DebridStreamBadgeGroup( + val id: String = "", + val name: String = "", + val color: String = "", + val isExpanded: Boolean = true, +) + +sealed interface DebridStreamBadgeImportResult { + data class Success(val rules: DebridStreamBadgeRules) : DebridStreamBadgeImportResult + data class Error(val message: String) : DebridStreamBadgeImportResult +} + +internal object DebridStreamBadgeRulesParser { + @OptIn(ExperimentalSerializationApi::class) + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun parse(sourceUrl: String, payload: String): DebridStreamBadgeRules { + val decoded = try { + json.decodeFromString(payload) + } catch (error: SerializationException) { + throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}") + } catch (error: IllegalArgumentException) { + throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}") + } + + val filters = decoded.filters.mapNotNull { filter -> + val name = filter.name.orEmpty().trim() + val pattern = filter.pattern.orEmpty().trim() + if (name.isBlank() || pattern.isBlank()) { + return@mapNotNull null + } + + DebridStreamBadgeFilter( + id = filter.id.orEmpty(), + groupId = filter.groupId.orEmpty(), + name = name, + pattern = pattern, + imageURL = filter.imageURL.orEmpty(), + isEnabled = filter.isEnabled ?: true, + tagColor = filter.tagColor.orEmpty(), + tagStyle = filter.tagStyle.orEmpty(), + textColor = filter.textColor.orEmpty(), + borderColor = filter.borderColor.orEmpty(), + ) + } + + if (filters.isEmpty()) { + throw IllegalArgumentException("Badge import did not contain any usable filters.") + } + + val groups = decoded.groups.map { group -> + DebridStreamBadgeGroup( + id = group.id.orEmpty(), + name = group.name.orEmpty(), + color = group.color.orEmpty(), + isExpanded = group.isExpanded ?: true, + ) + } + + return DebridStreamBadgeRules( + sourceUrl = sourceUrl.trim(), + filters = filters, + groups = groups, + ) + } +} + +internal object DebridStreamBadgeMatcher { + fun compile(rules: DebridStreamBadgeRules): List { + if (!rules.hasImport) return emptyList() + return rules.filters.mapNotNull { filter -> + if (!filter.isEnabled || filter.name.isBlank() || filter.pattern.isBlank()) { + return@mapNotNull null + } + val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null + DebridCompiledStreamBadgeFilter( + name = filter.name, + badge = StreamBadge( + name = filter.name, + imageURL = filter.imageURL, + tagColor = filter.tagColor, + tagStyle = filter.tagStyle, + textColor = filter.textColor, + borderColor = filter.borderColor, + ), + regex = regex, + ) + } + } + + fun matchedNames(stream: StreamItem, rules: DebridStreamBadgeRules): List = + matchedNames(stream, compile(rules)) + + fun matchedNames(stream: StreamItem, filters: List): List { + return matchedBadges(stream, filters).map { it.name } + } + + fun matchedBadges(stream: StreamItem, filters: List): List { + if (filters.isEmpty()) return emptyList() + val candidates = badgeMatchCandidates(stream) + if (candidates.isEmpty()) return emptyList() + + val matched = linkedMapOf() + filters.forEach { filter -> + if (candidates.any { candidate -> filter.regex.containsMatchIn(candidate) }) { + val key = filter.badge.dedupeKey() + if (key !in matched) matched[key] = filter.badge + } + } + return matched.values.toList() + } + + private fun badgeMatchCandidates(stream: StreamItem): List { + val resolve = stream.clientResolve + val raw = resolve?.stream?.raw + val parsed = raw?.parsed + val candidates = listOfNotNull( + raw?.filename, + resolve?.filename, + stream.behaviorHints.filename, + stream.debridCacheStatus?.cachedName, + raw?.torrentName, + resolve?.torrentName, + stream.name, + stream.title, + stream.description, + parsed?.rawTitle, + parsed?.parsedTitle, + parsed?.resolution, + parsed?.quality, + parsed?.codec, + parsed?.edition, + parsed?.audio?.joinToString(" "), + parsed?.channels?.joinToString(" "), + parsed?.hdr?.joinToString(" "), + parsed?.group, + stream.sourceName, + stream.addonName, + ) + .map { it.trim() } + .filter { it.isNotBlank() } + .distinct() + + return if (candidates.size <= 1) { + candidates + } else { + candidates + candidates.joinToString(" ") + } + } +} + +data class DebridCompiledStreamBadgeFilter( + val name: String, + val badge: StreamBadge, + val regex: Regex, +) + +private fun StreamBadge.dedupeKey(): String = + imageURL.takeIf { it.isNotBlank() } ?: name + +@Serializable +private data class DebridStreamBadgePayload( + val filters: List = emptyList(), + val groups: List = emptyList(), +) + +@Serializable +private data class DebridStreamBadgeFilterPayload( + val id: String? = null, + val groupId: String? = null, + val name: String? = null, + val pattern: String? = null, + val imageURL: String? = null, + val isEnabled: Boolean? = null, + val tagColor: String? = null, + val tagStyle: String? = null, + val textColor: String? = null, + val borderColor: String? = null, + val type: String? = null, +) + +@Serializable +private data class DebridStreamBadgeGroupPayload( + val id: String? = null, + val name: String? = null, + val color: String? = null, + val isExpanded: Boolean? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt index a148c9de3..9b00dc33d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamFormatter.kt @@ -4,14 +4,21 @@ import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStr import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamClientResolveParsed import com.nuvio.app.features.streams.StreamDebridCacheState +import com.nuvio.app.features.streams.StreamBadge import com.nuvio.app.features.streams.StreamItem class DebridStreamFormatter( private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(), ) { - fun format(stream: StreamItem, settings: DebridSettings): StreamItem { + fun format( + stream: StreamItem, + settings: DebridSettings, + compiledBadgeFilters: List = + DebridStreamBadgeMatcher.compile(settings.streamBadgeRules), + ): StreamItem { if (!stream.isManagedDebridStream) return stream - val values = buildValues(stream, settings) + val matchedBadges = DebridStreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters) + val values = buildValues(stream, settings, matchedBadges) val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE } val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE } val formattedName = engine.render(nameTemplate, values) @@ -29,10 +36,15 @@ class DebridStreamFormatter( return stream.copy( name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) }, description = formattedDescription.ifBlank { stream.description ?: stream.title }, + badges = matchedBadges, ) } - private fun buildValues(stream: StreamItem, settings: DebridSettings): Map { + private fun buildValues( + stream: StreamItem, + settings: DebridSettings, + matchedBadges: List, + ): Map { val resolve = stream.clientResolve val raw = resolve?.stream?.raw val parsed = raw?.parsed @@ -48,6 +60,7 @@ class DebridStreamFormatter( val audioTags = facts.audioTags.mapNotUnknown { it.label } val audioChannels = facts.audioChannels.mapNotUnknown { it.label } val edition = parsed?.edition ?: buildEdition(parsed) + val matchedBadgeNames = matchedBadges.map { it.name } return linkedMapOf( "stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title), @@ -76,7 +89,8 @@ class DebridStreamFormatter( "stream.duration" to parsed?.duration, "stream.edition" to edition, "stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName), - "stream.regexMatched" to null, + "stream.regexMatched" to matchedBadgeNames, + "stream.rseMatched" to matchedBadgeNames, "stream.type" to streamType(stream, resolve), "service.cached" to serviceCached(stream, resolve), "service.shortName" to DebridProviders.shortName(serviceId(stream)), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt index f9eedc463..43deeb5d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentation.kt @@ -16,10 +16,12 @@ object DebridStreamPresentation { val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream } if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams) + val compiledBadgeFilters = DebridStreamBadgeMatcher.compile(settings.streamBadgeRules) + val shouldFormatStreams = settings.hasCustomStreamFormatting || compiledBadgeFilters.isNotEmpty() val presentedDebridStreams = applyPreferences(debridStreams, settings) .map { stream -> - if (settings.hasCustomStreamFormatting) { - formatter.format(stream, settings) + if (shouldFormatStreams) { + formatter.format(stream, settings, compiledBadgeFilters) } else { stream } 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 80eb22b15..549d967e9 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 @@ -55,6 +55,8 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod 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.DebridStreamBadgeImportResult +import com.nuvio.app.features.debrid.DebridStreamBadgeRules import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -393,6 +395,7 @@ internal fun LazyListScope.debridSettingsContent( item { var activeTemplateField by rememberSaveable { mutableStateOf(null) } + var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_debrid_section_formatting), @@ -423,6 +426,15 @@ internal fun LazyListScope.debridSettingsContent( onClick = { activeTemplateField = DebridTemplateField.DESCRIPTION }, ) SettingsGroupDivider(isTablet = isTablet) + DebridPreferenceRow( + isTablet = isTablet, + title = "Badge URL", + description = "Import Fusion badge filters from a JSON URL.", + value = badgeRulesPreview(settings.streamBadgeRules), + enabled = settings.canResolvePlayableLinks, + onClick = { showBadgeImportDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) DebridPreferenceRow( isTablet = isTablet, title = stringResource(Res.string.settings_debrid_formatter_reset_title), @@ -453,6 +465,13 @@ internal fun LazyListScope.debridSettingsContent( ) null -> Unit } + + if (showBadgeImportDialog) { + DebridBadgeImportDialog( + currentRules = settings.streamBadgeRules, + onDismiss = { showBadgeImportDialog = false }, + ) + } } debridLearnMoreFooterItem(isTablet) @@ -500,6 +519,13 @@ private fun templatePreview(value: String, defaultValue: String): String { return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..." } +private fun badgeRulesPreview(rules: DebridStreamBadgeRules): String = + if (rules.hasImport) { + "${rules.filters.count { it.isEnabled }} badges" + } else { + "Not imported" + } + @Composable private fun prepareCountLabel(limit: Int): String = if (limit == 1) { @@ -663,6 +689,110 @@ private fun DebridTemplateDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun DebridBadgeImportDialog( + currentRules: DebridStreamBadgeRules, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var draftUrl by rememberSaveable(currentRules.sourceUrl) { mutableStateOf(currentRules.sourceUrl) } + var errorMessage by rememberSaveable { mutableStateOf(null) } + var isImporting by rememberSaveable { mutableStateOf(false) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + DebridDialogSurface(title = "Import badge URL") { + Text( + text = "Paste a Fusion badge filter JSON URL. Nuvio stores the imported rules and only uses user-imported badges.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = draftUrl, + onValueChange = { + draftUrl = it + errorMessage = null + }, + modifier = Modifier.fillMaxWidth(), + singleLine = false, + minLines = 2, + maxLines = 4, + enabled = !isImporting, + 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, + ), + ) + errorMessage?.let { message -> + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + if (currentRules.hasImport) { + Text( + text = "${currentRules.filters.count { it.isEnabled }} enabled badges imported from ${currentRules.groups.size} groups.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentRules.hasImport) { + TextButton( + enabled = !isImporting, + onClick = { + DebridSettingsRepository.clearStreamBadgeRules() + onDismiss() + }, + ) { + Text(text = stringResource(Res.string.action_clear), maxLines = 1) + } + } + TextButton( + enabled = !isImporting, + onClick = onDismiss, + ) { + Text(text = stringResource(Res.string.action_cancel), maxLines = 1) + } + Button( + enabled = !isImporting, + onClick = { + scope.launch { + isImporting = true + errorMessage = null + when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) { + is DebridStreamBadgeImportResult.Success -> onDismiss() + is DebridStreamBadgeImportResult.Error -> { + errorMessage = result.message + isImporting = false + } + } + } + }, + ) { + if (isImporting) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text(text = "Import", maxLines = 1) + } + } + } + } + } +} + @Composable private fun DebridPreferenceRow( isTablet: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index b88c82518..fb5a098c5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -19,6 +19,7 @@ data class StreamItem( val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, + val badges: List = emptyList(), ) { val streamLabel: String get() = name ?: runBlocking { getString(Res.string.stream_default_name) } @@ -63,6 +64,15 @@ data class StreamItem( get() = url != null || infoHash != null || externalUrl != null || clientResolve != null } +data class StreamBadge( + val name: String, + val imageURL: String = "", + val tagColor: String = "", + val tagStyle: String = "", + val textColor: String = "", + val borderColor: String = "", +) + private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index b7440d06b..9004d9525 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -1030,9 +1032,19 @@ private fun StreamCard( ) } - Spacer(modifier = Modifier.height(6.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - StreamFileSizeBadge(stream = stream) + val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } + if (badgeImages.isNotEmpty() || stream.behaviorHints.videoSize != null) { + Spacer(modifier = Modifier.height(5.dp)) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + badgeImages.forEach { badge -> + StreamImportedBadge(badge = badge) + } + StreamFileSizeBadge(stream = stream) + } } } } @@ -1200,6 +1212,36 @@ private fun StreamItem.instantServiceLabel(): String? { return "- $providerLabel Instant" } +@Composable +private fun StreamImportedBadge(badge: StreamBadge) { + val shape = RoundedCornerShape(4.dp) + val backgroundColor = if (badge.tagStyle.equals("filled", ignoreCase = true)) { + badge.tagColor.toBadgeColorOrNull() + } else { + null + } + val borderColor = badge.borderColor.toBadgeColorOrNull() + + Box( + modifier = Modifier + .height(20.dp) + .then(if (backgroundColor != null) Modifier.background(backgroundColor, shape) else Modifier) + .then(if (borderColor != null) Modifier.border(1.dp, borderColor, shape) else Modifier) + .padding(horizontal = 3.dp, vertical = 2.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = badge.imageURL, + contentDescription = badge.name, + modifier = Modifier + .height(16.dp) + .widthIn(min = 34.dp, max = 92.dp) + .clip(shape), + contentScale = ContentScale.Fit, + ) + } +} + @Composable private fun StreamFileSizeBadge(stream: StreamItem) { val bytes = stream.behaviorHints.videoSize ?: return @@ -1214,22 +1256,36 @@ private fun StreamFileSizeBadge(stream: StreamItem) { Box( modifier = Modifier - .clip(RoundedCornerShape(12.dp)) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) .background(Color(0xFF0A0C0C)) - .padding(horizontal = 8.dp, vertical = 3.dp), + .border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.streams_size, sizeLabel), style = MaterialTheme.typography.labelSmall.copy( - fontSize = 11.sp, + fontSize = 10.sp, + lineHeight = 12.sp, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.2.sp, + letterSpacing = 0.sp, ), color = Color.White, ) } } +private fun String.toBadgeColorOrNull(): Color? { + val hex = trim().removePrefix("#") + val argb = when (hex.length) { + 6 -> "FF$hex" + 8 -> hex + else -> return null + } + return argb.toLongOrNull(16)?.let { Color(it) } +} + private fun Long.toPlaybackClock(): String { val totalSeconds = (this / 1000L).coerceAtLeast(0L) val hours = totalSeconds / 3600L diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt index d97f29c5c..c5d17bf19 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridStreamPresentationTest.kt @@ -39,6 +39,125 @@ class DebridStreamPresentationTest { assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv") } + @Test + fun `formats imported badge matches from fusion badge rules`() { + val stream = localTorboxStream( + filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", + size = 40_000_000_000, + ) + + val formatted = DebridStreamFormatter().format( + stream = stream, + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamNameTemplate = "{stream.rseMatched::join(' | ')}", + streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing\"]}", + streamBadgeRules = DebridStreamBadgeRules( + sourceUrl = "https://example.test/badges.json", + filters = listOf( + DebridStreamBadgeFilter( + name = "REMUX", + pattern = "(?i)\\bremux\\b", + imageURL = "https://example.test/remux.png", + tagColor = "#27C04F", + tagStyle = "filled", + textColor = "#FFFFFF", + borderColor = "#27C04F", + ), + DebridStreamBadgeFilter( + name = "Disabled", + pattern = "(?i)\\bbluray\\b", + isEnabled = false, + ), + ), + ), + ), + ) + + assertEquals("REMUX", formatted.name) + assertEquals("has-remux", formatted.description) + assertEquals(1, formatted.badges.size) + assertEquals("REMUX", formatted.badges.single().name) + assertEquals("https://example.test/remux.png", formatted.badges.single().imageURL) + } + + @Test + fun `parses fusion badge url payload shape`() { + val rules = DebridStreamBadgeRulesParser.parse( + sourceUrl = "https://example.test/fusion-tags-ume.json", + payload = """ + { + "filters": [ + { + "borderColor": "#27C04F", + "groupId": "media", + "id": "remux", + "imageURL": "https://example.test/remux.png", + "isEnabled": true, + "name": "REMUX", + "pattern": "(?i)\\bremux\\b", + "tagColor": "#27C04F", + "tagStyle": "filled", + "textColor": "#FFFFFF", + "type": "filter" + } + ], + "groups": [ + { + "color": "#96CEB4", + "id": "media", + "isExpanded": true, + "name": "Media Source" + } + ] + } + """.trimIndent(), + ) + + assertEquals("https://example.test/fusion-tags-ume.json", rules.sourceUrl) + assertEquals(1, rules.filters.size) + assertEquals("REMUX", rules.filters.single().name) + assertEquals("(?i)\\bremux\\b", rules.filters.single().pattern) + assertEquals("https://example.test/remux.png", rules.filters.single().imageURL) + assertEquals("Media Source", rules.groups.single().name) + } + + @Test + fun `attaches imported badge urls to presented debrid streams`() { + val stream = localTorboxStream( + filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv", + size = 40_000_000_000, + ) + + val presented = DebridStreamPresentation.apply( + groups = listOf( + AddonStreamGroup( + addonName = "Addon", + addonId = "addon:test", + streams = listOf(stream), + ), + ), + settings = DebridSettings( + enabled = true, + providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"), + streamBadgeRules = DebridStreamBadgeRules( + sourceUrl = "https://example.test/badges.json", + filters = listOf( + DebridStreamBadgeFilter( + name = "REMUX 1", + pattern = "(?i)\\bremux\\b", + imageURL = "https://example.test/remux-t1.png", + ), + ), + ), + ), + ).single().streams.single() + + assertEquals(listOf("REMUX 1"), presented.badges.map { it.name }) + assertEquals("https://example.test/remux-t1.png", presented.badges.single().imageURL) + } + @Test fun `default formatter replaces addon source labels for managed streams`() { val stream = premiumizeDirectStream( 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 d11d9c644..b53748f14 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 @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val streamBadgeRulesKey = "debrid_stream_badge_rules" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,12 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) + + actual fun saveStreamBadgeRules(rules: String) { + saveString(streamBadgeRulesKey, rules) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key)