feat: collection preserving to prevent overwriting unsupported fields in blobs

This commit is contained in:
tapframe 2026-04-25 08:01:31 +05:30
parent 23080c4344
commit 84a4771f67
4 changed files with 215 additions and 41 deletions

View file

@ -0,0 +1,114 @@
package com.nuvio.app.features.collection
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal object CollectionJsonPreserver {
fun merge(
json: Json,
rawCollectionsJson: JsonElement,
collections: List<Collection>,
): JsonArray {
val rawById = rawCollectionsJson.asObjectArrayById()
return buildJsonArray {
collections.forEach { collection ->
add(
mergeCollection(
json = json,
raw = rawById[collection.id],
collection = collection,
),
)
}
}
}
private fun mergeCollection(
json: Json,
raw: JsonObject?,
collection: Collection,
): JsonObject {
val encoded = json.encodeToJsonElement(Collection.serializer(), collection).jsonObject
val rawFoldersById = raw?.get("folders").asObjectArrayById()
val mergedFolders = buildJsonArray {
collection.folders.forEach { folder ->
add(
mergeFolder(
json = json,
raw = rawFoldersById[folder.id],
folder = folder,
),
)
}
}
return mergeObjects(raw, encoded, mapOf("folders" to mergedFolders))
}
private fun mergeFolder(
json: Json,
raw: JsonObject?,
folder: CollectionFolder,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionFolder.serializer(), folder).jsonObject
val rawSourcesByKey = raw?.get("catalogSources").asObjectArrayByKey(::sourceKey)
val mergedSources = buildJsonArray {
folder.catalogSources.forEach { source ->
val sourceElement =
json.encodeToJsonElement(CollectionCatalogSource.serializer(), source)
add(
mergeSource(
json = json,
raw = rawSourcesByKey[sourceKey(sourceElement)],
source = source,
),
)
}
}
return mergeObjects(raw, encoded, mapOf("catalogSources" to mergedSources))
}
private fun mergeSource(
json: Json,
raw: JsonObject?,
source: CollectionCatalogSource,
): JsonObject {
val encoded = json.encodeToJsonElement(CollectionCatalogSource.serializer(), source).jsonObject
return mergeObjects(raw, encoded)
}
private fun mergeObjects(
raw: JsonObject?,
encoded: JsonObject,
overrides: Map<String, JsonElement> = emptyMap(),
): JsonObject = buildJsonObject {
raw?.forEach { (key, value) -> put(key, value) }
encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) }
}
private fun JsonElement?.asObjectArrayById(): Map<String, JsonObject> =
asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull }
private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map<String, JsonObject> =
(this as? JsonArray)
?.mapNotNull { element ->
val obj = element as? JsonObject ?: return@mapNotNull null
keySelector(obj)?.let { key -> key to obj }
}
?.toMap()
.orEmpty()
private fun sourceKey(element: JsonElement): String? {
val obj = element as? JsonObject ?: return null
val addonId = obj["addonId"]?.jsonPrimitive?.contentOrNull ?: return null
val type = obj["type"]?.jsonPrimitive?.contentOrNull ?: return null
val catalogId = obj["catalogId"]?.jsonPrimitive?.contentOrNull ?: return null
return "$addonId|$type|$catalogId"
}
}

View file

