diff --git a/locales/de.json b/locales/de.json index 0b38aa3f..9fce14bb 100644 --- a/locales/de.json +++ b/locales/de.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "{count} Duplikat-Gruppen gefunden", "noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden", "keepLatest": "Neueste Versionen behalten", diff --git a/locales/en.json b/locales/en.json index 23a46aa8..233e7463 100644 --- a/locales/en.json +++ b/locales/en.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "Scanning for duplicate recipes...", "found": "Found {count} duplicate groups", "noGroups": "No duplicate groups found with the current matching basis", "keepLatest": "Keep Latest Versions", diff --git a/locales/es.json b/locales/es.json index 300a41dd..5d9de398 100644 --- a/locales/es.json +++ b/locales/es.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "Se encontraron {count} grupos de duplicados", "noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual", "keepLatest": "Mantener versiones más recientes", diff --git a/locales/fr.json b/locales/fr.json index 3244bc68..cda8458a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "Trouvé {count} groupes de doublons", "noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel", "keepLatest": "Garder les dernières versions", diff --git a/locales/he.json b/locales/he.json index fe43ea7e..347f8577 100644 --- a/locales/he.json +++ b/locales/he.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "נמצאו {count} קבוצות כפולות", "noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי", "keepLatest": "שמור גרסאות אחרונות", diff --git a/locales/ja.json b/locales/ja.json index a774bfa8..4c5b31b0 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "{count} 個の重複グループが見つかりました", "noGroups": "現在の一致基準では重複グループが見つかりませんでした", "keepLatest": "最新バージョンを保持", diff --git a/locales/ko.json b/locales/ko.json index 0bda407a..96d70bc5 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "{count}개의 중복 그룹 발견", "noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다", "keepLatest": "최신 버전 유지", diff --git a/locales/ru.json b/locales/ru.json index 03df82f5..a5d2f046 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "Найдено {count} групп дубликатов", "noGroups": "Дубликатов с текущим критерием не найдено", "keepLatest": "Оставить последние версии", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 66ff68b2..3fa81625 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "发现 {count} 个重复组", "noGroups": "按当前判重依据未找到重复组", "keepLatest": "保留最新版本", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d5d96348..8b5004b0 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -963,6 +963,7 @@ } }, "duplicates": { + "finding": "[TODO: Translate] Scanning for duplicate recipes...", "found": "發現 {count} 組重複項", "noGroups": "按目前判重依據未找到重複組", "keepLatest": "保留最新版本", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 8f8ba243..2a53e384 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -618,16 +618,31 @@ class RecipeQueryHandler: include_prompt=include_prompt ) url_groups = await recipe_scanner.find_duplicate_recipes_by_source() + + # Assemble the response directly from the cached recipe summaries. + # Resolving each id via get_recipe_by_id would re-read every recipe + # JSON from disk — thousands of blocking reads on the event loop + # for large libraries — while all required fields already live in + # the cache. + cache = await recipe_scanner.get_cached_data() + recipes_by_id = { + str(recipe.get("id", "")): recipe for recipe in cache.raw_data + } + response_data = [] - for fingerprint, recipe_ids in fingerprint_groups.items(): - if len(recipe_ids) <= 1: - continue + def append_groups( + groups: Dict[str, List[Any]], group_type: str + ) -> None: + for group_key, recipe_ids in groups.items(): + if len(recipe_ids) <= 1: + continue - recipes = [] - for recipe_id in recipe_ids: - recipe = await recipe_scanner.get_recipe_by_id(recipe_id) - if recipe: + recipes = [] + for recipe_id in recipe_ids: + recipe = recipes_by_id.get(str(recipe_id)) + if recipe is None: + continue recipes.append( { "id": recipe.get("id"), @@ -642,55 +657,23 @@ class RecipeQueryHandler: } ) - if len(recipes) >= 2: - recipes.sort( - key=lambda entry: entry.get("modified", 0), reverse=True - ) - response_data.append( - { - "type": "fingerprint", - "key": f"g-{len(response_data) + 1}", - "fingerprint": fingerprint, - "count": len(recipes), - "recipes": recipes, - } - ) - - for url, recipe_ids in url_groups.items(): - if len(recipe_ids) <= 1: - continue - - recipes = [] - for recipe_id in recipe_ids: - recipe = await recipe_scanner.get_recipe_by_id(recipe_id) - if recipe: - recipes.append( + if len(recipes) >= 2: + recipes.sort( + key=lambda entry: entry.get("modified") or 0, + reverse=True, + ) + response_data.append( { - "id": recipe.get("id"), - "title": recipe.get("title"), - "file_url": recipe.get("file_url") - or self._format_recipe_file_url( - recipe.get("file_path", "") - ), - "modified": recipe.get("modified"), - "created_date": recipe.get("created_date"), - "lora_count": len(recipe.get("loras", [])), + "type": group_type, + "key": f"g-{len(response_data) + 1}", + "fingerprint": group_key, + "count": len(recipes), + "recipes": recipes, } ) - if len(recipes) >= 2: - recipes.sort( - key=lambda entry: entry.get("modified", 0), reverse=True - ) - response_data.append( - { - "type": "source_path", - "key": f"g-{len(response_data) + 1}", - "fingerprint": url, - "count": len(recipes), - "recipes": recipes, - } - ) + append_groups(fingerprint_groups, "fingerprint") + append_groups(url_groups, "source_path") response_data.sort(key=lambda entry: entry["count"], reverse=True) return web.json_response( diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index 63f17be3..dc43e8ad 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -12,6 +12,7 @@ export class DuplicatesManager { this.duplicateGroups = []; this.inDuplicateMode = false; this.selectedForDeletion = new Set(); + this._isFindingDuplicates = false; this._initPromptMatchToggle(); this._initHelpTooltip(); } @@ -87,6 +88,19 @@ export class DuplicatesManager { } async findDuplicates() { + // Guard against re-entry: the scan can take a while on large + // libraries, and repeated clicks would pile up identical requests + // on the backend. + if (this._isFindingDuplicates) { + return false; + } + this._isFindingDuplicates = true; + const triggerButton = document.querySelector('[data-action="find-duplicates"]'); + if (triggerButton) { + triggerButton.disabled = true; + triggerButton.classList.add('loading'); + } + state.loadingManager?.showSimpleLoading(translate('recipes.duplicates.finding')); try { const includePrompt = this._getPromptMatchPreference(); const endpoint = includePrompt @@ -96,14 +110,14 @@ export class DuplicatesManager { if (!response.ok) { throw new Error('Failed to find duplicates'); } - + const data = await response.json(); if (!data.success) { throw new Error(data.error || 'Unknown error finding duplicates'); } - + this.duplicateGroups = data.duplicate_groups || []; - + if (this.duplicateGroups.length === 0) { showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info'); // Keep (or enter) the duplicates view when the user is tuning @@ -115,13 +129,20 @@ export class DuplicatesManager { this.enterDuplicateMode(); return true; } - + this.enterDuplicateMode(); return true; } catch (error) { console.error('Error finding duplicates:', error); showToast('toast.duplicates.findFailed', { message: error.message }, 'error'); return false; + } finally { + this._isFindingDuplicates = false; + if (triggerButton) { + triggerButton.disabled = false; + triggerButton.classList.remove('loading'); + } + state.loadingManager?.hide(); } } diff --git a/tests/routes/test_recipe_routes.py b/tests/routes/test_recipe_routes.py index 53b54a52..63e1afe0 100644 --- a/tests/routes/test_recipe_routes.py +++ b/tests/routes/test_recipe_routes.py @@ -2003,10 +2003,10 @@ async def test_find_duplicates_defaults_to_fingerprint_only( monkeypatch, tmp_path: Path ) -> None: async with recipe_harness(monkeypatch, tmp_path) as harness: - harness.scanner.recipes = { - "r1": {"id": "r1", "title": "One", "modified": 100}, - "r2": {"id": "r2", "title": "Two", "modified": 200}, - } + harness.scanner.cached_raw = [ + {"id": "r1", "title": "One", "modified": 100}, + {"id": "r2", "title": "Two", "modified": 200}, + ] harness.scanner.duplicate_groups_override = {"abc:0.8": ["r1", "r2"]} harness.scanner.duplicate_source_groups_override = {} @@ -2028,12 +2028,12 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys( monkeypatch, tmp_path: Path ) -> None: async with recipe_harness(monkeypatch, tmp_path) as harness: - harness.scanner.recipes = { - "r1": {"id": "r1", "title": "One", "modified": 100}, - "r2": {"id": "r2", "title": "Two", "modified": 200}, - "r3": {"id": "r3", "title": "Three", "modified": 300}, - "r4": {"id": "r4", "title": "Four", "modified": 400}, - } + harness.scanner.cached_raw = [ + {"id": "r1", "title": "One", "modified": 100}, + {"id": "r2", "title": "Two", "modified": 200}, + {"id": "r3", "title": "Three", "modified": 300}, + {"id": "r4", "title": "Four", "modified": 400}, + ] harness.scanner.duplicate_groups_override = {"abc:0.8\x1fa girl": ["r1", "r2"]} harness.scanner.duplicate_source_groups_override = { "civitai.com/images/9": ["r3", "r4"]