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.encodeToString
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.collections_import_error_collection_blank_id
import nuvio.composeapp.generated.resources.collections_import_error_collection_blank_title
@ -31,6 +33,7 @@ object CollectionRepository {
private val _collections = MutableStateFlow<List<Collection>>(emptyList())
val collections: StateFlow<List<Collection>> = _collections.asStateFlow()
private var rawCollectionsJson: JsonElement = JsonArray(emptyList())
private var hasLoaded = false
@ -41,6 +44,8 @@ object CollectionRepository {
if (payload.isNullOrBlank()) return
runCatching {
val parsed = json.parseToJsonElement(payload)
rawCollectionsJson = parsed
_collections.value = json.decodeFromString<List<Collection>>(payload)
}.onFailure { e ->
log.e(e) { "Failed to load collections from storage" }
@ -50,11 +55,13 @@ object CollectionRepository {
fun onProfileChanged() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun clearLocalState() {
hasLoaded = false
_collections.value = emptyList()
rawCollectionsJson = JsonArray(emptyList())
}
fun getCollection(id: String): Collection? =
@ -81,6 +88,7 @@ object CollectionRepository {
}
fun setCollections(collections: List<Collection>) {
ensureLoaded()
_collections.value = collections
persist()
}
@ -106,11 +114,12 @@ object CollectionRepository {
fun exportToJson(): String {
ensureLoaded()
return json.encodeToString(_collections.value)
return mergedCollectionsJson().toString()
}
fun importFromJson(jsonString: String): Result<List<Collection>> {
return runCatching {
rawCollectionsJson = json.parseToJsonElement(jsonString)
val imported = json.decodeFromString<List<Collection>>(jsonString)
_collections.value = imported
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
persist()
}
@ -239,9 +249,14 @@ object CollectionRepository {
private fun persist() {
runCatching {
CollectionStorage.savePayload(json.encodeToString(_collections.value))
CollectionStorage.savePayload(mergedCollectionsJson().toString())
}.onFailure { e ->
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) {
isSyncingFromRemote = true
CollectionRepository.applyFromRemote(remoteCollections)
CollectionRepository.applyFromRemote(remoteCollections, blob.collectionsJson)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remoteCollections.size} collections from remote" }
} else {

View file

@ -22,14 +22,14 @@ 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.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
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.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
@ -40,14 +40,17 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.StreamsUiState
import kotlin.math.round
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -230,24 +233,32 @@ private fun SourceStreamRow(
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val cardShape = RoundedCornerShape(12.dp)
Row(
modifier = Modifier
.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(
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(
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 {
Modifier
},
)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
.padding(14.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
@ -258,11 +269,13 @@ private fun SourceStreamRow(
Text(
text = stream.streamLabel,
color = colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Box(
@ -280,34 +293,66 @@ private fun SourceStreamRow(
}
}
}
stream.streamSubtitle?.let { subtitle ->
if (subtitle != stream.streamLabel) {
Text(
text = subtitle,
color = colorScheme.onSurfaceVariant,
val subtitle = stream.streamSubtitle
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = 12.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
lineHeight = 18.sp,
),
color = colorScheme.onSurfaceVariant,
)
}
Text(
text = stream.addonName,
color = colorScheme.onSurfaceVariant,
Spacer(modifier = Modifier.height(6.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
PlayerStreamFileSizeBadge(stream = stream)
Text(
text = stream.addonName,
color = colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontStyle = FontStyle.Italic,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@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,
fontStyle = FontStyle.Italic,
maxLines = 1,
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),
)
}
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.2.sp,
),
color = Color.White,
)
}
}