feat: implement HomeCatalogSettings sync with cloud

This commit is contained in:
tapframe 2026-04-08 19:37:53 +05:30
parent 24363f0a22
commit 4f38d3713b
5 changed files with 221 additions and 2 deletions

View file

@ -123,6 +123,7 @@ import com.nuvio.app.features.collection.CollectionManagementScreen
import com.nuvio.app.features.collection.CollectionEditorScreen
import com.nuvio.app.features.collection.CollectionEditorRepository
import com.nuvio.app.features.collection.CollectionSyncService
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
import com.nuvio.app.features.collection.FolderDetailScreen
import com.nuvio.app.features.collection.FolderDetailRepository
import com.nuvio.app.features.streams.StreamContext
@ -394,6 +395,9 @@ private fun MainAppContent(
remember {
CollectionSyncService.startObserving()
}
remember {
HomeCatalogSettingsSyncService.startObserving()
}
val hapticFeedback = LocalHapticFeedback.current
val coroutineScope = rememberCoroutineScope()
var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) }

View file

@ -6,6 +6,7 @@ import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.collection.CollectionSyncService
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.profiles.ProfileRepository
@ -61,6 +62,10 @@ object SyncManager {
runCatching { CollectionSyncService.pullFromServer(profileId) }
.onFailure { log.e(it) { "Collections pull failed" } }
}
launch {
runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) }
.onFailure { log.e(it) { "HomeCatalogSettings pull failed" } }
}
log.i { "pullAllForProfile($profileId) — all pulls launched" }
}

View file

@ -378,6 +378,60 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
fun exportToSyncPayload(): SyncHomeCatalogPayload {
ensureLoaded()
val items = preferences.values.sortedBy { it.order }.map { pref ->
val parts = pref.key.split(":")
val isCollection = pref.key.startsWith("collection_")
if (isCollection) {
SyncCatalogItem(
addonId = "",
type = "",
catalogId = "",
enabled = pref.enabled,
order = pref.order,
customTitle = pref.customTitle,
isCollection = true,
collectionId = pref.key.removePrefix("collection_"),
)
} else {
SyncCatalogItem(
addonId = parts.getOrElse(0) { "" },
type = parts.getOrElse(1) { "" },
catalogId = parts.getOrElse(2) { "" },
enabled = pref.enabled,
order = pref.order,
customTitle = pref.customTitle,
isCollection = false,
)
}
}
return SyncHomeCatalogPayload(items = items)
}
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded()
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->
val key = if (item.isCollection) {
"collection_${item.collectionId}"
} else {
"${item.addonId}:${item.type}:${item.catalogId}"
}
key to StoredHomeCatalogPreference(
key = key,
customTitle = item.customTitle,
enabled = item.enabled,
heroSourceEnabled = existingHeroState[key] ?: true,
order = item.order,
)
}.toMutableMap()
hasLoaded = true
publish()
persist()
HomeRepository.applyCurrentSettings()
}
private fun allOrderedKeys(): List<String> {
val catalogKeys = definitions.map { it.key }
val collectionKeys = collectionDefinitions.map { it.key }

View file

@ -0,0 +1,156 @@
package com.nuvio.app.features.home
import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.rpc
import kotlin.concurrent.Volatile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@Serializable
data class SyncCatalogItem(
@SerialName("addon_id") val addonId: String,
val type: String,
@SerialName("catalog_id") val catalogId: String,
val enabled: Boolean = true,
val order: Int = 0,
@SerialName("custom_title") val customTitle: String = "",
@SerialName("is_collection") val isCollection: Boolean = false,
@SerialName("collection_id") val collectionId: String = "",
)
@Serializable
data class SyncHomeCatalogPayload(
val items: List<SyncCatalogItem> = emptyList(),
)
@Serializable
private data class SupabaseHomeCatalogSettingsBlob(
@SerialName("profile_id") val profileId: Int = 1,
@SerialName("settings_json") val settingsJson: JsonObject = buildJsonObject { },
@SerialName("updated_at") val updatedAt: String? = null,
)
object HomeCatalogSettingsSyncService {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("HomeCatalogSettingsSyncService")
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private const val PUSH_DEBOUNCE_MS = 1500L
@Volatile
var isSyncingFromRemote: Boolean = false
private var pushJob: Job? = null
private var observeJob: Job? = null
fun startObserving() {
if (observeJob?.isActive == true) return
observeLocalChangesAndPush()
}
suspend fun pullFromServer(profileId: Int) {
runCatching {
val params = buildJsonObject {
put("p_profile_id", profileId)
}
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params)
val blobs = result.decodeList<SupabaseHomeCatalogSettingsBlob>()
val blob = blobs.firstOrNull()
if (blob == null) {
log.i { "pullFromServer — no remote home catalog settings found" }
return
}
val remotePayload = runCatching {
json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson)
}.getOrNull()
if (remotePayload == null) {
log.w { "pullFromServer — failed to parse remote home catalog settings" }
return
}
if (remotePayload.items.isEmpty()) {
log.i { "pullFromServer — remote has empty items, preserving local" }
return
}
isSyncingFromRemote = true
HomeCatalogSettingsRepository.applyFromRemote(remotePayload)
isSyncingFromRemote = false
log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" }
}.onFailure { e ->
isSyncingFromRemote = false
log.e(e) { "pullFromServer — FAILED" }
}
}
fun triggerPush() {
pushJob?.cancel()
pushJob = scope.launch {
delay(500)
if (isSyncingFromRemote) return@launch
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch
pushToRemote()
}
}
private suspend fun pushToRemote() {
runCatching {
val profileId = ProfileRepository.activeProfileId
val payload = HomeCatalogSettingsRepository.exportToSyncPayload()
val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload)
val params = buildJsonObject {
put("p_profile_id", profileId)
put("p_settings_json", jsonElement)
}
SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params)
log.d { "pushToRemote — success" }
}.onFailure { e ->
log.e(e) { "pushToRemote — FAILED" }
}
}
@OptIn(FlowPreview::class)
private fun observeLocalChangesAndPush() {
observeJob = scope.launch {
HomeCatalogSettingsRepository.uiState
.map { it.signature }
.drop(1)
.distinctUntilChanged()
.debounce(PUSH_DEBOUNCE_MS)
.collect {
if (isSyncingFromRemote) return@collect
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect
pushToRemote()
}
}
}
}

View file

@ -1,2 +1,2 @@
CURRENT_PROJECT_VERSION=12
MARKETING_VERSION=0.1.0-alpha12
CURRENT_PROJECT_VERSION=14
MARKETING_VERSION=0.1.0-alpha14