mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-03 18:16:15 +00:00
feat: support for fusion filter tags for cloud streams
This commit is contained in:
parent
f568a15e89
commit
e36baa60ff
12 changed files with 646 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue