This commit is contained in:
Aniket Tuli 2026-07-25 17:19:45 +05:30 committed by GitHub
commit 5d1abe9eb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 23 deletions

View file

@ -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

View file

@ -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<PluginRepositoryItem>): List<PluginPushEntry> =
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<String>,
localRepositories: List<PluginRepositoryItem>,
): Boolean = remoteUrls.isEmpty() && localRepositories.isNotEmpty()

View file

@ -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(),
),
)
}
}

View file

@ -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<PluginsUiState> = _uiState.asStateFlow()
private var initialized = false
private var pulledFromServer = false
private var currentProfileId = 1
private val activeRefreshJobs = mutableMapOf<String, Job>()
@ -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<PluginRow>()
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<String, PluginRow>()
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<String>): List<String> =
urls.map(::ensureManifestSuffix).distinct()
private fun ensureManifestSuffix(url: String): String {
val path = url.substringBefore("?").trimEnd('/')
val query = url.substringAfter("?", "")