diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index d434bfd48..48326b846 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -23,8 +23,14 @@ object SyncManager { if (authState.isAnonymous) return scope.launch { - log.i { "Starting pull-all for profile $profileId" } + log.i { "pullAllForProfile($profileId) — auth=${(authState as AuthState.Authenticated).isAnonymous}" } + log.i { "pullAllForProfile — pulling addons first (await)..." } + runCatching { AddonRepository.pullFromServer(profileId) } + .onSuccess { log.i { "pullAllForProfile — addons pull completed" } } + .onFailure { log.e(it) { "Addon pull failed" } } + + log.i { "pullAllForProfile — launching remaining pulls in parallel" } launch { runCatching { LibraryRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "Library pull failed" } } @@ -33,10 +39,6 @@ object SyncManager { runCatching { WatchProgressRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "WatchProgress pull failed" } } } - launch { - runCatching { AddonRepository.pullFromServer(profileId) } - .onFailure { log.e(it) { "Addon pull failed" } } - } launch { runCatching { WatchedRepository.pullFromServer(profileId) } .onFailure { log.e(it) { "Watched pull failed" } } @@ -46,7 +48,7 @@ object SyncManager { .onFailure { log.e(it) { "ProfileSettings pull failed" } } } - log.i { "Pull-all launched for profile $profileId" } + log.i { "pullAllForProfile($profileId) — all pulls launched" } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index cb8a35206..a4ac08510 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger 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.query.Order import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,6 +12,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -22,11 +24,19 @@ import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put @Serializable -private data class AddonSyncItem( - @SerialName("manifest_url") val manifestUrl: String, +private data class AddonRow( + val url: String, + val name: String? = null, + val enabled: Boolean = true, + @SerialName("sort_order") val sortOrder: Int = 0, +) + +@Serializable +private data class AddonPushItem( + val url: String, + val name: String = "", + val enabled: Boolean = true, @SerialName("sort_order") val sortOrder: Int = 0, - @SerialName("display_name") val displayName: String = "", - @SerialName("is_enabled") val isEnabled: Boolean = true, ) object AddonRepository { @@ -37,12 +47,15 @@ object AddonRepository { val uiState: StateFlow = _uiState.asStateFlow() private var initialized = false + private var pulledFromServer = false fun initialize() { if (initialized) return initialized = true + log.d { "initialize() — loading local addons" } val storedUrls = AddonStorage.loadInstalledAddonUrls() + log.d { "initialize() — local addon count: ${storedUrls.size}" } if (storedUrls.isEmpty()) return _uiState.value = AddonsUiState( @@ -58,27 +71,70 @@ object AddonRepository { } suspend fun pullFromServer(profileId: Int) { + log.i { "pullFromServer() — profileId=$profileId, initialized=$initialized, pulledFromServer=$pulledFromServer" } runCatching { - val params = buildJsonObject { put("p_profile_id", profileId) } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_addons", params) - val serverAddons = result.decodeList() - val urls = serverAddons.sortedBy { it.sortOrder }.map { it.manifestUrl } - if (urls.isNotEmpty()) { - _uiState.value = AddonsUiState( - addons = urls.map { url -> - ManagedAddon(manifestUrl = url, isRefreshing = true) - }, - ) - persist() - urls.forEach(::refreshAddon) + val rows = SupabaseProvider.client.postgrest + .from("addons") + .select { + filter { eq("profile_id", profileId) } + order("sort_order", Order.ASCENDING) + } + .decodeList() + + val urls = rows.map { ensureManifestSuffix(it.url) } + log.i { "pullFromServer() — server returned ${rows.size} addons" } + urls.forEachIndexed { i, u -> log.d { " server[$i]: $u" } } + + if (urls.isEmpty() && !pulledFromServer) { + val localUrls = AddonStorage.loadInstalledAddonUrls() + log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" } + if (localUrls.isNotEmpty()) { + log.i { "pullFromServer() — migrating local addons to server for profile $profileId" } + initialize() + pulledFromServer = true + val addons = localUrls.mapIndexed { index, addonUrl -> + AddonPushItem( + url = addonUrl, + name = _uiState.value.addons + .find { it.manifestUrl == addonUrl }?.manifest?.name ?: "", + enabled = true, + sortOrder = index, + ) + } + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_addons", json.encodeToJsonElement(addons)) + } + SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) + log.i { "pullFromServer() — migration push done (${addons.size} addons)" } + return + } } + + _uiState.value = AddonsUiState( + addons = urls.map { url -> + ManagedAddon(manifestUrl = url, isRefreshing = true) + }, + ) + persist() + urls.forEach(::refreshAddon) + pulledFromServer = true initialized = true + log.i { "pullFromServer() — applied ${urls.size} addons to state" } }.onFailure { e -> - log.e(e) { "Failed to pull addons from server" } + log.e(e) { "pullFromServer() — FAILED" } + } + } + + suspend fun awaitManifestsLoaded() { + if (_uiState.value.addons.isEmpty()) return + uiState.first { state -> + state.addons.isEmpty() || state.addons.any { it.manifest != null } } } suspend fun addAddon(rawUrl: String): AddAddonResult { + log.i { "addAddon() — rawUrl=$rawUrl" } val manifestUrl = try { normalizeManifestUrl(rawUrl) } catch (error: IllegalArgumentException) { @@ -117,6 +173,7 @@ object AddonRepository { } fun removeAddon(manifestUrl: String) { + log.i { "removeAddon() — $manifestUrl" } _uiState.update { current -> current.copy( addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, @@ -176,20 +233,22 @@ object AddonRepository { runCatching { val profileId = ProfileRepository.activeProfileId val addons = _uiState.value.addons.mapIndexed { index, addon -> - AddonSyncItem( - manifestUrl = addon.manifestUrl, + AddonPushItem( + url = addon.manifestUrl, + name = addon.manifest?.name ?: "", + enabled = true, sortOrder = index, - displayName = addon.manifest?.name ?: "", - isEnabled = true, ) } + log.d { "pushToServer() — profileId=$profileId, pushing ${addons.size} addons" } val params = buildJsonObject { put("p_profile_id", profileId) put("p_addons", json.encodeToJsonElement(addons)) } SupabaseProvider.client.postgrest.rpc("sync_push_addons", params) + log.d { "pushToServer() — success" } }.onFailure { e -> - log.e(e) { "Failed to push addons to server" } + log.e(e) { "pushToServer() — FAILED" } } } } @@ -218,6 +277,13 @@ object AddonRepository { } } +private fun ensureManifestSuffix(url: String): String { + val path = url.substringBefore("?").trimEnd('/') + val query = url.substringAfter("?", "") + val withSuffix = if (path.endsWith("/manifest.json")) path else "$path/manifest.json" + return if (query.isEmpty()) withSuffix else "$withSuffix?$query" +} + private fun normalizeManifestUrl(rawUrl: String): String { val trimmed = rawUrl.trim() require(trimmed.isNotEmpty()) { "Enter an addon URL." } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 3563bcf63..ba8a128ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import co.touchlab.kermit.Logger import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository @@ -14,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json @@ -114,6 +116,13 @@ object WatchProgressRepository { if (needsResolution.isEmpty()) return syncScope.launch { + withTimeoutOrNull(30_000L) { + AddonRepository.awaitManifestsLoaded() + } ?: run { + log.w { "Timed out waiting for addon manifests" } + return@launch + } + for ((key, entries) in needsResolution) { val (metaId, metaType) = key val meta = runCatching {