From 5d49e2c0a408d18a8e5c11f6bda53f3c7c70b5ed Mon Sep 17 00:00:00 2001
From: halibiram <94225244+halibiram@users.noreply.github.com>
Date: Mon, 4 May 2026 21:30:14 +0300
Subject: [PATCH] Add duplicate scraper detection dialog on plugins settings
---
.../composeResources/values-tr/strings.xml | 4 +
.../composeResources/values/strings.xml | 4 +
.../features/plugins/PluginDuplicateGuard.kt | 259 ++++++++++++++++++
.../features/plugins/PluginsSettingsScreen.kt | 3 +
4 files changed, 270 insertions(+)
create mode 100644 composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginDuplicateGuard.kt
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index e3d66b12..e515cf89 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -814,6 +814,10 @@
PIN\'i mi unuttun?
PIN hatalı
Kilitli. %1$dsn sonra tekrar dene
+ %1$d tanesini kapat
+ Daha sonra
+ Aşağıdaki sağlayıcıların birden fazla aktif kopyası var. Yinelenen kopyaları devre dışı bırakalım mı?
+ Yinelenen sağlayıcılar tespit edildi
Katalog yüklenince avatar seçenekleri burada görünecek.
Avatar: %1$s
Avatar seç
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 7cbb3f3e..2ce464d6 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -969,6 +969,10 @@
Forgot PIN?
Incorrect PIN
Locked. Try again in %1$ds
+ Disable %1$d
+ Later
+ The following providers have more than one active copy across your repositories. Disable the duplicates?
+ Duplicate providers detected
Avatar options will appear here when the catalog loads.
Avatar: %1$s
Choose an avatar
diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginDuplicateGuard.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginDuplicateGuard.kt
new file mode 100644
index 00000000..29aba9d8
--- /dev/null
+++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginDuplicateGuard.kt
@@ -0,0 +1,259 @@
+package com.nuvio.app.features.plugins
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.BasicAlertDialog
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import com.nuvio.app.core.ui.NuvioPrimaryButton
+import kotlinx.coroutines.delay
+import nuvio.composeapp.generated.resources.Res
+import nuvio.composeapp.generated.resources.plugin_duplicates_disable
+import nuvio.composeapp.generated.resources.plugin_duplicates_later
+import nuvio.composeapp.generated.resources.plugin_duplicates_subtitle
+import nuvio.composeapp.generated.resources.plugin_duplicates_title
+import org.jetbrains.compose.resources.stringResource
+
+/**
+ * Detects scrapers that share the same `name` (case-insensitive) or `filename`
+ * across repositories and shows a dialog inviting the user to disable the
+ * duplicates. Reappears whenever a previously-disabled scraper id becomes
+ * enabled again (so toggling a repository off then on resurfaces the prompt).
+ */
+@Composable
+internal fun PluginDuplicateGuard(uiState: PluginsUiState) {
+ val repoNameByUrl = remember(uiState.repositories) {
+ uiState.repositories.associate { it.manifestUrl to it.name }
+ }
+
+ val currentEnabledIds = remember(uiState.scrapers) {
+ uiState.scrapers.asSequence()
+ .filter { it.enabled }
+ .map { it.id }
+ .toSet()
+ }
+
+ var dismissedSnapshot by remember { mutableStateOf?>(null) }
+
+ LaunchedEffect(currentEnabledIds) {
+ val snapshot = dismissedSnapshot
+ if (snapshot != null && (currentEnabledIds - snapshot).isNotEmpty()) {
+ dismissedSnapshot = null
+ }
+ }
+
+ val rawGroups = remember(uiState.scrapers, repoNameByUrl) {
+ computeDuplicateGroups(uiState.scrapers, repoNameByUrl)
+ }
+
+ var stableGroups by remember { mutableStateOf>(emptyList()) }
+ LaunchedEffect(rawGroups) {
+ if (rawGroups.isEmpty()) {
+ stableGroups = emptyList()
+ } else {
+ // Debounce so refresh emits coalesce into a single comprehensive check.
+ delay(500)
+ stableGroups = rawGroups
+ }
+ }
+
+ val showDialog = stableGroups.isNotEmpty() && dismissedSnapshot == null
+ if (!showDialog) return
+
+ DuplicateResolutionDialog(
+ groups = stableGroups,
+ onConfirm = {
+ stableGroups.forEach { group ->
+ group.duplicates.forEach { duplicate ->
+ PluginRepository.toggleScraper(duplicate.id, false)
+ }
+ }
+ },
+ onDismiss = { dismissedSnapshot = currentEnabledIds },
+ )
+}
+
+internal data class DuplicateGroup(
+ val keep: DuplicateEntry,
+ val duplicates: List,
+) {
+ val totalToDisable: Int get() = duplicates.size
+}
+
+internal data class DuplicateEntry(
+ val id: String,
+ val name: String,
+ val filename: String,
+ val repositoryName: String,
+)
+
+internal fun computeDuplicateGroups(
+ scrapers: List,
+ repoNameByUrl: Map,
+): List {
+ val enabled = scrapers.filter { it.enabled }
+ if (enabled.size < 2) return emptyList()
+
+ val n = enabled.size
+ val parent = IntArray(n) { it }
+ fun find(x: Int): Int {
+ var r = x
+ while (parent[r] != r) r = parent[r]
+ parent[x] = r
+ return r
+ }
+ fun union(a: Int, b: Int) {
+ val ra = find(a)
+ val rb = find(b)
+ if (ra != rb) parent[ra] = rb
+ }
+
+ for (i in 0 until n) {
+ for (j in i + 1 until n) {
+ val a = enabled[i]
+ val b = enabled[j]
+ val nameEq = a.name.isNotBlank() && a.name.equals(b.name, ignoreCase = true)
+ val fileEq = a.filename.isNotBlank() && a.filename.equals(b.filename, ignoreCase = true)
+ if (nameEq || fileEq) union(i, j)
+ }
+ }
+
+ return enabled.indices
+ .groupBy { find(it) }
+ .values
+ .filter { it.size > 1 }
+ .map { idxs ->
+ val members = idxs.map { enabled[it] }.sortedBy { it.id }
+ DuplicateGroup(
+ keep = members.first().toDuplicateEntry(repoNameByUrl),
+ duplicates = members.drop(1).map { it.toDuplicateEntry(repoNameByUrl) },
+ )
+ }
+}
+
+private fun PluginScraper.toDuplicateEntry(repoNameByUrl: Map) =
+ DuplicateEntry(
+ id = id,
+ name = name,
+ filename = filename,
+ repositoryName = repoNameByUrl[repositoryUrl].orEmpty(),
+ )
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun DuplicateResolutionDialog(
+ groups: List,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ val totalToDisable = groups.sumOf { it.totalToDisable }
+ val bodySmallSize = MaterialTheme.typography.bodySmall.fontSize
+ val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
+
+ BasicAlertDialog(onDismissRequest = onDismiss) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surface,
+ shape = RoundedCornerShape(24.dp),
+ ) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(
+ text = stringResource(Res.string.plugin_duplicates_title),
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(Res.string.plugin_duplicates_subtitle),
+ style = MaterialTheme.typography.bodyMedium,
+ color = onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 320.dp)
+ .verticalScroll(rememberScrollState()),
+ ) {
+ groups.forEachIndexed { index, group ->
+ if (index > 0) Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = buildAnnotatedString {
+ append(group.keep.name)
+ if (group.keep.repositoryName.isNotBlank()) {
+ withStyle(
+ SpanStyle(
+ color = onSurfaceVariant,
+ fontSize = bodySmallSize,
+ ),
+ ) {
+ append(" - ${group.keep.repositoryName}")
+ }
+ }
+ },
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ group.duplicates.forEach { duplicate ->
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = buildAnnotatedString {
+ append("- ${duplicate.name}")
+ if (duplicate.repositoryName.isNotBlank()) {
+ withStyle(SpanStyle(color = onSurfaceVariant)) {
+ append(" - ${duplicate.repositoryName}")
+ }
+ }
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text(stringResource(Res.string.plugin_duplicates_later))
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ NuvioPrimaryButton(
+ text = stringResource(Res.string.plugin_duplicates_disable, totalToDisable),
+ onClick = onConfirm,
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
index 71b7e4e3..93faa7a5 100644
--- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
+++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
@@ -50,6 +50,9 @@ fun PluginsSettingsPageContent(
}
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
+
+ PluginDuplicateGuard(uiState)
+
val tmdbSettings by remember {
TmdbSettingsRepository.ensureLoaded()
TmdbSettingsRepository.uiState