From 135f4731b8618519fc60c6c4ea464210012c208b Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 02:43:50 -0700 Subject: [PATCH 1/2] fix: stop mobile plugin sync clobbering CloudStream repos saved by other apps Pulling plugins normalized every server URL by appending /manifest.json and pushing dropped the repo_type column, so any push from the mobile app rewrote CloudStream repository rows into URLs and types the TV app could no longer match, causing it to remove them. The startup pull also pushed the local list whenever the server select came back empty, clobbering remote data without any user action. Plugin pull is now pull-only on startup: an empty remote with non-empty local state preserves local repositories instead of push-migrating. Pushes now round-trip the original server url, repo_type and enabled flag for entries that came from the server, so repositories saved by nuvioapp.space or the TV app survive mobile pushes untouched. Fixes #1190 Co-Authored-By: Claude Fable 5 --- .../app/features/plugins/PluginModels.kt | 6 + .../app/features/plugins/PluginSyncPlan.kt | 25 ++++ .../features/plugins/PluginSyncPlanTest.kt | 113 ++++++++++++++++++ .../app/features/plugins/PluginRepository.kt | 65 ++++++---- 4 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginSyncPlan.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginSyncPlanTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index afca7988..e780841b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -40,6 +40,9 @@ data class PluginRepositoryItem( val lastUpdated: Long = 0L, val isRefreshing: Boolean = false, val errorMessage: String? = null, + val serverUrl: String? = null, + val serverRepoType: String? = null, + val serverEnabled: Boolean? = null, ) data class PluginScraper( @@ -106,6 +109,9 @@ internal data class StoredPluginRepository( val version: String? = null, val scraperCount: Int = 0, val lastUpdated: Long = 0L, + val serverUrl: String? = null, + val serverRepoType: String? = null, + val serverEnabled: Boolean? = null, ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginSyncPlan.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginSyncPlan.kt new file mode 100644 index 00000000..8226aa43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginSyncPlan.kt @@ -0,0 +1,25 @@ +package com.nuvio.app.features.plugins + +internal data class PluginPushEntry( + val url: String, + val name: String, + val enabled: Boolean, + val repoType: String?, + val sortOrder: Int, +) + +internal fun buildPluginPushEntries(repositories: List): List = + repositories.mapIndexed { index, repo -> + PluginPushEntry( + url = repo.serverUrl?.takeIf { it.isNotBlank() } ?: repo.manifestUrl, + name = repo.name, + enabled = repo.serverEnabled ?: true, + repoType = repo.serverRepoType?.takeIf { it.isNotBlank() }, + sortOrder = index, + ) + } + +internal fun shouldPreserveLocalPluginRepositories( + remoteUrls: List, + localRepositories: List, +): Boolean = remoteUrls.isEmpty() && localRepositories.isNotEmpty() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginSyncPlanTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginSyncPlanTest.kt new file mode 100644 index 00000000..473162e0 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginSyncPlanTest.kt @@ -0,0 +1,113 @@ +package com.nuvio.app.features.plugins + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class PluginSyncPlanTest { + + private fun repo( + manifestUrl: String, + name: String = "Repo", + serverUrl: String? = null, + serverRepoType: String? = null, + serverEnabled: Boolean? = null, + ) = PluginRepositoryItem( + manifestUrl = manifestUrl, + name = name, + serverUrl = serverUrl, + serverRepoType = serverRepoType, + serverEnabled = serverEnabled, + ) + + @Test + fun pushEntriesPreserveOriginalServerUrlAndRepoType() { + val cloudStreamRepo = repo( + manifestUrl = "https://example.com/cs/repo.json/manifest.json", + name = "CS Repo", + serverUrl = "https://example.com/cs/repo.json", + serverRepoType = "CLOUDSTREAM", + serverEnabled = false, + ) + + val entries = buildPluginPushEntries(listOf(cloudStreamRepo)) + + assertEquals(1, entries.size) + assertEquals("https://example.com/cs/repo.json", entries[0].url) + assertEquals("CLOUDSTREAM", entries[0].repoType) + assertEquals(false, entries[0].enabled) + assertEquals(0, entries[0].sortOrder) + } + + @Test + fun pushEntriesFallBackToManifestUrlForLocallyAddedRepos() { + val localRepo = repo( + manifestUrl = "https://example.com/nuvio/manifest.json", + name = "Local Repo", + ) + + val entries = buildPluginPushEntries(listOf(localRepo)) + + assertEquals("https://example.com/nuvio/manifest.json", entries[0].url) + assertNull(entries[0].repoType) + assertTrue(entries[0].enabled) + } + + @Test + fun pushEntriesIgnoreBlankServerMetadata() { + val blankMetadataRepo = repo( + manifestUrl = "https://example.com/nuvio/manifest.json", + serverUrl = " ", + serverRepoType = "", + ) + + val entries = buildPluginPushEntries(listOf(blankMetadataRepo)) + + assertEquals("https://example.com/nuvio/manifest.json", entries[0].url) + assertNull(entries[0].repoType) + } + + @Test + fun pushEntriesAssignSortOrderByPosition() { + val entries = buildPluginPushEntries( + listOf( + repo(manifestUrl = "https://a.example/manifest.json"), + repo(manifestUrl = "https://b.example/manifest.json"), + ), + ) + + assertEquals(listOf(0, 1), entries.map { it.sortOrder }) + } + + @Test + fun preservesLocalWhenRemoteEmptyAndLocalHasRepositories() { + assertTrue( + shouldPreserveLocalPluginRepositories( + remoteUrls = emptyList(), + localRepositories = listOf(repo(manifestUrl = "https://a.example/manifest.json")), + ), + ) + } + + @Test + fun doesNotPreserveLocalWhenRemoteHasRepositories() { + assertFalse( + shouldPreserveLocalPluginRepositories( + remoteUrls = listOf("https://a.example/manifest.json"), + localRepositories = listOf(repo(manifestUrl = "https://b.example/manifest.json")), + ), + ) + } + + @Test + fun doesNotPreserveLocalWhenLocalIsEmpty() { + assertFalse( + shouldPreserveLocalPluginRepositories( + remoteUrls = emptyList(), + localRepositories = emptyList(), + ), + ) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index 88439c51..e3a67b8f 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -41,6 +41,7 @@ private data class PluginRow( val name: String? = null, val enabled: Boolean = true, @SerialName("sort_order") val sortOrder: Int = 0, + @SerialName("repo_type") val repoType: String? = null, ) @Serializable @@ -49,6 +50,7 @@ private data class PluginPushItem( val name: String = "", val enabled: Boolean = true, @SerialName("sort_order") val sortOrder: Int = 0, + @SerialName("repo_type") val repoType: String? = null, ) actual object PluginRepository { @@ -60,7 +62,6 @@ actual object PluginRepository { actual val uiState: StateFlow = _uiState.asStateFlow() private var initialized = false - private var pulledFromServer = false private var currentProfileId = 1 private val activeRefreshJobs = mutableMapOf() @@ -82,7 +83,6 @@ actual object PluginRepository { cancelActiveRefreshes() currentProfileId = effectiveProfileId initialized = false - pulledFromServer = false _uiState.value = PluginsUiState() } @@ -90,7 +90,6 @@ actual object PluginRepository { cancelActiveRefreshes() currentProfileId = 1 initialized = false - pulledFromServer = false _uiState.value = PluginsUiState() } @@ -106,25 +105,35 @@ actual object PluginRepository { } .decodeList() - val urls = dedupeManifestUrls(rows.map { it.url }) - if (urls.isEmpty() && !pulledFromServer) { - val localUrls = _uiState.value.repositories.map { it.manifestUrl } - if (localUrls.isNotEmpty()) { - initialize() - pulledFromServer = true - pushToServer() - return + val rowsByUrl = linkedMapOf() + rows.forEach { row -> + val manifestUrl = ensureManifestSuffix(row.url) + if (!rowsByUrl.containsKey(manifestUrl)) { + rowsByUrl[manifestUrl] = row } } + val urls = rowsByUrl.keys.toList() + + if (shouldPreserveLocalPluginRepositories(urls, _uiState.value.repositories)) { + log.w { "pullFromServer — remote empty while local has repositories; preserving local" } + initialized = true + return + } val existingReposByUrl = _uiState.value.repositories.associateBy { it.manifestUrl } val nextRepos = urls.map { url -> - existingReposByUrl[url]?.copy(isRefreshing = true, errorMessage = null) + val row = rowsByUrl.getValue(url) + val base = existingReposByUrl[url]?.copy(isRefreshing = true, errorMessage = null) ?: PluginRepositoryItem( manifestUrl = url, name = url.substringBefore("?").substringAfterLast('/'), isRefreshing = true, ) + base.copy( + serverUrl = row.url, + serverRepoType = row.repoType, + serverEnabled = row.enabled, + ) } val nextScrapers = _uiState.value.scrapers.filter { scraper -> urls.contains(scraper.repositoryUrl) @@ -142,7 +151,6 @@ actual object PluginRepository { refreshRepository(url, pushAfterRefresh = false) } - pulledFromServer = true initialized = true }.onFailure { error -> log.e(error) { "pullFromServer failed" } @@ -228,7 +236,15 @@ actual object PluginRepository { result.fold( onSuccess = { (repo, scrapers) -> val updatedRepos = state.repositories.map { existing -> - if (existing.manifestUrl == manifestUrl) repo else existing + if (existing.manifestUrl == manifestUrl) { + repo.copy( + serverUrl = existing.serverUrl, + serverRepoType = existing.serverRepoType, + serverEnabled = existing.serverEnabled, + ) + } else { + existing + } } state.copy( repositories = updatedRepos, @@ -438,12 +454,13 @@ actual object PluginRepository { private fun pushToServer() { scope.launch { runCatching { - val repos = _uiState.value.repositories.mapIndexed { index, repo -> + val repos = buildPluginPushEntries(_uiState.value.repositories).map { entry -> PluginPushItem( - url = repo.manifestUrl, - name = repo.name, - enabled = true, - sortOrder = index, + url = entry.url, + name = entry.name, + enabled = entry.enabled, + sortOrder = entry.sortOrder, + repoType = entry.repoType, ) } @@ -471,6 +488,9 @@ actual object PluginRepository { version = repo.version, scraperCount = repo.scraperCount, lastUpdated = repo.lastUpdated, + serverUrl = repo.serverUrl, + serverRepoType = repo.serverRepoType, + serverEnabled = repo.serverEnabled, ) }, scrapers = state.scrapers.map { scraper -> @@ -512,7 +532,6 @@ actual object PluginRepository { if (currentProfileId != profileId) { cancelActiveRefreshes() - pulledFromServer = false } currentProfileId = profileId @@ -536,6 +555,9 @@ actual object PluginRepository { lastUpdated = it.lastUpdated, isRefreshing = false, errorMessage = null, + serverUrl = it.serverUrl, + serverRepoType = it.serverRepoType, + serverEnabled = it.serverEnabled, ) } ?: emptyList(), @@ -561,9 +583,6 @@ actual object PluginRepository { ) } - private fun dedupeManifestUrls(urls: List): List = - urls.map(::ensureManifestSuffix).distinct() - private fun ensureManifestSuffix(url: String): String { val path = url.substringBefore("?").trimEnd('/') val query = url.substringAfter("?", "") From 141d74a814b74362413a7864bf96db960f3143ff Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 03:03:45 -0700 Subject: [PATCH 2/2] fix(plugins): refresh local repositories when preserving them on empty remote The preserve-local branch previously returned before scheduling any repository refreshes, leaving local repositories stale for the whole session on cold start. They now refresh (without pushing) before the early return, matching the non-empty-remote path. Co-Authored-By: Claude Fable 5 --- .../kotlin/com/nuvio/app/features/plugins/PluginRepository.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index e3a67b8f..99724f59 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -116,6 +116,9 @@ actual object PluginRepository { if (shouldPreserveLocalPluginRepositories(urls, _uiState.value.repositories)) { log.w { "pullFromServer — remote empty while local has repositories; preserving local" } + _uiState.value.repositories.forEach { repo -> + refreshRepository(repo.manifestUrl, pushAfterRefresh = false) + } initialized = true return }