diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md
index 620801d7..922116e7 100644
--- a/docs/i18n-translation-guidelines.md
+++ b/docs/i18n-translation-guidelines.md
@@ -125,6 +125,14 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> (terminology in §2, "OpenModelDB feature"), so the "no remaining placeholders" claim
> holds again.
+> **Status (2026-10, Civitai ids in model modal):** the model modal's hash footnote now
+> shows the Civitai model id and version id (right-aligned, with copy buttons), adding
+> 4 keys (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId`,
+> `modals.model.actions.copyCivitaiId` / `.civitaiIdCopied`). The same pass removed the
+> search-options "hash" toggle (`header.search.filters.hash`) because hash/id search is
+> now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"),
+> so the "no remaining placeholders" claim holds again.
+
---
## 1. Hard rules (do not violate)
@@ -603,6 +611,24 @@ locale's existing `settings.metadataArchive.enableCivarchiveApi(Help)` phrasing,
|---|---|
| catalogue (the OpenModelDB catalogue) | zh-CN 目录 · zh-TW 目錄 · ja カタログ · ko 카탈로그 · fr catalogue · de Katalog · es catálogo · ru каталог · he קטלוג |
+### Civitai ids feature (model/version id in the model modal)
+
+The model modal's hash footnote shows the Civitai **model id** and **version id** with
+copy buttons (`modals.model.metadata.civitaiModelId` / `.civitaiVersionId` labels,
+`modals.model.actions.copyCivitaiId` tooltip, `.civitaiIdCopied` toast). **"ID" stays
+Latin in every locale** (same precedent as `recipes.*.copyId`), and `Civitai` is the
+brand (R3) — it is never translated or transliterated; the casing mirrors `en.json`
+verbatim (R9). The copy/copied strings reuse each locale's existing clipboard patterns
+(`modals.model.actions.copyHash` / `openFileLocation.copied`).
+
+| Term | Rendering |
+|---|---|
+| Model ID (label) | zh-CN 模型 ID · zh-TW 模型 ID · ja モデル ID · ko 모델 ID · fr ID du modèle · de Modell-ID · es ID del modelo · ru ID модели · he מזהה מודל |
+| Version ID (label) | zh-CN 版本 ID · zh-TW 版本 ID · ja バージョン ID · ko 버전 ID · fr ID de version · de Versions-ID · es ID de versión · ru ID версии · he מזהה גרסה |
+
+Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these
+labels, matching `recipes.*.copyId` (העתק מזהה מתכון).
+
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
diff --git a/locales/de.json b/locales/de.json
index c7bd0e15..950fffa4 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -251,7 +251,6 @@
"modelname": "Modellname",
"tags": "Tags",
"creator": "Ersteller",
- "hash": "Hash",
"title": "Rezept-Titel",
"loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden",
"copyHash": "Hash kopieren",
+ "copyCivitaiId": "Civitai-ID kopieren",
+ "civitaiIdCopied": "Civitai-ID in die Zwischenablage kopiert",
"deleteModelWithShortcut": "Modell löschen (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "Basismodell",
"size": "Größe",
"hashes": "Hashes",
+ "civitaiModelId": "Modell-ID",
+ "civitaiVersionId": "Versions-ID",
"unknown": "Unbekannt",
"usageTips": "Nutzungstipps",
"additionalNotes": "Zusätzliche Notizen",
diff --git a/locales/en.json b/locales/en.json
index 058e83dd..4450155d 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -251,7 +251,6 @@
"modelname": "Model Name",
"tags": "Tags",
"creator": "Creator",
- "hash": "Hash",
"title": "Recipe Title",
"loraName": "LoRA Filename",
"loraModel": "LoRA Model Name",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI",
"copyHash": "Copy hash",
+ "copyCivitaiId": "Copy Civitai ID",
+ "civitaiIdCopied": "Civitai ID copied to clipboard",
"deleteModelWithShortcut": "Delete model (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "Base Model",
"size": "Size",
"hashes": "Hashes",
+ "civitaiModelId": "Model ID",
+ "civitaiVersionId": "Version ID",
"unknown": "Unknown",
"usageTips": "Usage Tips",
"additionalNotes": "Additional Notes",
diff --git a/locales/es.json b/locales/es.json
index 28f6a4ff..3436ddee 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -251,7 +251,6 @@
"modelname": "Nombre del modelo",
"tags": "Etiquetas",
"creator": "Creador",
- "hash": "Hash",
"title": "Título de la receta",
"loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI",
"copyHash": "Copiar hash",
+ "copyCivitaiId": "Copiar ID de Civitai",
+ "civitaiIdCopied": "ID de Civitai copiado al portapapeles",
"deleteModelWithShortcut": "Eliminar modelo (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "Modelo base",
"size": "Tamaño",
"hashes": "Hashes",
+ "civitaiModelId": "ID del modelo",
+ "civitaiVersionId": "ID de versión",
"unknown": "Desconocido",
"usageTips": "Consejos de uso",
"additionalNotes": "Notas adicionales",
diff --git a/locales/fr.json b/locales/fr.json
index 974cbb49..9dcdb70b 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -251,7 +251,6 @@
"modelname": "Nom du modèle",
"tags": "Tags",
"creator": "Créateur",
- "hash": "Hash",
"title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI",
"copyHash": "Copier le hash",
+ "copyCivitaiId": "Copier l’ID Civitai",
+ "civitaiIdCopied": "ID Civitai copié dans le presse-papiers",
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "Modèle de base",
"size": "Taille",
"hashes": "Hashes",
+ "civitaiModelId": "ID du modèle",
+ "civitaiVersionId": "ID de version",
"unknown": "Inconnu",
"usageTips": "Conseils d'utilisation",
"additionalNotes": "Notes supplémentaires",
diff --git a/locales/he.json b/locales/he.json
index a5bec424..59e85aae 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -251,7 +251,6 @@
"modelname": "שם מודל",
"tags": "תגיות",
"creator": "יוצר",
- "hash": "hash",
"title": "כותרת מתכון",
"loraName": "שם קובץ LoRA",
"loraModel": "שם מודל LoRA",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI",
"copyHash": "העתק hash",
+ "copyCivitaiId": "העתק מזהה Civitai",
+ "civitaiIdCopied": "מזהה Civitai הועתק ללוח העריכה",
"deleteModelWithShortcut": "מחק מודל (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "מודל בסיס",
"size": "גודל",
"hashes": "hashes",
+ "civitaiModelId": "מזהה מודל",
+ "civitaiVersionId": "מזהה גרסה",
"unknown": "לא ידוע",
"usageTips": "טיפים לשימוש",
"additionalNotes": "הערות נוספות",
diff --git a/locales/ja.json b/locales/ja.json
index 30eb94fa..df6d00f0 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -251,7 +251,6 @@
"modelname": "モデル名",
"tags": "タグ",
"creator": "作成者",
- "hash": "ハッシュ",
"title": "レシピタイトル",
"loraName": "LoRAファイル名",
"loraModel": "LoRAモデル名",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信",
"copyHash": "ハッシュをコピー",
+ "copyCivitaiId": "Civitai IDをコピー",
+ "civitaiIdCopied": "Civitai IDをクリップボードにコピーしました",
"deleteModelWithShortcut": "モデルを削除(Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "ベースモデル",
"size": "サイズ",
"hashes": "ハッシュ",
+ "civitaiModelId": "モデル ID",
+ "civitaiVersionId": "バージョン ID",
"unknown": "不明",
"usageTips": "使用のヒント",
"additionalNotes": "追加メモ",
diff --git a/locales/ko.json b/locales/ko.json
index 43ef946a..a9f5db33 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -251,7 +251,6 @@
"modelname": "모델명",
"tags": "태그",
"creator": "제작자",
- "hash": "해시",
"title": "레시피 제목",
"loraName": "LoRA 파일명",
"loraModel": "LoRA 모델명",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기",
"copyHash": "해시 복사",
+ "copyCivitaiId": "Civitai ID 복사",
+ "civitaiIdCopied": "Civitai ID가 클립보드에 복사되었습니다",
"deleteModelWithShortcut": "모델 삭제(Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "베이스 모델",
"size": "크기",
"hashes": "해시",
+ "civitaiModelId": "모델 ID",
+ "civitaiVersionId": "버전 ID",
"unknown": "알 수 없음",
"usageTips": "사용 팁",
"additionalNotes": "추가 메모",
diff --git a/locales/ru.json b/locales/ru.json
index fc04363a..992e64e4 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -251,7 +251,6 @@
"modelname": "Название модели",
"tags": "Теги",
"creator": "Автор",
- "hash": "Хэш",
"title": "Название рецепта",
"loraName": "Имя файла LoRA",
"loraModel": "Название модели LoRA",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI",
"copyHash": "Копировать хеш",
+ "copyCivitaiId": "Копировать ID Civitai",
+ "civitaiIdCopied": "ID Civitai скопирован в буфер обмена",
"deleteModelWithShortcut": "Удалить модель (Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "Базовая модель",
"size": "Размер",
"hashes": "Хэши",
+ "civitaiModelId": "ID модели",
+ "civitaiVersionId": "ID версии",
"unknown": "Неизвестно",
"usageTips": "Советы по использованию",
"additionalNotes": "Дополнительные заметки",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index 5815342e..81532243 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -251,7 +251,6 @@
"modelname": "模型名称",
"tags": "标签",
"creator": "创作者",
- "hash": "哈希",
"title": "配方标题",
"loraName": "LoRA 文件名",
"loraModel": "LoRA 模型名称",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI",
"copyHash": "复制哈希值",
+ "copyCivitaiId": "复制 Civitai ID",
+ "civitaiIdCopied": "Civitai ID 已复制到剪贴板",
"deleteModelWithShortcut": "删除模型(Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "基础模型",
"size": "大小",
"hashes": "哈希值",
+ "civitaiModelId": "模型 ID",
+ "civitaiVersionId": "版本 ID",
"unknown": "未知",
"usageTips": "使用提示",
"additionalNotes": "附加备注",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 59de4adf..ca730e0b 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -251,7 +251,6 @@
"modelname": "模型名稱",
"tags": "標籤",
"creator": "創作者",
- "hash": "雜湊",
"title": "配方標題",
"loraName": "LoRA 檔案名稱",
"loraModel": "LoRA 模型名稱",
@@ -1839,6 +1838,8 @@
"sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI",
"copyHash": "複製雜湊值",
+ "copyCivitaiId": "複製 Civitai ID",
+ "civitaiIdCopied": "Civitai ID 已複製到剪貼簿",
"deleteModelWithShortcut": "刪除模型(Del)"
},
"openFileLocation": {
@@ -1857,6 +1858,8 @@
"baseModel": "基礎模型",
"size": "大小",
"hashes": "雜湊值",
+ "civitaiModelId": "模型 ID",
+ "civitaiVersionId": "版本 ID",
"unknown": "未知",
"usageTips": "使用提示",
"additionalNotes": "附加備註",
diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py
index a2287ef7..d2fe867b 100644
--- a/py/routes/handlers/model_handlers.py
+++ b/py/routes/handlers/model_handlers.py
@@ -387,7 +387,6 @@ class ModelListingHandler:
== "true",
"tags": request.query.get("search_tags", "false").lower() == "true",
"creator": request.query.get("search_creator", "false").lower() == "true",
- "hash": request.query.get("search_hash", "false").lower() == "true",
"recursive": request.query.get("recursive", "true").lower() == "true",
}
diff --git a/py/services/model_lifecycle_service.py b/py/services/model_lifecycle_service.py
index f5c8fb4b..b6a3fb4c 100644
--- a/py/services/model_lifecycle_service.py
+++ b/py/services/model_lifecycle_service.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+import asyncio
import json
import logging
import os
-from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
+from contextlib import asynccontextmanager
+from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
from ..services.service_registry import ServiceRegistry
from ..services.pending_delete_service import get_pending_delete_service
@@ -107,6 +109,36 @@ def _require_path_in_library_roots(file_path: str, scanner, *, label: str = "pat
)
+class BulkRenameContext:
+ """Per-session state threaded through ``rename_model`` calls of a bulk rename.
+
+ Holds the lazily built recipe hash index so a bulk rename loop pays the
+ O(recipes) index build at most once (on the first recipe-touching rename)
+ instead of rescanning every recipe per renamed file. Also tracks whether
+ any recipe was re-pointed so the session finalizes recipe maintenance only
+ when needed.
+ """
+
+ def __init__(self, recipe_scanner: Any) -> None:
+ self._recipe_scanner = recipe_scanner
+ self._recipe_hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None
+ self.recipes_touched = False
+
+ @property
+ def recipe_scanner(self) -> Any:
+ return self._recipe_scanner
+
+ async def get_recipe_hash_index(self) -> Optional[Dict[str, List[Dict[str, Any]]]]:
+ """Return the lora-hash → recipes index, building it on first use."""
+ if self._recipe_scanner is None:
+ return None
+ if self._recipe_hash_index is None:
+ self._recipe_hash_index = (
+ await self._recipe_scanner.build_lora_hash_index()
+ )
+ return self._recipe_hash_index
+
+
class ModelLifecycleService:
"""Co-ordinate destructive and mutating model operations."""
@@ -365,10 +397,45 @@ class ModelLifecycleService:
return await self._scanner.bulk_delete_models(file_paths)
+ @asynccontextmanager
+ async def bulk_rename_session(self) -> AsyncIterator[BulkRenameContext]:
+ """Context for bulk rename loops (filename-template "Apply to Library").
+
+ While active, the per-file ``update_single_model_cache`` resort/persist
+ chain and the per-file recipe folder-metadata refresh/resort are
+ deferred; both run exactly once when the outermost session exits — see
+ ``ModelScanner.defer_cache_persist`` and
+ ``RecipeScanner.finalize_bulk_filename_updates``. The finalize steps run
+ even on cancellation or mid-loop errors, because files are already
+ renamed on disk and the caches must not be left diverging.
+
+ Yields a :class:`BulkRenameContext` to pass as ``bulk_context`` into
+ each ``rename_model`` call of the loop.
+ """
+ recipe_scanner = await self._recipe_scanner_factory()
+ context = BulkRenameContext(recipe_scanner)
+ async with self._scanner.defer_cache_persist():
+ try:
+ yield context
+ finally:
+ if recipe_scanner is not None and context.recipes_touched:
+ try:
+ await recipe_scanner.finalize_bulk_filename_updates()
+ except Exception as exc: # pragma: no cover - defensive logging
+ logger.error(
+ "Error finalizing bulk recipe updates: %s", exc
+ )
+
async def rename_model(
- self, *, file_path: str, new_file_name: str
+ self, *, file_path: str, new_file_name: str, bulk_context: Optional[BulkRenameContext] = None
) -> Dict[str, object]:
- """Rename a model and its companion artefacts."""
+ """Rename a model and its companion artefacts.
+
+ When ``bulk_context`` is given (bulk rename loop), the recipe
+ re-pointing uses the session's prebuilt hash index and defers recipe
+ maintenance to the session finalize; the scanner cache persist is
+ likewise deferred by the surrounding ``bulk_rename_session``.
+ """
if not file_path or not new_file_name:
raise ValueError("File path and new file name are required")
@@ -419,20 +486,11 @@ class ModelLifecycleService:
raw_hash = metadata.get("sha256") if isinstance(metadata, dict) else None
hash_value = raw_hash if isinstance(raw_hash, str) else None
- renamed_files: List[str] = []
- new_metadata_path: Optional[str] = None
new_preview: Optional[str] = None
- for old_path, pattern in existing_files:
- ext = self._get_multipart_ext(pattern)
- new_path = os.path.join(
- os.path.dirname(old_path), f"{new_file_name}{ext}"
- ).replace(os.sep, "/")
- os.rename(old_path, new_path)
- renamed_files.append(new_path)
-
- if ext == ".metadata.json":
- new_metadata_path = new_path
+ renamed_files, new_metadata_path = await asyncio.to_thread(
+ self._rename_companion_files, existing_files, new_file_name
+ )
if metadata and new_metadata_path:
metadata["file_name"] = new_file_name
@@ -457,12 +515,26 @@ class ModelLifecycleService:
)
if hash_value and getattr(self._scanner, "model_type", "") == "lora":
- recipe_scanner = await self._recipe_scanner_factory()
+ if bulk_context is not None:
+ recipe_scanner = bulk_context.recipe_scanner
+ hash_index = await bulk_context.get_recipe_hash_index()
+ defer_maintenance = True
+ else:
+ recipe_scanner = await self._recipe_scanner_factory()
+ hash_index = None
+ defer_maintenance = False
if recipe_scanner:
try:
- await recipe_scanner.update_lora_filename_by_hash(
- hash_value, new_file_name
+ file_count, cache_count = (
+ await recipe_scanner.update_lora_filename_by_hash(
+ hash_value,
+ new_file_name,
+ hash_index=hash_index,
+ defer_maintenance=defer_maintenance,
+ )
)
+ if bulk_context is not None and (file_count or cache_count):
+ bulk_context.recipes_touched = True
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error updating recipe references for %s: %s",
@@ -478,6 +550,34 @@ class ModelLifecycleService:
"reload_required": False,
}
+ def _rename_companion_files(
+ self,
+ existing_files: List[tuple[str, str]],
+ new_file_name: str,
+ ) -> tuple[List[str], Optional[str]]:
+ """Rename all companion files, off the event loop thread.
+
+ Runs the blocking ``os.rename`` sequence for one model in a worker
+ thread so a single file's HDD I/O does not stall the event loop.
+ Never parallelized across files: one model's renames stay sequential
+ and the helper holds no locks.
+ """
+ renamed_files: List[str] = []
+ new_metadata_path: Optional[str] = None
+
+ for old_path, pattern in existing_files:
+ ext = self._get_multipart_ext(pattern)
+ new_path = os.path.join(
+ os.path.dirname(old_path), f"{new_file_name}{ext}"
+ ).replace(os.sep, "/")
+ os.rename(old_path, new_path)
+ renamed_files.append(new_path)
+
+ if ext == ".metadata.json":
+ new_metadata_path = new_path
+
+ return renamed_files, new_metadata_path
+
@staticmethod
def _get_multipart_ext(filename: str) -> str:
"""Return the extension for files with compound suffixes."""
diff --git a/py/services/model_query.py b/py/services/model_query.py
index 29d5c544..a15ea05e 100644
--- a/py/services/model_query.py
+++ b/py/services/model_query.py
@@ -432,7 +432,6 @@ class SearchStrategy:
"tags": False,
"recursive": True,
"creator": False,
- "hash": False,
}
def __init__(
@@ -495,13 +494,14 @@ class SearchStrategy:
results.append(item)
continue
- # Hash search is always exact (never fuzzy): match the full
- # sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
- if options.get("hash", False):
- hash_query = search_lower.strip()
- if hash_query and self._matches_hash(item, hash_query):
- results.append(item)
- continue
+ # Hash/id search is always exact (never fuzzy) and always on: it
+ # matches the full sha256, its autov2 prefix (first 10 chars),
+ # the autov3 hash, or the Civitai model/version ids. Exact-match
+ # semantics mean it adds no noise to ordinary keyword searches.
+ hash_query = search_lower.strip()
+ if hash_query and self._matches_hash(item, hash_query):
+ results.append(item)
+ continue
return results
@@ -515,6 +515,18 @@ class SearchStrategy:
autov3 = item.get("autov3")
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
return True
+ civitai = item.get("civitai")
+ if isinstance(civitai, dict):
+ # A model card corresponds to one Civitai version: `modelId` is
+ # the model id (may match several cards when the library holds
+ # multiple versions), `id` is the version id (unique per card).
+ for key in ("modelId", "model_id", "id"):
+ value = civitai.get(key)
+ if value is None:
+ continue
+ value_str = str(value).strip()
+ if value_str and value_str != "0" and hash_query == value_str:
+ return True
return False
def _matches(
diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py
index bf5d430e..7724e1cb 100644
--- a/py/services/model_scanner.py
+++ b/py/services/model_scanner.py
@@ -4,6 +4,7 @@ import logging
import asyncio
import time
import shutil
+from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
@@ -162,6 +163,13 @@ class ModelScanner:
self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
self._move_locks: Dict[str, asyncio.Lock] = {} # Per-source-file move locks
+ # Bulk-operation deferral: while _defer_persist_depth > 0,
+ # update_single_model_cache() skips the per-call resort/persist and
+ # only marks _deferred_persist_pending; the exit of the outermost
+ # defer_cache_persist() context finalizes once (see
+ # _finalize_deferred_cache_persist).
+ self._defer_persist_depth = 0
+ self._deferred_persist_pending = False
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
# Guard against concurrent all-folders backfill walks (cold fallback
# for persisted snapshots that predate folder recording).
@@ -773,11 +781,11 @@ class ModelScanner:
except Exception as exc:
logger.warning("AutoV3 backfill failed: %s", exc)
- async def _save_persistent_cache(self, scan_result: CacheBuildResult) -> None:
+ async def _save_persistent_cache(self, scan_result: CacheBuildResult, *, force: bool = False) -> None:
if not scan_result or not getattr(self, '_persistent_cache', None):
return
- if self.is_cancelled():
+ if self.is_cancelled() and not force:
logger.info(
f"{self.model_type.capitalize()} Scanner: Skipping _save_persistent_cache "
"after cancellation"
@@ -836,7 +844,7 @@ class ModelScanner:
bucket.append(path)
return snapshot
- async def _persist_current_cache(self) -> None:
+ async def _persist_current_cache(self, *, force: bool = False) -> None:
if self._cache is None or not getattr(self, '_persistent_cache', None):
return
@@ -851,7 +859,7 @@ class ModelScanner:
else None
),
)
- await self._save_persistent_cache(snapshot)
+ await self._save_persistent_cache(snapshot, force=force)
await self._sync_download_history(snapshot.raw_data, source='scan')
def _count_model_files(self) -> int:
"""Count all model files with supported extensions in all roots
@@ -2646,11 +2654,85 @@ class ModelScanner:
logger.error(f"Error updating metadata paths: {e}", exc_info=True)
return None
+ @asynccontextmanager
+ async def defer_cache_persist(self):
+ """Defer heavyweight cache maintenance for a bulk operation.
+
+ While at least one ``defer_cache_persist`` context is active,
+ :meth:`update_single_model_cache` performs only the in-memory entry
+ swap plus incremental index updates — it skips the full version-index
+ rebuild, the natsort resort, and the whole-table SQLite persist plus
+ download-history sync that normally run per call. When the outermost
+ context exits, the pending maintenance runs **once** (resort, persist,
+ download-history sync).
+
+ The final persist is forced: it runs even when the scanner's
+ cancellation flag is set or the wrapped block raised, because callers
+ use this around operations that already mutated files on disk and the
+ cache must not be left diverging from reality.
+
+ Intended for bulk rename/move loops (e.g. the filename-template "Apply
+ to Library" flow). Single-shot callers keep the immediate per-call
+ behavior by not entering this context.
+ """
+ self._defer_persist_depth = getattr(self, "_defer_persist_depth", 0) + 1
+ try:
+ yield
+ finally:
+ self._defer_persist_depth -= 1
+ if self._defer_persist_depth == 0:
+ await self._finalize_deferred_cache_persist()
+
+ @property
+ def _cache_persist_deferred(self) -> bool:
+ """True while cache resort/persist is deferred to a bulk finalize."""
+ return getattr(self, "_defer_persist_depth", 0) > 0
+
+ async def _finalize_deferred_cache_persist(self) -> None:
+ """Run the resort + persist deferred by ``defer_cache_persist``.
+
+ Best-effort: failures are logged, never raised, so an error here
+ cannot mask the outcome of the bulk operation itself (including
+ cancellation).
+ """
+ if not getattr(self, "_deferred_persist_pending", False):
+ return
+ self._deferred_persist_pending = False
+ if self._cache is None:
+ return
+ try:
+ # resort() rebuilds the version index and folder list, so the
+ # per-call rebuilds skipped during deferral are covered here.
+ await self._cache.resort()
+ await self._persist_current_cache(force=True)
+ self.bump_cache_version()
+ except Exception:
+ logger.error(
+ "%s Scanner: failed to finalize deferred cache persist",
+ self.model_type.capitalize(),
+ exc_info=True,
+ )
+
async def update_single_model_cache(self, original_path: str, new_path: str, metadata: Optional[Dict[str, Any]], recalculate_type: bool = False) -> Union[bool, Dict[str, Any]]:
- """Update cache after a model has been moved or modified"""
+ """Update cache after a model has been moved or modified.
+
+ Performs the full maintenance chain (version-index rebuild, resort,
+ whole-table persist, download-history sync) unless the scanner is
+ inside a :meth:`defer_cache_persist` context, in which case only
+ the in-memory entry swap and incremental index updates run and the
+ heavy chain executes once at context exit.
+ """
+ deferred = self._cache_persist_deferred
cache = await self.get_cached_data()
- existing_item = next((item for item in cache.raw_data if item['file_path'] == original_path), None)
+ existing_index: Optional[int] = None
+ existing_item = None
+ for idx, item in enumerate(cache.raw_data):
+ if item['file_path'] == original_path:
+ existing_item = item
+ existing_index = idx
+ break
+
if existing_item:
cache.remove_from_version_index(existing_item)
@@ -2662,11 +2744,18 @@ class ModelScanner:
del self._tags_count[tag]
self._hash_index.remove_by_path(original_path)
-
- cache.raw_data = [
- item for item in cache.raw_data
- if item['file_path'] != original_path
- ]
+
+ if deferred:
+ # In-place swap avoids the O(n) list rebuild per renamed file;
+ # indexes were already updated incrementally above/below, and the
+ # folder recompute happens in the single finalize resort().
+ if existing_index is not None:
+ cache.raw_data.pop(existing_index)
+ else:
+ cache.raw_data = [
+ item for item in cache.raw_data
+ if item['file_path'] != original_path
+ ]
cache_modified = bool(existing_item) or bool(metadata)
cache_entry: Optional[Dict[str, Any]] = None
@@ -2707,8 +2796,11 @@ class ModelScanner:
cache_entry.get('autov3') or None,
)
- all_folders = set(item['folder'] for item in cache.raw_data)
- cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
+ if not deferred:
+ # O(n) over raw_data; the finalize resort() recomputes the
+ # folder list once, so bulk callers skip it per file.
+ all_folders = set(item['folder'] for item in cache.raw_data)
+ cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# The move target may live in directories the last scan never saw;
# record the destination folder (and its parents) in the known
@@ -2723,13 +2815,18 @@ class ModelScanner:
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
- cache.rebuild_version_index()
+ if deferred:
+ if cache_modified:
+ self._deferred_persist_pending = True
+ self.bump_cache_version()
+ else:
+ cache.rebuild_version_index()
- await cache.resort()
+ await cache.resort()
- if cache_modified:
- await self._persist_current_cache()
- self.bump_cache_version()
+ if cache_modified:
+ await self._persist_current_cache()
+ self.bump_cache_version()
if metadata and cache_entry is not None:
return cache_entry
diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py
index 13e3650e..9d118102 100644
--- a/py/services/recipe_scanner.py
+++ b/py/services/recipe_scanner.py
@@ -4580,14 +4580,64 @@ class RecipeScanner:
return syntax_parts
+ async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
+ """Build a one-shot lowercase-LoRA-hash → recipes index.
+
+ Scans the recipe cache exactly once (O(recipes × loras)) and returns
+ a mapping of lowercase lora ``hash`` to the list of recipe dicts
+ containing it. Bulk rename loops pass this index to
+ :meth:`update_lora_filename_by_hash` so per-file lookups are O(1)
+ instead of rescanning every recipe for each renamed LoRA.
+ """
+ cache = await self.get_cached_data()
+ index: Dict[str, List[Dict[str, Any]]] = {}
+ if not cache or not cache.raw_data:
+ return index
+
+ for recipe in cache.raw_data:
+ loras = recipe.get("loras", [])
+ if not isinstance(loras, list):
+ continue
+ for lora in loras:
+ if not isinstance(lora, dict):
+ continue
+ hash_value = (lora.get("hash") or "").lower()
+ if hash_value:
+ index.setdefault(hash_value, []).append(recipe)
+ return index
+
+ async def finalize_bulk_filename_updates(self) -> None:
+ """Run once after a bulk rename session that deferred maintenance.
+
+ Refreshes folder metadata and schedules a single re-sort. Filename-only
+ renames never change recipe folders, so the deferred refresh is
+ redundant but cheap; skipping it per file is what makes bulk renames
+ O(1)-per-file.
+ """
+ if self._cache is None:
+ return
+ self._schedule_resort()
+
async def update_lora_filename_by_hash(
- self, hash_value: str, new_file_name: str
+ self,
+ hash_value: str,
+ new_file_name: str,
+ *,
+ hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
+ defer_maintenance: bool = False,
) -> Tuple[int, int]:
"""Update file_name in all recipes that contain a LoRA with the specified hash.
Args:
hash_value: The SHA256 hash value of the LoRA
new_file_name: The new file_name to set
+ hash_index: Optional prebuilt index from
+ :meth:`build_lora_hash_index`. When given, the O(recipes)
+ cache scan (and its folder-metadata walk) is skipped and the
+ affected recipes are looked up directly — the bulk rename path.
+ defer_maintenance: When True, skip the folder-metadata refresh and
+ resort scheduling. The caller MUST run
+ :meth:`finalize_bulk_filename_updates` exactly once afterwards.
Returns:
Tuple[int, int]: (number of recipes updated in files, number of recipes updated in cache)
@@ -4598,17 +4648,21 @@ class RecipeScanner:
# Always use lowercase hash for consistency
hash_value = hash_value.lower()
- # Get cache
- cache = await self.get_cached_data()
- if not cache or not cache.raw_data:
- return 0, 0
+ if hash_index is not None:
+ candidate_recipes = hash_index.get(hash_value, [])
+ else:
+ # Get cache
+ cache = await self.get_cached_data()
+ if not cache or not cache.raw_data:
+ return 0, 0
+ candidate_recipes = cache.raw_data
file_updated_count = 0
cache_updated_count = 0
- # Find recipes that need updating from the cache
+ # Find recipes that need updating
recipes_to_update = []
- for recipe in cache.raw_data:
+ for recipe in candidate_recipes:
loras = recipe.get("loras", [])
if not isinstance(loras, list):
continue
@@ -4654,7 +4708,9 @@ class RecipeScanner:
# We don't necessarily need to resort because LoRA file_name isn't a sort key,
# but we might want to schedule a resort if we're paranoid or if searching relies on sorted state.
# Given it's a rename of a dependency, search results might change if searching by LoRA name.
- self._schedule_resort()
+ # Bulk callers defer this to a single finalize_bulk_filename_updates() call.
+ if not defer_maintenance:
+ self._schedule_resort()
return file_updated_count, cache_updated_count
diff --git a/py/services/use_cases/filename_template_use_case.py b/py/services/use_cases/filename_template_use_case.py
index e569d681..659e7c61 100644
--- a/py/services/use_cases/filename_template_use_case.py
+++ b/py/services/use_cases/filename_template_use_case.py
@@ -33,7 +33,9 @@ class FilenameTemplateUseCase:
An empty template restores the recorded original filename instead of
rendering a template. Shares the auto-organize lock (and its in-progress
error) so a bulk rename never runs concurrently with an auto-organize
- operation.
+ operation. The whole loop runs inside a bulk rename session so cache
+ persist/resort and recipe maintenance happen once at the end instead of
+ per renamed file.
"""
def __init__(
@@ -106,23 +108,24 @@ class FilenameTemplateUseCase:
await self._emit_progress(progress_callback, result, "started")
- for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
- if self._scanner.is_cancelled():
- logger.info(
- "Filename template apply cancelled for %s", self._model_type
- )
- break
-
- batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
- for model in batch:
+ async with self._lifecycle_service.bulk_rename_session() as bulk_context:
+ for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self._scanner.is_cancelled():
+ logger.info(
+ "Filename template apply cancelled for %s", self._model_type
+ )
break
- await self._process_model(model, template, result)
- result.processed += 1
- await self._emit_progress(progress_callback, result, "processing")
- # Yield between batches so the server stays responsive.
- await asyncio.sleep(0.1)
+ batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
+ for model in batch:
+ if self._scanner.is_cancelled():
+ break
+ await self._process_model(model, template, result, bulk_context)
+ result.processed += 1
+
+ await self._emit_progress(progress_callback, result, "processing")
+ # Yield between batches so the server stays responsive.
+ await asyncio.sleep(0.1)
if self._scanner.is_cancelled():
result.status = "cancelled"
@@ -150,6 +153,7 @@ class FilenameTemplateUseCase:
model: Dict[str, Any],
template: str,
result: AutoOrganizeResult,
+ bulk_context: Any = None,
) -> None:
model_name = model.get("model_name", "Unknown")
try:
@@ -177,7 +181,7 @@ class FilenameTemplateUseCase:
return
await self._lifecycle_service.rename_model(
- file_path=file_path, new_file_name=new_stem
+ file_path=file_path, new_file_name=new_stem, bulk_context=bulk_context
)
result.success_count += 1
diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css
index d5fcca7e..4604fea2 100644
--- a/static/css/components/lora-modal/lora-modal.css
+++ b/static/css/components/lora-modal/lora-modal.css
@@ -292,6 +292,15 @@
font-size: 0.8em;
}
+/* Civitai model/version ids — pushed to the right end of the footnote line */
+.hash-footnote .civitai-id-group {
+ display: inline-flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 4px 8px;
+ margin-left: auto;
+}
+
.hash-footnote .hash-copy-btn {
display: inline-flex;
align-items: center;
diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js
index 28aa6867..5030869d 100644
--- a/static/js/api/baseModelApi.js
+++ b/static/js/api/baseModelApi.js
@@ -1566,9 +1566,6 @@ export class BaseModelApiClient {
if (pageState.searchOptions.creator !== undefined) {
params.append('search_creator', pageState.searchOptions.creator.toString());
}
- if (pageState.searchOptions.hash !== undefined) {
- params.append('search_hash', pageState.searchOptions.hash.toString());
- }
}
}
diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js
index 1cec9af8..9e48ebea 100644
--- a/static/js/components/shared/ModelModal.js
+++ b/static/js/components/shared/ModelModal.js
@@ -367,7 +367,9 @@ export async function showModelModal(model, modelType) {
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
// De-emphasized hash display: a borderless full-width footnote line below
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
- // full (12 chars); the full value is copied via data-hash.
+ // full (12 chars); the full value is copied via data-hash. Civitai model /
+ // version ids join the same line on the right when the model comes from
+ // the Civitai ecosystem (a model card corresponds to one Civitai version).
const modelSha256 = modelWithFullData.sha256 || '';
const modelAutov3 = modelWithFullData.autov3 || '';
const truncatedSha256 = modelSha256.length > 16
@@ -395,8 +397,47 @@ export async function showModelModal(model, modelType) {
`);
}
- const hashesMarkup = modelSha256 && hashEntries.length ? `
-
diff --git a/tests/conftest.py b/tests/conftest.py
index d4c44e5f..04238b74 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,9 +3,10 @@ import importlib.util
import inspect
import sys
import types
+from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from pathlib import Path
-from typing import Any, Dict, List, Optional, Sequence
+from typing import Any, AsyncIterator, Dict, List, Optional, Sequence
from unittest import mock
import pytest
@@ -177,6 +178,10 @@ class MockScanner:
def reset_cancellation(self) -> None:
self._cancelled = False
+ @asynccontextmanager
+ async def defer_cache_persist(self) -> AsyncIterator[None]:
+ yield None
+
async def get_cached_data(self, force_refresh: bool = False):
return self._cache
diff --git a/tests/frontend/api/baseModelApi.searchParams.test.js b/tests/frontend/api/baseModelApi.searchParams.test.js
deleted file mode 100644
index f1977d1f..00000000
--- a/tests/frontend/api/baseModelApi.searchParams.test.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-
-const {
- BASE_MODEL_API_MODULE,
- STATE_MODULE,
- UI_HELPERS_MODULE,
- I18N_MODULE,
- STORAGE_MODULE,
- API_CONFIG_MODULE,
- API_FACTORY_MODULE,
- SIDEBAR_MANAGER_MODULE,
-} = vi.hoisted(() => ({
- BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
- STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
- UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
- I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
- STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
- API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
- API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
- SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
-}));
-
-vi.mock(STATE_MODULE, () => ({
- state: {
- global: { settings: {} },
- },
- getCurrentPageState: vi.fn(() => ({})),
-}));
-
-vi.mock(UI_HELPERS_MODULE, () => ({
- showToast: vi.fn(),
-}));
-
-vi.mock(I18N_MODULE, () => ({
- translate: vi.fn((key) => key),
-}));
-
-vi.mock(STORAGE_MODULE, () => ({
- getStorageItem: vi.fn(),
- getSessionItem: vi.fn(() => null),
- removeSessionItem: vi.fn(),
- saveMapToStorage: vi.fn(),
-}));
-
-vi.mock(API_CONFIG_MODULE, () => ({
- getCompleteApiConfig: vi.fn(() => ({
- endpoints: {},
- config: { displayName: 'LoRA', singularName: 'LoRA', supportsLetterFilter: false },
- })),
- getCurrentModelType: vi.fn(() => 'loras'),
- isValidModelType: vi.fn(() => true),
- DOWNLOAD_ENDPOINTS: {},
- HF_ENDPOINTS: {},
- WS_ENDPOINTS: {},
-}));
-
-vi.mock(API_FACTORY_MODULE, () => ({
- resetAndReload: vi.fn(),
-}));
-
-vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
- sidebarManager: { refresh: vi.fn() },
-}));
-
-async function createClient() {
- const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
- class TestClient extends BaseModelApiClient {}
- return new TestClient('loras');
-}
-
-function makePageState(searchOptions) {
- return {
- viewMode: 'active',
- activeFolder: null,
- showFavoritesOnly: false,
- showUpdateAvailableOnly: false,
- filters: { search: 'abc123' },
- searchOptions: {
- filename: true,
- modelname: true,
- tags: false,
- creator: false,
- recursive: true,
- ...searchOptions,
- },
- };
-}
-
-describe('BaseModelApiClient._buildQueryParams hash search option', () => {
- it('appends search_hash=true when the hash option is enabled', async () => {
- const client = await createClient();
- const params = client._buildQueryParams({}, makePageState({ hash: true }));
-
- expect(params.get('search_hash')).toBe('true');
- expect(params.get('search')).toBe('abc123');
- });
-
- it('appends search_hash=false when the hash option is disabled', async () => {
- const client = await createClient();
- const params = client._buildQueryParams({}, makePageState({ hash: false }));
-
- expect(params.get('search_hash')).toBe('false');
- });
-
- it('omits search_hash when the option is absent (backend defaults to false)', async () => {
- const client = await createClient();
- const params = client._buildQueryParams({}, makePageState({}));
-
- expect(params.get('search_hash')).toBeNull();
- });
-
- it('does not send search_hash without an active search term', async () => {
- const client = await createClient();
- const pageState = makePageState({ hash: true });
- pageState.filters.search = '';
- const params = client._buildQueryParams({}, pageState);
-
- expect(params.get('search_hash')).toBeNull();
- });
-});
diff --git a/tests/frontend/components/modelModal.hashes.test.js b/tests/frontend/components/modelModal.hashes.test.js
index cb4ce66b..85d70430 100644
--- a/tests/frontend/components/modelModal.hashes.test.js
+++ b/tests/frontend/components/modelModal.hashes.test.js
@@ -165,12 +165,76 @@ describe('Model modal hash rendering', () => {
expect(rows[0].querySelector('.hash-kind').textContent).toBe('SHA256');
});
- it('hides the hashes item entirely when sha256 is empty', async () => {
+ it('renders autov3 alone when sha256 is empty', async () => {
await renderModal(makeModel({ sha256: '', autov3: AUTOV3 }));
+ const rows = document.querySelectorAll('.hash-footnote .hash-entry');
+ expect(rows).toHaveLength(1);
+ expect(rows[0].querySelector('.hash-kind').textContent).toBe('AutoV3');
+ });
+
+ it('hides the footnote entirely when there are no hashes and no civitai ids', async () => {
+ await renderModal(makeModel({ sha256: '', autov3: '', civitai: {} }));
+
expect(document.querySelector('.hash-footnote')).toBeNull();
});
+ it('renders civitai model and version ids with copy buttons', async () => {
+ await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } }));
+
+ const group = document.querySelector('.hash-footnote .civitai-id-group');
+ expect(group).not.toBeNull();
+
+ const entries = group.querySelectorAll('.civitai-id-entry');
+ expect(entries).toHaveLength(2);
+
+ expect(entries[0].querySelector('.hash-kind').textContent).toBe('Model ID');
+ expect(entries[0].querySelector('.model-hash-value').textContent).toBe('12345');
+ expect(entries[0].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('12345');
+
+ expect(entries[1].querySelector('.hash-kind').textContent).toBe('Version ID');
+ expect(entries[1].querySelector('.model-hash-value').textContent).toBe('67890');
+ expect(entries[1].querySelector('[data-action="copy-hash"]').dataset.hash).toBe('67890');
+ });
+
+ it('hides civitai ids for models without civitai data', async () => {
+ await renderModal(makeModel({ civitai: {} }));
+
+ expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull();
+ });
+
+ it.each([0, '0', null, undefined, ''])('treats civitai id %s as missing', async (missingId) => {
+ await renderModal(makeModel({ civitai: { modelId: missingId, id: missingId } }));
+
+ expect(document.querySelector('.hash-footnote .civitai-id-group')).toBeNull();
+ });
+
+ it('falls back to snake_case model_id', async () => {
+ await renderModal(makeModel({ civitai: { model_id: 777, id: 888 } }));
+
+ const group = document.querySelector('.hash-footnote .civitai-id-group');
+ const entries = group.querySelectorAll('.civitai-id-entry');
+ expect(entries[0].querySelector('.model-hash-value').textContent).toBe('777');
+ expect(entries[1].querySelector('.model-hash-value').textContent).toBe('888');
+ });
+
+ it('still renders the footnote when only civitai ids exist', async () => {
+ await renderModal(makeModel({ sha256: '', autov3: '', civitai: { modelId: 12345, id: 67890 } }));
+
+ const footnote = document.querySelector('.hash-footnote');
+ expect(footnote).not.toBeNull();
+ expect(footnote.querySelectorAll('.civitai-id-entry')).toHaveLength(2);
+ });
+
+ it('copies the version id with the civitai toast when its copy button is clicked', async () => {
+ await renderModal(makeModel({ civitai: { modelId: 12345, id: 67890 } }));
+
+ const entries = document.querySelectorAll('.civitai-id-entry [data-action="copy-hash"]');
+ entries[1].click();
+
+ expect(copyToClipboard).toHaveBeenCalledWith('67890', 'Civitai ID copied to clipboard');
+ });
+
it('copies the full hash when the copy button is clicked', async () => {
await renderModal(makeModel());
diff --git a/tests/services/test_bulk_rename_apply.py b/tests/services/test_bulk_rename_apply.py
new file mode 100644
index 00000000..3f0d94e0
--- /dev/null
+++ b/tests/services/test_bulk_rename_apply.py
@@ -0,0 +1,639 @@
+"""Performance-regression tests for the bulk filename-template rename path.
+
+The bulk "Apply to Library" rename must be O(1)-per-file: the heavyweight
+cache persist/resort chain and the recipe maintenance run exactly once per
+bulk operation (even on cancellation or mid-loop errors), while single-shot
+renames keep their immediate per-call behavior.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+from contextlib import asynccontextmanager
+from pathlib import Path
+from typing import Any, AsyncIterator, Dict, List, Optional
+
+import pytest
+
+from py.services import model_scanner as model_scanner_module
+from py.services.model_cache import ModelCache
+from py.services.model_lifecycle_service import ModelLifecycleService
+from py.services.model_scanner import ModelScanner
+from py.services.settings_manager import get_settings_manager
+from py.services.use_cases.filename_template_use_case import FilenameTemplateUseCase
+from py.utils.models import BaseModelMetadata
+
+
+class BulkDummyScanner(ModelScanner):
+ """Minimal concrete scanner for cache-behavior tests."""
+
+ def __init__(self, root: Path):
+ self._root = str(root)
+ super().__init__(
+ model_type="dummy",
+ model_class=BaseModelMetadata,
+ file_extensions={".txt"},
+ )
+
+ def get_model_roots(self) -> List[str]:
+ return [self._root]
+
+
+@pytest.fixture(autouse=True)
+def _reset_model_scanner_singletons():
+ ModelScanner._instances.clear()
+ ModelScanner._locks.clear()
+ yield
+ ModelScanner._instances.clear()
+ ModelScanner._locks.clear()
+
+
+@pytest.fixture(autouse=True)
+def _disable_persistent_cache_env(monkeypatch: pytest.MonkeyPatch):
+ monkeypatch.setenv("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "1")
+
+
+@pytest.fixture(autouse=True)
+def _stub_register_service(monkeypatch: pytest.MonkeyPatch):
+ async def noop(*_args: Any, **_kwargs: Any) -> None:
+ return None
+
+ monkeypatch.setattr(
+ model_scanner_module.ServiceRegistry, "register_service", noop
+ )
+
+
+def _metadata(stem: str, path: str, sha256: str, civitai_id: int = 1) -> Dict[str, Any]:
+ return {
+ "file_name": stem,
+ "file_path": path,
+ "model_name": stem,
+ "sha256": sha256,
+ "folder": "",
+ "tags": [],
+ "size": 1,
+ "modified": 1.0,
+ "civitai": {"id": civitai_id, "modelId": 2},
+ }
+
+
+class _SpiedCache:
+ """Scanner cache with call counters for resort/persist/sync."""
+
+ def __init__(self, scanner: BulkDummyScanner, entries: List[Dict[str, Any]]):
+ self.cache = ModelCache(raw_data=[dict(e) for e in entries], folders=[])
+ scanner._cache = self.cache
+ self.resort_calls = 0
+ self.save_calls: List[bool] = []
+ self.sync_calls = 0
+ self._original_resort = self.cache.resort
+
+ async def resort(self) -> None:
+ self.resort_calls += 1
+ await self._original_resort()
+
+ async def fake_save(self, scan_result: Any, *, force: bool = False) -> None:
+ self.save_calls.append(force)
+
+ async def fake_sync(self, raw_data: Any, *, source: str) -> None:
+ self.sync_calls += 1
+
+
+async def _make_spied_scanner(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, entries: List[Dict[str, Any]]
+) -> tuple[BulkDummyScanner, _SpiedCache]:
+ scanner = BulkDummyScanner(tmp_path)
+ spied = _SpiedCache(scanner, entries)
+ # Truthy stand-in so _persist_current_cache() does not early-return.
+ scanner._persistent_cache = object()
+ monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
+ monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
+ # Flush the resort task scheduled by ModelCache.__post_init__ before
+ # installing the counting wrapper.
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ monkeypatch.setattr(spied.cache, "resort", spied.resort)
+ return scanner, spied
+
+
+# ---------------------------------------------------------------------------
+# ModelScanner deferred persist
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_update_single_model_cache_persists_immediately_by_default(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ path_a = (tmp_path / "a.txt").as_posix()
+ scanner, spied = await _make_spied_scanner(
+ tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
+ )
+
+ await scanner.update_single_model_cache(
+ path_a, path_a, _metadata("a", path_a, "hash-a")
+ )
+
+ assert spied.save_calls == [False]
+ assert spied.sync_calls == 1
+ assert spied.resort_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_deferred_cache_persist_persists_once_for_many_updates(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ entries = []
+ for index, stem in enumerate(("a", "b", "c")):
+ path = (tmp_path / f"{stem}.txt").as_posix()
+ entries.append(_metadata(stem, path, f"hash-{stem}", civitai_id=index + 1))
+ scanner, spied = await _make_spied_scanner(tmp_path, monkeypatch, entries)
+
+ async with scanner.defer_cache_persist():
+ for index, stem in enumerate(("a", "b", "c")):
+ old_path = (tmp_path / f"{stem}.txt").as_posix()
+ new_path = (tmp_path / f"{stem}-renamed.txt").as_posix()
+ await scanner.update_single_model_cache(
+ old_path,
+ new_path,
+ _metadata(
+ f"{stem}-renamed", new_path, f"hash-{stem}", civitai_id=index + 1
+ ),
+ )
+ # Nothing heavy may run mid-loop.
+ assert spied.save_calls == []
+ assert spied.sync_calls == 0
+ assert spied.resort_calls == 0
+
+ # Exactly one heavyweight finalize for the whole bulk operation.
+ assert spied.save_calls == [True]
+ assert spied.sync_calls == 1
+ assert spied.resort_calls == 1
+
+ # In-memory state is correct, including the incremental version index.
+ cache = await scanner.get_cached_data()
+ cached_paths = {item["file_path"] for item in cache.raw_data}
+ for stem in ("a", "b", "c"):
+ assert (tmp_path / f"{stem}.txt").as_posix() not in cached_paths
+ assert (tmp_path / f"{stem}-renamed.txt").as_posix() in cached_paths
+ assert cache.version_index[1]["file_path"].endswith("a-renamed.txt")
+ assert scanner._hash_index.get_path("hash-a").endswith("a-renamed.txt")
+
+
+@pytest.mark.asyncio
+async def test_deferred_cache_persist_finalizes_despite_cancellation(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ path_a = (tmp_path / "a.txt").as_posix()
+ scanner, spied = await _make_spied_scanner(
+ tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
+ )
+
+ scanner.cancel_task()
+ async with scanner.defer_cache_persist():
+ new_path = (tmp_path / "a-renamed.txt").as_posix()
+ await scanner.update_single_model_cache(
+ path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
+ )
+
+ # Files are already renamed on disk, so the persist must be forced even
+ # though the cancellation flag is set.
+ assert spied.save_calls == [True]
+ assert spied.sync_calls == 1
+ assert spied.resort_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_deferred_cache_persist_finalizes_despite_mid_loop_error(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ path_a = (tmp_path / "a.txt").as_posix()
+ scanner, spied = await _make_spied_scanner(
+ tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
+ )
+
+ with pytest.raises(RuntimeError, match="boom"):
+ async with scanner.defer_cache_persist():
+ new_path = (tmp_path / "a-renamed.txt").as_posix()
+ await scanner.update_single_model_cache(
+ path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
+ )
+ raise RuntimeError("boom")
+
+ assert spied.save_calls == [True]
+ assert spied.sync_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_deferred_cache_persist_nested_contexts_finalize_once(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ path_a = (tmp_path / "a.txt").as_posix()
+ scanner, spied = await _make_spied_scanner(
+ tmp_path, monkeypatch, [_metadata("a", path_a, "hash-a")]
+ )
+
+ async with scanner.defer_cache_persist():
+ async with scanner.defer_cache_persist():
+ new_path = (tmp_path / "a-renamed.txt").as_posix()
+ await scanner.update_single_model_cache(
+ path_a, new_path, _metadata("a-renamed", new_path, "hash-a")
+ )
+
+ assert spied.save_calls == [True]
+ assert spied.resort_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_deferred_cache_persist_without_updates_persists_nothing(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ scanner, spied = await _make_spied_scanner(tmp_path, monkeypatch, [])
+
+ async with scanner.defer_cache_persist():
+ pass
+
+ assert spied.save_calls == []
+ assert spied.resort_calls == 0
+
+
+# ---------------------------------------------------------------------------
+# ModelLifecycleService bulk rename session
+# ---------------------------------------------------------------------------
+
+
+class _SessionScanner:
+ model_type = "lora"
+
+ def __init__(self, root: Path):
+ self._root = str(root)
+ self.cache_updates: List[tuple[str, str]] = []
+ self.defer_enters = 0
+ self.defer_exits = 0
+
+ def get_model_roots(self) -> List[str]:
+ return [self._root]
+
+ @asynccontextmanager
+ async def defer_cache_persist(self) -> AsyncIterator[None]:
+ self.defer_enters += 1
+ try:
+ yield
+ finally:
+ self.defer_exits += 1
+
+ async def update_single_model_cache(
+ self, old_path: str, new_path: str, metadata: Dict[str, Any]
+ ) -> bool:
+ self.cache_updates.append((old_path, new_path))
+ return True
+
+
+class _PassthroughMetadataManager:
+ def __init__(self) -> None:
+ self.saved: List[str] = []
+
+ async def save_metadata(self, path: str, metadata: Dict[str, Any]) -> bool:
+ self.saved.append(path)
+ return True
+
+
+class RecordingRecipeScanner:
+ """Records bulk-mode recipe calls and applies filename changes."""
+
+ def __init__(self) -> None:
+ self.index_builds = 0
+ self.updates: List[Dict[str, Any]] = []
+ self.finalizes = 0
+ self._recipes: Dict[str, Dict[str, Any]] = {}
+
+ def add_recipe(self, recipe_id: str, lora_hash: str, file_name: str) -> None:
+ self._recipes[recipe_id] = {
+ "id": recipe_id,
+ "loras": [{"hash": lora_hash, "file_name": file_name}],
+ }
+
+ async def build_lora_hash_index(self) -> Dict[str, List[Dict[str, Any]]]:
+ self.index_builds += 1
+ index: Dict[str, List[Dict[str, Any]]] = {}
+ for recipe in self._recipes.values():
+ for lora in recipe["loras"]:
+ index.setdefault(lora["hash"].lower(), []).append(recipe)
+ return index
+
+ async def update_lora_filename_by_hash(
+ self,
+ hash_value: str,
+ new_file_name: str,
+ *,
+ hash_index: Optional[Dict[str, List[Dict[str, Any]]]] = None,
+ defer_maintenance: bool = False,
+ ) -> tuple[int, int]:
+ self.updates.append(
+ {
+ "hash_value": hash_value,
+ "new_file_name": new_file_name,
+ "hash_index": hash_index,
+ "defer_maintenance": defer_maintenance,
+ }
+ )
+ matched = 0
+ for recipe in (self._recipes.values() if hash_index is None else hash_index.get(hash_value.lower(), [])):
+ for lora in recipe["loras"]:
+ if lora["hash"].lower() == hash_value.lower():
+ lora["file_name"] = new_file_name
+ matched += 1
+ return (matched, matched)
+
+ async def finalize_bulk_filename_updates(self) -> None:
+ self.finalizes += 1
+
+
+def _write_model_with_sidecar(
+ root: Path, stem: str, sha256: str, model_name: Optional[str] = None
+) -> str:
+ model_path = root / f"{stem}.safetensors"
+ model_path.write_bytes(b"model")
+ (root / f"{stem}.metadata.json").write_text(
+ json.dumps(
+ {
+ "file_name": stem,
+ "file_path": model_path.as_posix(),
+ "model_name": model_name or stem,
+ "sha256": sha256,
+ }
+ )
+ )
+ return model_path.as_posix()
+
+
+def _make_lifecycle_service(
+ scanner: _SessionScanner, recipe_scanner: RecordingRecipeScanner
+) -> ModelLifecycleService:
+ async def metadata_loader(path: str) -> Dict[str, Any]:
+ with open(path, "r", encoding="utf-8") as handle:
+ return json.load(handle)
+
+ async def recipe_scanner_factory() -> RecordingRecipeScanner:
+ return recipe_scanner
+
+ return ModelLifecycleService(
+ scanner=scanner, # pyright: ignore[reportArgumentType]
+ metadata_manager=_PassthroughMetadataManager(), # pyright: ignore[reportArgumentType]
+ metadata_loader=metadata_loader,
+ recipe_scanner_factory=recipe_scanner_factory,
+ )
+
+
+@pytest.mark.asyncio
+async def test_bulk_rename_session_defers_and_finalizes_once(tmp_path: Path):
+ scanner = _SessionScanner(tmp_path)
+ recipe_scanner = RecordingRecipeScanner()
+ recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
+ recipe_scanner.add_recipe("r2", "bb" * 32, "old_b")
+ service = _make_lifecycle_service(scanner, recipe_scanner)
+
+ path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
+ path_b = _write_model_with_sidecar(tmp_path, "model-b", "bb" * 32)
+
+ async with service.bulk_rename_session() as bulk_context:
+ await service.rename_model(
+ file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
+ )
+ await service.rename_model(
+ file_path=path_b, new_file_name="renamed-b", bulk_context=bulk_context
+ )
+ # No finalize may run before the session ends.
+ assert recipe_scanner.finalizes == 0
+ assert scanner.defer_enters == 1
+ assert scanner.defer_exits == 0
+
+ assert scanner.defer_exits == 1
+ # Hash index built at most once for the whole session.
+ assert recipe_scanner.index_builds == 1
+ assert recipe_scanner.finalizes == 1
+ assert len(recipe_scanner.updates) == 2
+ for update in recipe_scanner.updates:
+ assert update["hash_index"] is not None
+ assert update["defer_maintenance"] is True
+
+ # Recipes were re-pointed.
+ assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "renamed-a"
+ assert recipe_scanner._recipes["r2"]["loras"][0]["file_name"] == "renamed-b"
+
+
+@pytest.mark.asyncio
+async def test_bulk_rename_session_finalizes_recipes_despite_error(tmp_path: Path):
+ scanner = _SessionScanner(tmp_path)
+ recipe_scanner = RecordingRecipeScanner()
+ recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
+ service = _make_lifecycle_service(scanner, recipe_scanner)
+
+ path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
+
+ with pytest.raises(RuntimeError, match="mid-loop"):
+ async with service.bulk_rename_session() as bulk_context:
+ await service.rename_model(
+ file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
+ )
+ raise RuntimeError("mid-loop")
+
+ assert recipe_scanner.finalizes == 1
+ assert scanner.defer_exits == 1
+
+
+@pytest.mark.asyncio
+async def test_bulk_rename_session_skips_recipe_finalize_when_untouched(tmp_path: Path):
+ scanner = _SessionScanner(tmp_path)
+ recipe_scanner = RecordingRecipeScanner()
+ recipe_scanner.add_recipe("r1", "cc" * 32, "old_c")
+ service = _make_lifecycle_service(scanner, recipe_scanner)
+
+ # Model whose hash matches no recipe: the lookup runs but no recipe is
+ # touched, so no recipe maintenance is needed at finalize.
+ path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
+
+ async with service.bulk_rename_session() as bulk_context:
+ await service.rename_model(
+ file_path=path_a, new_file_name="renamed-a", bulk_context=bulk_context
+ )
+
+ assert len(recipe_scanner.updates) == 1
+ assert recipe_scanner.updates[0]["defer_maintenance"] is True
+ assert recipe_scanner.finalizes == 0
+ assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "old_c"
+
+
+@pytest.mark.asyncio
+async def test_single_rename_keeps_immediate_recipe_behavior(tmp_path: Path):
+ scanner = _SessionScanner(tmp_path)
+ recipe_scanner = RecordingRecipeScanner()
+ recipe_scanner.add_recipe("r1", "aa" * 32, "old_a")
+ service = _make_lifecycle_service(scanner, recipe_scanner)
+
+ path_a = _write_model_with_sidecar(tmp_path, "model-a", "aa" * 32)
+
+ await service.rename_model(file_path=path_a, new_file_name="renamed-a")
+
+ assert len(recipe_scanner.updates) == 1
+ update = recipe_scanner.updates[0]
+ assert update["hash_index"] is None
+ assert update["defer_maintenance"] is False
+ assert recipe_scanner.finalizes == 0
+ assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "renamed-a"
+
+
+# ---------------------------------------------------------------------------
+# Use case end-to-end: exactly one heavyweight persist for the whole loop
+# ---------------------------------------------------------------------------
+
+
+class _UseCaseScanner(ModelScanner):
+ def __init__(self, root: Path):
+ self._root = str(root)
+ super().__init__(
+ model_type="lora",
+ model_class=BaseModelMetadata,
+ file_extensions={".safetensors"},
+ )
+
+ def get_model_roots(self) -> List[str]:
+ return [self._root]
+
+
+class _UseCaseLockProvider:
+ def __init__(self) -> None:
+ self._lock = asyncio.Lock()
+
+ def is_auto_organize_running(self) -> bool:
+ return False
+
+ async def get_auto_organize_lock(self) -> asyncio.Lock:
+ return self._lock
+
+
+def _set_filename_template(template: str, model_type: str = "lora") -> None:
+ manager = get_settings_manager()
+ templates = dict(manager.settings.get("download_filename_templates") or {})
+ templates[model_type] = template
+ manager.settings["download_filename_templates"] = templates
+
+
+@pytest.mark.asyncio
+async def test_filename_template_bulk_apply_persists_and_repoints_once(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ _set_filename_template("{model_name}")
+
+ entries = []
+ for index, (stem, model_name, sha) in enumerate(
+ (("model-a", "alpha", "aa" * 32), ("model-b", "beta", "bb" * 32))
+ ):
+ path = _write_model_with_sidecar(tmp_path, stem, sha, model_name=model_name)
+ entries.append(_metadata(model_name, path, sha, civitai_id=index + 1))
+
+ scanner = _UseCaseScanner(tmp_path)
+ spied = _SpiedCache(scanner, entries)
+ scanner._persistent_cache = object()
+ monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
+ monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ monkeypatch.setattr(spied.cache, "resort", spied.resort)
+
+ recipe_scanner = RecordingRecipeScanner()
+ recipe_scanner.add_recipe("r1", "aa" * 32, "alpha")
+ recipe_scanner.add_recipe("r2", "bb" * 32, "beta")
+
+ service = _make_lifecycle_service( # type: ignore[arg-type]
+ scanner, recipe_scanner # pyright: ignore[reportArgumentType]
+ )
+ use_case = FilenameTemplateUseCase(
+ scanner=scanner,
+ lifecycle_service=service,
+ lock_provider=_UseCaseLockProvider(),
+ model_type="lora",
+ )
+
+ result = await use_case.execute(progress_callback=None)
+
+ assert result.status == "success"
+ assert result.success_count == 2
+
+ # One heavyweight persist/resort for the entire bulk operation, not two.
+ assert spied.save_calls == [True]
+ assert spied.sync_calls == 1
+ assert spied.resort_calls == 1
+
+ # Recipes re-pointed via a single lazily built hash index, maintenance
+ # finalized once.
+ assert recipe_scanner.index_builds == 1
+ assert recipe_scanner.finalizes == 1
+ assert recipe_scanner._recipes["r1"]["loras"][0]["file_name"] == "alpha"
+ assert recipe_scanner._recipes["r2"]["loras"][0]["file_name"] == "beta"
+
+ # Cache reflects the new paths.
+ cache = await scanner.get_cached_data()
+ cached_paths = {item["file_path"] for item in cache.raw_data}
+ assert (tmp_path / "alpha.safetensors").as_posix() in cached_paths
+ assert (tmp_path / "beta.safetensors").as_posix() in cached_paths
+
+ # Sidecars moved alongside the model files.
+ assert (tmp_path / "alpha.metadata.json").exists()
+ assert (tmp_path / "beta.metadata.json").exists()
+
+
+@pytest.mark.asyncio
+async def test_filename_template_bulk_apply_finalizes_persist_on_cancellation(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+):
+ _set_filename_template("{model_name}")
+
+ entries = []
+ paths = []
+ for index, (stem, model_name, sha) in enumerate(
+ (("model-a", "alpha", "aa" * 32), ("model-b", "beta", "bb" * 32))
+ ):
+ path = _write_model_with_sidecar(tmp_path, stem, sha, model_name=model_name)
+ paths.append(path)
+ entries.append(_metadata(model_name, path, sha, civitai_id=index + 1))
+
+ scanner = _UseCaseScanner(tmp_path)
+ spied = _SpiedCache(scanner, entries)
+ scanner._persistent_cache = object()
+ monkeypatch.setattr(scanner, "_save_persistent_cache", spied.fake_save)
+ monkeypatch.setattr(scanner, "_sync_download_history", spied.fake_sync)
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ monkeypatch.setattr(spied.cache, "resort", spied.resort)
+
+ service = _make_lifecycle_service(
+ scanner, # pyright: ignore[reportArgumentType]
+ RecordingRecipeScanner(),
+ )
+
+ original_rename = service.rename_model
+
+ async def cancelling_rename(**kwargs: Any) -> Dict[str, object]:
+ result = await original_rename(**kwargs)
+ scanner.cancel_task()
+ return result
+
+ monkeypatch.setattr(service, "rename_model", cancelling_rename)
+
+ use_case = FilenameTemplateUseCase(
+ scanner=scanner,
+ lifecycle_service=service,
+ lock_provider=_UseCaseLockProvider(),
+ model_type="lora",
+ )
+
+ result = await use_case.execute(progress_callback=None)
+
+ assert result.status == "cancelled"
+ # The one file renamed before cancellation is on disk; the cache must
+ # still be persisted exactly once.
+ assert spied.save_calls == [True]
+ assert spied.sync_calls == 1
+ assert spied.resort_calls == 1
diff --git a/tests/services/test_model_query_search.py b/tests/services/test_model_query_search.py
index 89f0aa9a..34934609 100644
--- a/tests/services/test_model_query_search.py
+++ b/tests/services/test_model_query_search.py
@@ -13,20 +13,13 @@ SHA256 = "abcdef1234567890" + "f" * 48 # 64-char hex
AUTOV2 = SHA256[:10]
AUTOV3 = "0123456789ab"
+# Hash/id exact matching is always on; these options only disable the
+# scope-based branches so tests exercise the hash branch in isolation.
HASH_ONLY_OPTIONS = {
"filename": False,
"modelname": False,
"tags": False,
"creator": False,
- "hash": True,
-}
-
-HASH_OFF_OPTIONS = {
- "filename": False,
- "modelname": False,
- "tags": False,
- "creator": False,
- "hash": False,
}
@@ -88,9 +81,11 @@ class TestSearchStrategyHash:
item = make_item(autov3="")
assert strategy.apply([item], AUTOV3, HASH_ONLY_OPTIONS) == []
- def test_hash_option_disabled(self, strategy):
- assert strategy.apply([make_item()], SHA256, HASH_OFF_OPTIONS) == []
- assert strategy.apply([make_item()], AUTOV3, HASH_OFF_OPTIONS) == []
+ def test_hash_matching_is_always_on(self, strategy):
+ # There is no hash option anymore: exact hash/id matching happens
+ # regardless of the search scope options.
+ assert len(strategy.apply([make_item()], SHA256, HASH_ONLY_OPTIONS)) == 1
+ assert len(strategy.apply([make_item()], AUTOV3, HASH_ONLY_OPTIONS)) == 1
def test_fuzzy_mode_still_exact(self, strategy):
# Fuzzy matching must never apply to the hash field
@@ -104,6 +99,48 @@ class TestSearchStrategyHash:
assert strategy.apply([item], SHA256, HASH_ONLY_OPTIONS) == []
+class TestSearchStrategyCivitaiIds:
+ """Hash search also exact-matches Civitai model/version ids."""
+
+ def test_version_id_matches(self, strategy):
+ item = make_item(civitai={"modelId": 12345, "id": 67890})
+ assert len(strategy.apply([item], "67890", HASH_ONLY_OPTIONS)) == 1
+
+ def test_model_id_matches(self, strategy):
+ item = make_item(civitai={"modelId": 12345, "id": 67890})
+ assert len(strategy.apply([item], "12345", HASH_ONLY_OPTIONS)) == 1
+
+ def test_model_id_matches_all_versions_of_the_model(self, strategy):
+ v1 = make_item(file_name="v1.safetensors", civitai={"modelId": 12345, "id": 111})
+ v2 = make_item(file_name="v2.safetensors", civitai={"modelId": 12345, "id": 222})
+ other = make_item(file_name="other.safetensors", civitai={"modelId": 999, "id": 333})
+ result = strategy.apply([v1, v2, other], "12345", HASH_ONLY_OPTIONS)
+ assert {r["file_name"] for r in result} == {"v1.safetensors", "v2.safetensors"}
+
+ def test_snake_case_model_id_matches(self, strategy):
+ item = make_item(civitai={"model_id": 12345, "id": 67890})
+ assert len(strategy.apply([item], "12345", HASH_ONLY_OPTIONS)) == 1
+
+ def test_string_ids_match(self, strategy):
+ item = make_item(civitai={"modelId": "12345", "id": "67890"})
+ assert len(strategy.apply([item], "67890", HASH_ONLY_OPTIONS)) == 1
+
+ def test_unknown_id_does_not_match(self, strategy):
+ item = make_item(civitai={"modelId": 12345, "id": 67890})
+ assert strategy.apply([item], "555", HASH_ONLY_OPTIONS) == []
+
+ @pytest.mark.parametrize("missing", [0, "0", None, ""])
+ def test_placeholder_ids_do_not_match(self, strategy, missing):
+ item = make_item(sha256="", autov3=None, civitai={"modelId": missing, "id": missing})
+ assert strategy.apply([item], "0", HASH_ONLY_OPTIONS) == []
+
+ def test_missing_civitai_data_does_not_match(self, strategy):
+ item = make_item(sha256="", autov3=None, civitai=None)
+ assert strategy.apply([item], "12345", HASH_ONLY_OPTIONS) == []
+ item = make_item(sha256="", autov3=None)
+ assert strategy.apply([item], "12345", HASH_ONLY_OPTIONS) == []
+
+
class TestFormatResponseAutov3:
"""format_response should pass the autov3 field through unchanged."""
diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py
index 88b6f47b..2a234b0c 100644
--- a/tests/services/test_recipe_scanner.py
+++ b/tests/services/test_recipe_scanner.py
@@ -1903,6 +1903,115 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(
assert cached1["loras"][0]["file_name"] == new_name
+@pytest.mark.asyncio
+async def test_update_lora_filename_by_hash_bulk_mode_skips_scan_and_defers_resort(
+ tmp_path: Path, recipe_scanner, monkeypatch: pytest.MonkeyPatch
+):
+ """Bulk rename path: prebuilt hash index, no per-call cache walk/resort."""
+ scanner, _ = recipe_scanner
+ recipes_dir = Path(config.loras_roots[0]) / "recipes"
+ recipes_dir.mkdir(parents=True, exist_ok=True)
+
+ recipe1_id = "recipe-bulk-1"
+ recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
+ recipe1_data = {
+ "id": recipe1_id,
+ "file_path": str(tmp_path / "bulk1.png"),
+ "title": "Bulk 1",
+ "modified": 0.0,
+ "created_date": 0.0,
+ "loras": [{"file_name": "old_name", "hash": "hash1"}],
+ }
+ recipe1_path.write_text(json.dumps(recipe1_data))
+ await scanner.add_recipe(dict(recipe1_data))
+
+ # Build the index once (O(recipes)), as a bulk rename session does.
+ hash_index = await scanner.build_lora_hash_index()
+ assert "hash1" in hash_index
+
+ # Spies: the bulk call must not walk the recipe cache again, and must not
+ # schedule a resort per call.
+ get_cached_calls = 0
+ original_get_cached = scanner.get_cached_data
+
+ async def counting_get_cached_data(*args, **kwargs):
+ nonlocal get_cached_calls
+ get_cached_calls += 1
+ return await original_get_cached(*args, **kwargs)
+
+ monkeypatch.setattr(scanner, "get_cached_data", counting_get_cached_data)
+
+ resort_calls = 0
+ original_schedule = scanner._schedule_resort
+
+ def counting_schedule_resort(**kwargs):
+ nonlocal resort_calls
+ resort_calls += 1
+ original_schedule(**kwargs)
+
+ monkeypatch.setattr(scanner, "_schedule_resort", counting_schedule_resort)
+
+ file_count, cache_count = await scanner.update_lora_filename_by_hash(
+ "HASH1", "new_name", hash_index=hash_index, defer_maintenance=True
+ )
+
+ assert (file_count, cache_count) == (1, 1)
+ assert get_cached_calls == 0
+ assert resort_calls == 0
+
+ # The per-match recipe JSON rewrite must still happen.
+ persisted1 = json.loads(recipe1_path.read_text())
+ assert persisted1["loras"][0]["file_name"] == "new_name"
+ cached1 = next(r for r in hash_index["hash1"] if r["id"] == recipe1_id)
+ assert cached1["loras"][0]["file_name"] == "new_name"
+
+ # Deferred maintenance runs exactly once at finalize.
+ await scanner.finalize_bulk_filename_updates()
+ await asyncio.sleep(0)
+ assert resort_calls == 1
+
+
+@pytest.mark.asyncio
+async def test_update_lora_filename_by_hash_with_index_still_resorts_when_not_deferred(
+ tmp_path: Path, recipe_scanner, monkeypatch: pytest.MonkeyPatch
+):
+ """hash_index without defer_maintenance: O(1) lookup, immediate resort."""
+ scanner, _ = recipe_scanner
+ recipes_dir = Path(config.loras_roots[0]) / "recipes"
+ recipes_dir.mkdir(parents=True, exist_ok=True)
+
+ recipe1_id = "recipe-idx-1"
+ recipe1_path = recipes_dir / f"{recipe1_id}.recipe.json"
+ recipe1_data = {
+ "id": recipe1_id,
+ "file_path": str(tmp_path / "idx1.png"),
+ "title": "Index 1",
+ "modified": 0.0,
+ "created_date": 0.0,
+ "loras": [{"file_name": "old_name", "hash": "hash1"}],
+ }
+ recipe1_path.write_text(json.dumps(recipe1_data))
+ await scanner.add_recipe(dict(recipe1_data))
+
+ hash_index = await scanner.build_lora_hash_index()
+
+ resort_calls = 0
+ original_schedule = scanner._schedule_resort
+
+ def counting_schedule_resort(**kwargs):
+ nonlocal resort_calls
+ resort_calls += 1
+ original_schedule(**kwargs)
+
+ monkeypatch.setattr(scanner, "_schedule_resort", counting_schedule_resort)
+
+ file_count, cache_count = await scanner.update_lora_filename_by_hash(
+ "hash1", "new_name", hash_index=hash_index
+ )
+
+ assert (file_count, cache_count) == (1, 1)
+ assert resort_calls == 1
+
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
scanner, _ = recipe_scanner
diff --git a/tests/services/test_use_cases.py b/tests/services/test_use_cases.py
index e24d4817..bafbbeba 100644
--- a/tests/services/test_use_cases.py
+++ b/tests/services/test_use_cases.py
@@ -1,8 +1,9 @@
import asyncio
import logging
+from contextlib import asynccontextmanager
from dataclasses import dataclass
from types import SimpleNamespace
-from typing import Any, Dict, List, Optional
+from typing import Any, AsyncIterator, Dict, List, Optional
import pytest
@@ -547,7 +548,13 @@ class StubLifecycleService:
self.cancel_on_rename = False
self._scanner = scanner
- async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
+ @asynccontextmanager
+ async def bulk_rename_session(self) -> AsyncIterator[None]:
+ yield None
+
+ async def rename_model(
+ self, *, file_path: str, new_file_name: str, bulk_context: Any = None
+ ) -> Dict[str, Any]:
if self.error is not None:
raise self.error
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})