mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat: collection preserving to prevent overwriting unsupported fields in blobs
This commit is contained in:
parent
23080c4344
commit
84a4771f67
4 changed files with 215 additions and 41 deletions
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
fontSize = 14.sp,
|
style = MaterialTheme.typography.bodyMedium.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
fontWeight = FontWeight.Bold,
|
||||||
overflow = TextOverflow.Ellipsis,
|
lineHeight = 20.sp,
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -280,34 +293,66 @@ private fun SourceStreamRow(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stream.streamSubtitle?.let { subtitle ->
|
|
||||||
if (subtitle != stream.streamLabel) {
|
val subtitle = stream.streamSubtitle
|
||||||
Text(
|
if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) {
|
||||||
text = subtitle,
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
color = colorScheme.onSurfaceVariant,
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 2,
|
lineHeight = 18.sp,
|
||||||
overflow = TextOverflow.Ellipsis,
|
),
|
||||||
)
|
color = colorScheme.onSurfaceVariant,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = stream.addonName,
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
color = colorScheme.onSurfaceVariant,
|
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,
|
fontSize = 11.sp,
|
||||||
fontStyle = FontStyle.Italic,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
letterSpacing = 0.2.sp,
|
||||||
overflow = TextOverflow.Ellipsis,
|
),
|
||||||
)
|
color = Color.White,
|
||||||
}
|
)
|
||||||
if (isCurrent) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Check,
|
|
||||||
contentDescription = stringResource(Res.string.compose_player_currently_playing),
|
|
||||||
tint = colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue