feat: support for fusion filter tags for cloud streams

This commit is contained in:
tapframe 2026-05-29 01:27:43 +05:30
parent f568a15e89
commit e36baa60ff
12 changed files with 646 additions and 13 deletions

View file

@ -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<String> =
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)

View file

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

View file

@ -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 <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
runCatching { enumValueOf<T>(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<DebridStreamBadgeRules>(value)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
}
private enum class DebridTemplateKind {
NAME,
DESCRIPTION,

View file

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

View file

@ -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<DebridStreamBadgeFilter> = emptyList(),
val groups: List<DebridStreamBadgeGroup> = 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<DebridStreamBadgePayload>(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<DebridCompiledStreamBadgeFilter> {
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<String> =
matchedNames(stream, compile(rules))
fun matchedNames(stream: StreamItem, filters: List<DebridCompiledStreamBadgeFilter>): List<String> {
return matchedBadges(stream, filters).map { it.name }
}
fun matchedBadges(stream: StreamItem, filters: List<DebridCompiledStreamBadgeFilter>): List<StreamBadge> {
if (filters.isEmpty()) return emptyList()
val candidates = badgeMatchCandidates(stream)
if (candidates.isEmpty()) return emptyList()
val matched = linkedMapOf<String, StreamBadge>()
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<String> {
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<DebridStreamBadgeFilterPayload> = emptyList(),
val groups: List<DebridStreamBadgeGroupPayload> = 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,
)

View file

@ -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<DebridCompiledStreamBadgeFilter> =
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<String, Any?> {
private fun buildValues(
stream: StreamItem,
settings: DebridSettings,
matchedBadges: List<StreamBadge>,
): Map<String, Any?> {
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)),

View file

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

View file

@ -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<DebridTemplateField?>(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<String?>(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,

View file

@ -19,6 +19,7 @@ data class StreamItem(
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
val debridCacheStatus: StreamDebridCacheStatus? = null,
val badges: List<StreamBadge> = 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

View file

@ -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

View file

@ -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(

View file

@ -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<String> =
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)