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 98769a63..c7976e46 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 @@ -41,6 +41,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( @@ -117,6 +120,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 f935d961..720ab6f3 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 @@ -42,6 +42,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 @@ -50,6 +51,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 { @@ -61,7 +63,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() @@ -83,7 +84,6 @@ actual object PluginRepository { cancelActiveRefreshes() currentProfileId = effectiveProfileId initialized = false - pulledFromServer = false _uiState.value = PluginsUiState() } @@ -91,7 +91,6 @@ actual object PluginRepository { cancelActiveRefreshes() currentProfileId = 1 initialized = false - pulledFromServer = false _uiState.value = PluginsUiState() } @@ -107,25 +106,38 @@ 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" } + _uiState.value.repositories.forEach { repo -> + refreshRepository(repo.manifestUrl, pushAfterRefresh = false) + } + 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) @@ -143,7 +155,6 @@ actual object PluginRepository { refreshRepository(url, pushAfterRefresh = false) } - pulledFromServer = true initialized = true }.onFailure { error -> log.e(error) { "pullFromServer failed" } @@ -229,7 +240,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, @@ -439,12 +458,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, ) } @@ -472,6 +492,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 -> @@ -513,7 +536,6 @@ actual object PluginRepository { if (currentProfileId != profileId) { cancelActiveRefreshes() - pulledFromServer = false } currentProfileId = profileId @@ -537,6 +559,9 @@ actual object PluginRepository { lastUpdated = it.lastUpdated, isRefreshing = false, errorMessage = null, + serverUrl = it.serverUrl, + serverRepoType = it.serverRepoType, + serverEnabled = it.serverEnabled, ) } ?: emptyList(), @@ -563,9 +588,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("?", "")