@ -10,6 +10,8 @@ import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
@ -31,6 +33,7 @@ object CollectionRepository {
private val _collections = MutableStateFlow<List<Collection>>(emptyList()) private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow() val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
private var hasLoaded = false private var hasLoaded = false
@ -41,6 +44,8 @@ object CollectionRepository {
if (payload.isNullOrBlank()) return if (payload.isNullOrBlank()) return
runCatching { runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload) _collections.value = json.decodeFromString<List<Collection>>(payload)
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to load collections from storage" } log.e(e) { "Failed to load collections from storage" }
@ -50,11 +55,13 @@ object CollectionRepository {
fun onProfileChanged() { fun onProfileChanged() {
hasLoaded = false hasLoaded = false
_collections.value = emptyList() _collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
} }
fun clearLocalState() { fun clearLocalState() {
hasLoaded = false hasLoaded = false
_collections.value = emptyList() _collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
} }
fun getCollection(id: String): Collection? = fun getCollection(id: String): Collection? =
@ -81,6 +88,7 @@ object CollectionRepository {
} }
fun setCollections(collections: List<Collection>) { fun setCollections(collections: List<Collection>) {
ensureLoaded()
_collections.value = collections _collections.value = collections
persist() persist()
} }
@ -106,11 +114,12 @@ object CollectionRepository {
fun exportToJson(): String { fun exportToJson(): String {
ensureLoaded() ensureLoaded()
return json.encodeToString(_collections.value) return mergedCollectionsJson().toString()
} }
fun importFromJson(jsonString: String): Result<List<Collection>> { fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching { return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString) val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported _collections.value = imported
persist() persist()
@ -228,7 +237,8 @@ object CollectionRepository {
} }
} }
internal fun applyFromRemote(collections: List<Collection>) { internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
rawCollectionsJson = rawJson
_collections.value = collections _collections.value = collections
persist() persist()
} }
@ -239,9 +249,14 @@ object CollectionRepository {
private fun persist() { private fun persist() {
runCatching { runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value)) CollectionStorage.savePayload(mergedCollectionsJson().toString())
}.onFailure { e -> }.onFailure { e ->
log.e(e) { "Failed to persist collections" } log.e(e) { "Failed to persist collections" }
} }
} }
private fun mergedCollectionsJson(): JsonArray =
CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also {
rawCollectionsJson = it
}
} }

View file

@ -80,7 +80,7 @@ object CollectionSyncService {
if (remoteCollections != null) { if (remoteCollections != null) {
isSyncingFromRemote = true isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections) CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
isSyncingFromRemote = false isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" } log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else { } else {

View file

@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -40,14 +40,17 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.nuvio.app.core.i18n.localizedByteUnit
import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.StreamsUiState
import kotlin.math.round
import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -230,24 +233,32 @@ private fun SourceStreamRow(
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val cardShape = RoundedCornerShape(12.dp)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .heightIn(min = 68.dp)
.shadow(
elevation = 2.dp,
shape = cardShape,
ambientColor = Color.Black.copy(alpha = 0.04f),
spotColor = Color.Black.copy(alpha = 0.04f),
)
.clip(cardShape)
.background( .background(
if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent, if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f),
) )
.then( .then(
if (isCurrent) { if (isCurrent) {
Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape)
} else { } else {
Modifier Modifier
}, },
) )
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@ -258,11 +269,13 @@ private fun SourceStreamRow(
Text( Text(
text = stream.streamLabel, text = stream.streamLabel,
color = colorScheme.onSurface, color = colorScheme.onSurface,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
maxLines = 1, lineHeight = 20.sp,
overflow = TextOverflow.Ellipsis, letterSpacing = 0.1.sp,
modifier = Modifier.weight(1f, fill = false), ),
modifier = Modifier.weight(1f),
) )
if (isCurrent) { if (isCurrent) {
Box( Box(
@ -280,17 +293,26 @@ private fun SourceStreamRow(
} }
} }
} }
stream.streamSubtitle?.let { subtitle ->
if (subtitle != stream.streamLabel) { val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = subtitle, text = subtitle,
color = colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp, fontSize = 12.sp,
maxLines = 2, lineHeight = 18.sp,
overflow = TextOverflow.Ellipsis, ),
color = colorScheme.onSurfaceVariant,
) )
} }
}
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlayerStreamFileSizeBadge(stream = stream)
Text( Text(
text = stream.addonName, text = stream.addonName,
color = colorScheme.onSurfaceVariant, color = colorScheme.onSurfaceVariant,
@ -300,17 +322,40 @@ private fun SourceStreamRow(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
} }
if (isCurrent) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = stringResource(Res.string.compose_player_currently_playing),
tint = colorScheme.primary,
modifier = Modifier.size(20.dp),
)
} }
} }
} }
@Composable
private fun PlayerStreamFileSizeBadge(stream: StreamItem) {
val bytes = stream.behaviorHints.videoSize ?: return
val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0)
val sizeLabel = if (gib >= 1.0) {
val roundedGiB = round(gib * 10.0) / 10.0
"$roundedGiB ${localizedByteUnit("GB")}"
} else {
val mib = bytes.toDouble() / (1024.0 * 1024.0)
"${round(mib).toInt()} ${localizedByteUnit("MB")}"
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(Color(0xFF0A0C0C))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(
text = stringResource(Res.string.streams_size, sizeLabel),
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = Color.White,
)
}
}
@Composable @Composable
internal fun AddonFilterChip( internal fun AddonFilterChip(
label: String, label: String,