diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt index 041ec0664..e81119573 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt @@ -1,3 +1,5 @@ package com.nuvio.app.core.sync internal const val MOBILE_SYNC_PLATFORM = "mobile" +internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared" +internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index bddc4c975..c00a41e16 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -3,7 +3,8 @@ 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.sync.MOBILE_SYNC_PLATFORM +import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS +import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest @@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put @Serializable @@ -53,6 +55,14 @@ private data class SupabaseHomeCatalogSettingsBlob( @SerialName("updated_at") val updatedAt: String? = null, ) +private data class RemoteHomeCatalogSettings( + val platform: String, + val payload: SyncHomeCatalogPayload, + val updatedAt: String?, + val hasHideUnreleasedContent: Boolean, + val hasHideCatalogUnderline: Boolean, +) + object HomeCatalogSettingsSyncService { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("HomeCatalogSettingsSyncService") @@ -62,6 +72,8 @@ object HomeCatalogSettingsSyncService { } private const val PUSH_DEBOUNCE_MS = 1500L + private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile var isSyncingFromRemote: Boolean = false @@ -76,31 +88,18 @@ object HomeCatalogSettingsSyncService { suspend fun pullFromServer(profileId: Int) { runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) - val blobs = result.decodeList() - val blob = blobs.firstOrNull() + val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() + val remote = fetchBestRemotePayload(profileId, localPayload) - if (blob == null) { + if (remote == null) { log.i { "pullFromServer — no remote home catalog settings found" } - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() if (localPayload.items.isNotEmpty()) { pushToRemote(profileId) } 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 - } + val remotePayload = remote.payload if (remotePayload.items.isEmpty()) { log.i { "pullFromServer — remote has empty items, preserving local catalog order" } @@ -118,6 +117,9 @@ object HomeCatalogSettingsSyncService { HomeCatalogSettingsRepository.applyFromRemote(remotePayload) isSyncingFromRemote = false log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } + if (remote.platform != HOME_CATALOG_SHARED_SYNC_PLATFORM) { + pushToRemote(profileId) + } }.onFailure { e -> isSyncingFromRemote = false log.e(e) { "pullFromServer — FAILED" } @@ -142,11 +144,11 @@ object HomeCatalogSettingsSyncService { private suspend fun pushToRemote(profileId: Int) { runCatching { val payload = HomeCatalogSettingsRepository.exportToSyncPayload() - val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload) + val jsonElement = mergedSharedPayloadJson(profileId, payload) val params = buildJsonObject { put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) + put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) @@ -172,4 +174,114 @@ object HomeCatalogSettingsSyncService { } } } + + private suspend fun fetchBestRemotePayload( + profileId: Int, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val shared = fetchRemotePayload( + profileId = profileId, + platform = HOME_CATALOG_SHARED_SYNC_PLATFORM, + localPayload = localPayload, + ) + val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS + .mapNotNull { platform -> + fetchRemotePayload( + profileId = profileId, + platform = platform, + localPayload = localPayload, + ) + } + val rows = listOfNotNull(shared) + legacyRows + val selected = rows + .filter { it.payload.items.isNotEmpty() } + .maxByOrNull { it.updatedAt.orEmpty() } + ?: shared + ?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() } + + return selected?.withNewestStandaloneSettings(rows) + } + + private suspend fun fetchRemotePayload( + profileId: Int, + platform: String, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val blob = fetchRemoteBlob(profileId, platform) ?: return null + val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload) + if (payload == null) { + log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" } + return null + } + return RemoteHomeCatalogSettings( + platform = platform, + payload = payload, + updatedAt = blob.updatedAt, + hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), + hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), + ) + } + + private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings( + rows: List, + ): RemoteHomeCatalogSettings { + val hideUnreleasedSource = rows + .filter { it.hasHideUnreleasedContent } + .maxByOrNull { it.updatedAt.orEmpty() } + val hideUnderlineSource = rows + .filter { it.hasHideCatalogUnderline } + .maxByOrNull { it.updatedAt.orEmpty() } + + return copy( + payload = payload.copy( + hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent + ?: payload.hideUnreleasedContent, + hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline + ?: payload.hideCatalogUnderline, + ), + ) + } + + private suspend fun fetchRemoteBlob( + profileId: Int, + platform: String, + ): SupabaseHomeCatalogSettingsBlob? { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", platform) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) + return result.decodeList().firstOrNull() + } + + private fun decodePayloadPreservingLocalDefaults( + settingsJson: JsonObject, + localPayload: SyncHomeCatalogPayload, + ): SyncHomeCatalogPayload? = runCatching { + val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) + decoded.copy( + hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { + decoded.hideUnreleasedContent + } else { + localPayload.hideUnreleasedContent + }, + hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) { + decoded.hideCatalogUnderline + } else { + localPayload.hideCatalogUnderline + }, + ) + }.getOrNull() + + private suspend fun mergedSharedPayloadJson( + profileId: Int, + payload: SyncHomeCatalogPayload, + ): JsonObject { + val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject + val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson + return buildJsonObject { + remoteJson?.forEach { (key, value) -> put(key, value) } + localJson.forEach { (key, value) -> put(key, value) } + } + } }