fix(recipes): serve duplicate scan from cache and guard against re-entry

This commit is contained in:
Will Miao
2026-08-26 20:34:50 +08:00
parent c52cfc7e7a
commit 3025c64fea
13 changed files with 80 additions and 66 deletions
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "נמצאו {count} קבוצות כפולות",
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
"keepLatest": "שמור גרסאות אחרונות",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "{count} 個の重複グループが見つかりました",
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
"keepLatest": "最新バージョンを保持",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "{count}개의 중복 그룹 발견",
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
"keepLatest": "최신 버전 유지",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "Найдено {count} групп дубликатов",
"noGroups": "Дубликатов с текущим критерием не найдено",
"keepLatest": "Оставить последние версии",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "发现 {count} 个重复组",
"noGroups": "按当前判重依据未找到重复组",
"keepLatest": "保留最新版本",
+1
View File
@@ -963,6 +963,7 @@
}
},
"duplicates": {
"finding": "[TODO: Translate] Scanning for duplicate recipes...",
"found": "發現 {count} 組重複項",
"noGroups": "按目前判重依據未找到重複組",
"keepLatest": "保留最新版本",
+35 -52
View File
@@ -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(
+25 -4
View File
@@ -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();
}
}
+10 -10
View File
@@ -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"]