feat: add upto 3 badges

This commit is contained in:
tapframe 2026-05-29 01:51:12 +05:30
parent 226e8370f1
commit 1d68504a05
5 changed files with 569 additions and 110 deletions

View file

@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@ -242,27 +243,46 @@ object DebridSettingsRepository {
}
return try {
val currentRules = streamBadgeRules.normalized()
val isExistingImport = currentRules.imports.any { import ->
import.sourceUrl.equals(normalizedUrl, ignoreCase = true)
}
if (!isExistingImport && currentRules.imports.size >= DEBRID_STREAM_BADGE_IMPORT_LIMIT) {
return DebridStreamBadgeImportResult.Error("You can import up to $DEBRID_STREAM_BADGE_IMPORT_LIMIT badge URLs.")
}
val payload = httpGetText(normalizedUrl)
val parsed = DebridStreamBadgeRulesParser.parse(
val parsedImport = DebridStreamBadgeRulesParser.parse(
sourceUrl = normalizedUrl,
payload = payload,
)
streamBadgeRules = parsed
streamBadgeRules = currentRules.upsert(parsedImport, activate = true)
publish()
saveStreamBadgeRules()
DebridStreamBadgeImportResult.Success(parsed)
DebridStreamBadgeImportResult.Success(streamBadgeRules)
} catch (error: Exception) {
if (error is CancellationException) throw error
DebridStreamBadgeImportResult.Error(error.message ?: "Badge import failed.")
}
}
fun clearStreamBadgeRules() {
fun setActiveStreamBadgeRulesSource(sourceUrl: String) {
ensureLoaded()
if (streamBadgeRules == DebridStreamBadgeRules()) return
streamBadgeRules = DebridStreamBadgeRules()
val currentRules = streamBadgeRules.normalized()
val nextRules = currentRules.setActiveSource(sourceUrl)
if (nextRules == currentRules) return
streamBadgeRules = nextRules
publish()
DebridSettingsStorage.saveStreamBadgeRules("")
saveStreamBadgeRules()
}
fun deleteStreamBadgeRulesSource(sourceUrl: String) {
ensureLoaded()
val currentRules = streamBadgeRules.normalized()
val nextRules = currentRules.removeSource(sourceUrl)
if (nextRules == currentRules) return
streamBadgeRules = nextRules
publish()
saveStreamBadgeRules()
}
private fun disableIfNoResolver() {
@ -392,7 +412,13 @@ object DebridSettingsRepository {
}
private fun saveStreamBadgeRules() {
DebridSettingsStorage.saveStreamBadgeRules(json.encodeToString(streamBadgeRules))
val normalizedRules = streamBadgeRules.normalized()
val payload = if (normalizedRules.hasImport) {
json.encodeToString(normalizedRules)
} else {
""
}
DebridSettingsStorage.saveStreamBadgeRules(payload)
}
private inline fun <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
@ -411,13 +437,25 @@ object DebridSettingsRepository {
private fun parseStreamBadgeRules(value: String?): DebridStreamBadgeRules? {
if (value.isNullOrBlank()) return null
return try {
json.decodeFromString<DebridStreamBadgeRules>(value)
val decodedRules = try {
json.decodeFromString<DebridStreamBadgeRules>(value).normalized()
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
if (decodedRules?.hasImport == true) return decodedRules
val legacyRules = try {
json.decodeFromString<LegacyDebridStreamBadgeRules>(value)
.toBadgeRules()
.normalized()
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
return legacyRules?.takeIf { it.hasImport } ?: decodedRules
}
private enum class DebridTemplateKind {
@ -436,6 +474,24 @@ object DebridSettingsRepository {
}
}
@Serializable
private data class LegacyDebridStreamBadgeRules(
val sourceUrl: String = "",
val filters: List<DebridStreamBadgeFilter> = emptyList(),
val groups: List<DebridStreamBadgeGroup> = emptyList(),
) {
fun toBadgeRules(): DebridStreamBadgeRules =
DebridStreamBadgeRules(
imports = listOf(
DebridStreamBadgeImport(
sourceUrl = sourceUrl,
filters = filters,
groups = groups,
),
),
)
}
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
copy(
maxResults = normalizeDebridStreamMaxResults(maxResults),

View file

@ -8,17 +8,96 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
const val DEBRID_STREAM_BADGE_IMPORT_LIMIT = 3
@Serializable
data class DebridStreamBadgeRules(
val imports: List<DebridStreamBadgeImport> = emptyList(),
) {
val hasImport: Boolean
get() = imports.isNotEmpty()
val activeImport: DebridStreamBadgeImport?
get() = imports.firstOrNull { it.isActive } ?: imports.firstOrNull()
val enabledFilterCount: Int
get() = activeImport?.enabledFilterCount ?: 0
fun normalized(): DebridStreamBadgeRules {
val normalizedImports = mutableListOf<DebridStreamBadgeImport>()
imports.forEach { import ->
val normalizedUrl = import.sourceUrl.trim()
if (normalizedUrl.isBlank() || import.filters.isEmpty()) return@forEach
val normalizedImport = import.copy(sourceUrl = normalizedUrl)
val existingIndex = normalizedImports.indexOfFirst { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }
if (existingIndex >= 0) {
normalizedImports[existingIndex] = normalizedImport
} else if (normalizedImports.size < DEBRID_STREAM_BADGE_IMPORT_LIMIT) {
normalizedImports += normalizedImport
}
}
if (normalizedImports.isEmpty()) return copy(imports = emptyList())
val activeIndex = normalizedImports.indexOfFirst { it.isActive }.takeIf { it >= 0 } ?: 0
return copy(
imports = normalizedImports.mapIndexed { index, import ->
import.copy(isActive = index == activeIndex)
},
)
}
fun upsert(import: DebridStreamBadgeImport, activate: Boolean = true): DebridStreamBadgeRules {
val normalizedUrl = import.sourceUrl.trim()
if (normalizedUrl.isBlank()) return normalized()
val normalizedImport = import.copy(sourceUrl = normalizedUrl, isActive = activate)
val replaced = imports.map { existing ->
if (existing.sourceUrl.equals(normalizedUrl, ignoreCase = true)) normalizedImport else existing
}
val nextImports = if (imports.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
replaced
} else {
imports + normalizedImport
}
val activeImports = if (activate) {
nextImports.map { existing ->
existing.copy(isActive = existing.sourceUrl.equals(normalizedUrl, ignoreCase = true))
}
} else {
nextImports
}
return copy(imports = activeImports).normalized()
}
fun setActiveSource(sourceUrl: String): DebridStreamBadgeRules {
val normalizedUrl = sourceUrl.trim()
if (normalizedUrl.isBlank() || imports.none { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
return normalized()
}
return copy(
imports = imports.map { import ->
import.copy(isActive = import.sourceUrl.equals(normalizedUrl, ignoreCase = true))
},
).normalized()
}
fun removeSource(sourceUrl: String): DebridStreamBadgeRules =
copy(imports = imports.filterNot { it.sourceUrl.equals(sourceUrl.trim(), ignoreCase = true) }).normalized()
override fun toString(): String =
"DebridStreamBadgeRules(imports=${imports.size}, activeFilters=$enabledFilterCount, groups=${imports.sumOf { it.groups.size }})"
}
@Serializable
data class DebridStreamBadgeImport(
val sourceUrl: String = "",
val filters: List<DebridStreamBadgeFilter> = emptyList(),
val groups: List<DebridStreamBadgeGroup> = emptyList(),
val isActive: Boolean = true,
) {
val hasImport: Boolean
get() = filters.isNotEmpty()
val enabledFilterCount: Int
get() = filters.count { it.isEnabled }
override fun toString(): String =
"DebridStreamBadgeRules(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size})"
"DebridStreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)"
}
@Serializable
@ -55,7 +134,7 @@ internal object DebridStreamBadgeRulesParser {
explicitNulls = false
}
fun parse(sourceUrl: String, payload: String): DebridStreamBadgeRules {
fun parse(sourceUrl: String, payload: String): DebridStreamBadgeImport {
val decoded = try {
json.decodeFromString<DebridStreamBadgePayload>(payload)
} catch (error: SerializationException) {
@ -98,7 +177,7 @@ internal object DebridStreamBadgeRulesParser {
)
}
return DebridStreamBadgeRules(
return DebridStreamBadgeImport(
sourceUrl = sourceUrl.trim(),
filters = filters,
groups = groups,
@ -109,23 +188,25 @@ internal object DebridStreamBadgeRulesParser {
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(
return rules.normalized().imports.filter { it.isActive }.flatMap { import ->
import.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,
imageURL = filter.imageURL,
tagColor = filter.tagColor,
tagStyle = filter.tagStyle,
textColor = filter.textColor,
borderColor = filter.borderColor,
),
regex = regex,
)
badge = StreamBadge(
name = filter.name,
imageURL = filter.imageURL,
tagColor = filter.tagColor,
tagStyle = filter.tagStyle,
textColor = filter.textColor,
borderColor = filter.borderColor,
),
regex = regex,
)
}
}
}

View file

@ -1,10 +1,14 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -12,21 +16,27 @@ 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.layout.width
import androidx.compose.foundation.layout.widthIn
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.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -40,12 +50,18 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.features.debrid.DEBRID_PREPARE_INSTANT_PLAYBACK_DEFAULT_LIMIT
import com.nuvio.app.features.debrid.DEBRID_STREAM_BADGE_IMPORT_LIMIT
import com.nuvio.app.features.debrid.DebridCredentialValidator
import com.nuvio.app.features.debrid.DebridDeviceAuthorization
import com.nuvio.app.features.debrid.DebridDeviceAuthorizationTokenResult
@ -56,6 +72,8 @@ 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.DebridStreamBadgeFilter
import com.nuvio.app.features.debrid.DebridStreamBadgeImport
import com.nuvio.app.features.debrid.DebridStreamBadgeRules
import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults
import com.nuvio.app.features.debrid.DebridStreamAudioChannel
@ -75,6 +93,7 @@ import kotlinx.coroutines.delay
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_delete
import nuvio.composeapp.generated.resources.action_retry
import nuvio.composeapp.generated.resources.action_reset
import nuvio.composeapp.generated.resources.action_save
@ -428,8 +447,8 @@ internal fun LazyListScope.debridSettingsContent(
SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Badge URL",
description = "Import Fusion badge filters from a JSON URL.",
title = "Badge URLs",
description = "Manage imported label badge JSON URLs.",
value = badgeRulesPreview(settings.streamBadgeRules),
enabled = settings.canResolvePlayableLinks,
onClick = { showBadgeImportDialog = true },
@ -467,7 +486,7 @@ internal fun LazyListScope.debridSettingsContent(
}
if (showBadgeImportDialog) {
DebridBadgeImportDialog(
DebridBadgeUrlManagerDialog(
currentRules = settings.streamBadgeRules,
onDismiss = { showBadgeImportDialog = false },
)
@ -519,12 +538,14 @@ 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"
private fun badgeRulesPreview(rules: DebridStreamBadgeRules): String {
val normalizedRules = rules.normalized()
return if (normalizedRules.hasImport) {
"${normalizedRules.imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs, ${normalizedRules.enabledFilterCount} active badges"
} else {
"Not imported"
}
}
@Composable
private fun prepareCountLabel(limit: Int): String =
@ -691,19 +712,21 @@ private fun DebridTemplateDialog(
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun DebridBadgeImportDialog(
private fun DebridBadgeUrlManagerDialog(
currentRules: DebridStreamBadgeRules,
onDismiss: () -> Unit,
) {
val scope = rememberCoroutineScope()
var draftUrl by rememberSaveable(currentRules.sourceUrl) { mutableStateOf(currentRules.sourceUrl) }
val imports = currentRules.normalized().imports
var draftUrl by rememberSaveable { mutableStateOf("") }
var errorMessage by rememberSaveable { mutableStateOf<String?>(null) }
var isImporting by rememberSaveable { mutableStateOf(false) }
var previewImport by remember { mutableStateOf<DebridStreamBadgeImport?>(null) }
BasicAlertDialog(onDismissRequest = onDismiss) {
DebridDialogSurface(title = "Import badge URL") {
DebridDialogSurface(title = "Badge URLs") {
Text(
text = "Paste a Fusion badge filter JSON URL. Nuvio stores the imported rules and only uses user-imported badges.",
text = "Import up to $DEBRID_STREAM_BADGE_IMPORT_LIMIT label badge JSON URLs. Each URL can be updated or deleted separately.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -714,6 +737,7 @@ private fun DebridBadgeImportDialog(
errorMessage = null
},
modifier = Modifier.fillMaxWidth(),
label = { Text("Badge JSON URL") },
singleLine = false,
minLines = 2,
maxLines = 4,
@ -726,50 +750,28 @@ private fun DebridBadgeImportDialog(
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)
}
Text(
text = "${imports.size}/$DEBRID_STREAM_BADGE_IMPORT_LIMIT URLs imported",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Button(
enabled = !isImporting,
enabled = !isImporting && draftUrl.isNotBlank(),
onClick = {
scope.launch {
isImporting = true
errorMessage = null
when (val result = DebridSettingsRepository.importStreamBadgeRulesFromUrl(draftUrl)) {
is DebridStreamBadgeImportResult.Success -> onDismiss()
is DebridStreamBadgeImportResult.Success -> {
draftUrl = ""
isImporting = false
}
is DebridStreamBadgeImportResult.Error -> {
errorMessage = result.message
isImporting = false
@ -789,8 +791,305 @@ private fun DebridBadgeImportDialog(
}
}
}
errorMessage?.let { message ->
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
if (imports.isEmpty()) {
Text(
text = "No badge URLs imported.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 300.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(
items = imports,
key = { import -> import.sourceUrl },
) { import ->
DebridBadgeUrlRow(
import = import,
showActiveChoice = imports.size > 1,
enabled = !isImporting,
onActivate = {
DebridSettingsRepository.setActiveStreamBadgeRulesSource(import.sourceUrl)
},
onPreview = { previewImport = import },
onDelete = {
DebridSettingsRepository.deleteStreamBadgeRulesSource(import.sourceUrl)
if (previewImport?.sourceUrl.equals(import.sourceUrl, ignoreCase = true)) {
previewImport = null
}
},
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
enabled = !isImporting,
onClick = onDismiss,
) {
Text(text = stringResource(Res.string.action_cancel), maxLines = 1)
}
}
}
}
previewImport?.let { import ->
DebridBadgePreviewDialog(
import = import,
onDismiss = { previewImport = null },
)
}
}
@Composable
private fun DebridBadgeUrlRow(
import: DebridStreamBadgeImport,
showActiveChoice: Boolean,
enabled: Boolean,
onActivate: () -> Unit,
onPreview: () -> Unit,
onDelete: () -> Unit,
) {
val containerColor = if (import.isActive) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (showActiveChoice) {
RadioButton(
selected = import.isActive,
onClick = onActivate,
enabled = enabled,
)
}
Text(
text = import.sourceUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
val status = if (import.isActive) "Active" else "Inactive"
Text(
text = "$status, ${import.enabledFilterCount} enabled badges, ${import.groups.size} groups",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(
enabled = enabled,
onClick = onPreview,
) {
Icon(
imageVector = Icons.Rounded.Visibility,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "Preview", maxLines = 1)
}
IconButton(
enabled = enabled,
onClick = onDelete,
) {
Icon(
imageVector = Icons.Rounded.Delete,
contentDescription = stringResource(Res.string.action_delete),
)
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
private fun DebridBadgePreviewDialog(
import: DebridStreamBadgeImport,
onDismiss: () -> Unit,
) {
val sections = remember(import) { badgePreviewSections(import) }
val badgeCount = sections.sumOf { it.filters.size }
BasicAlertDialog(onDismissRequest = onDismiss) {
DebridDialogSurface(title = "Badge preview") {
Text(
text = import.sourceUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "$badgeCount badges from this URL",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (sections.isEmpty()) {
Text(
text = "No badge images in this URL.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 460.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
items(
items = sections,
key = { section -> section.id },
) { section ->
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = section.title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
) {
section.filters.forEach { filter ->
DebridBadgePreviewChip(filter)
}
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onDismiss) {
Text(text = "Close", maxLines = 1)
}
}
}
}
}
@Composable
private fun DebridBadgePreviewChip(filter: DebridStreamBadgeFilter) {
val shape = RoundedCornerShape(6.dp)
val backgroundColor = if (filter.tagStyle.equals("filled", ignoreCase = true)) {
filter.tagColor.toBadgeColorOrNull()
} else {
null
}
val borderColor = filter.borderColor.toBadgeColorOrNull()
Box(
modifier = Modifier
.height(24.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 = 4.dp, vertical = 3.dp),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = filter.imageURL,
contentDescription = filter.name,
modifier = Modifier
.height(18.dp)
.widthIn(min = 38.dp, max = 112.dp)
.clip(shape),
contentScale = ContentScale.Fit,
)
}
}
private data class DebridBadgePreviewSection(
val id: String,
val title: String,
val filters: List<DebridStreamBadgeFilter>,
)
private fun badgePreviewSections(import: DebridStreamBadgeImport): List<DebridBadgePreviewSection> {
val filters = import.filters.filter { it.imageURL.isNotBlank() }
if (filters.isEmpty()) return emptyList()
val filtersByGroupId = filters.groupBy { it.groupId }
val usedGroupIds = mutableSetOf<String>()
val sections = mutableListOf<DebridBadgePreviewSection>()
import.groups.forEachIndexed { index, group ->
val groupFilters = filtersByGroupId[group.id].orEmpty()
if (groupFilters.isNotEmpty()) {
usedGroupIds += group.id
sections += DebridBadgePreviewSection(
id = group.id.ifBlank { "group-$index" },
title = group.name.ifBlank { "Group ${index + 1}" },
filters = groupFilters,
)
}
}
val ungroupedFilters = filters.filter { it.groupId !in usedGroupIds }
if (ungroupedFilters.isNotEmpty()) {
sections += DebridBadgePreviewSection(
id = "other",
title = "Other badges",
filters = ungroupedFilters,
)
}
return sections
}
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) }
}
@Composable

View file

@ -1214,7 +1214,7 @@ private fun StreamItem.instantServiceLabel(): String? {
@Composable
private fun StreamImportedBadge(badge: StreamBadge) {
val shape = RoundedCornerShape(4.dp)
val shape = RoundedCornerShape(6.dp)
val backgroundColor = if (badge.tagStyle.equals("filled", ignoreCase = true)) {
badge.tagColor.toBadgeColorOrNull()
} else {
@ -1257,9 +1257,9 @@ private fun StreamFileSizeBadge(stream: StreamItem) {
Box(
modifier = Modifier
.height(20.dp)
.clip(RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(6.dp))
.background(Color(0xFF0A0C0C))
.border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(4.dp))
.border(1.dp, Color(0xFF0A0C0C), RoundedCornerShape(6.dp))
.padding(horizontal = 6.dp),
contentAlignment = Alignment.Center,
) {

View file

@ -52,39 +52,58 @@ class DebridStreamPresentationTest {
enabled = true,
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
streamNameTemplate = "{stream.rseMatched::join(' | ')}",
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing\"]}",
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}",
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",
imports = listOf(
DebridStreamBadgeImport(
sourceUrl = "https://example.test/media-badges.json",
isActive = false,
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,
),
),
),
DebridStreamBadgeFilter(
name = "Disabled",
pattern = "(?i)\\bbluray\\b",
isEnabled = false,
DebridStreamBadgeImport(
sourceUrl = "https://example.test/audio-badges.json",
isActive = true,
filters = listOf(
DebridStreamBadgeFilter(
name = "TRUEHD",
pattern = "(?i)\\btruehd\\b",
imageURL = "https://example.test/truehd.png",
tagColor = "#B968FF",
tagStyle = "filled",
textColor = "#FFFFFF",
borderColor = "#B968FF",
),
),
),
),
),
),
)
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)
assertEquals("TRUEHD", formatted.name)
assertEquals("missing-remux", formatted.description)
assertEquals(listOf("TRUEHD"), formatted.badges.map { it.name })
assertEquals(listOf("https://example.test/truehd.png"), formatted.badges.map { it.imageURL })
}
@Test
fun `parses fusion badge url payload shape`() {
val rules = DebridStreamBadgeRulesParser.parse(
val importedRules = DebridStreamBadgeRulesParser.parse(
sourceUrl = "https://example.test/fusion-tags-ume.json",
payload = """
{
@ -115,12 +134,12 @@ class DebridStreamPresentationTest {
""".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)
assertEquals("https://example.test/fusion-tags-ume.json", importedRules.sourceUrl)
assertEquals(1, importedRules.filters.size)
assertEquals("REMUX", importedRules.filters.single().name)
assertEquals("(?i)\\bremux\\b", importedRules.filters.single().pattern)
assertEquals("https://example.test/remux.png", importedRules.filters.single().imageURL)
assertEquals("Media Source", importedRules.groups.single().name)
}
@Test
@ -142,12 +161,16 @@ class DebridStreamPresentationTest {
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",
imports = listOf(
DebridStreamBadgeImport(
sourceUrl = "https://example.test/badges.json",
filters = listOf(
DebridStreamBadgeFilter(
name = "REMUX 1",
pattern = "(?i)\\bremux\\b",
imageURL = "https://example.test/remux-t1.png",
),
),
),
),
),