Merge remote-tracking branch 'origin/main' into feat/openmodeldb-support

# Conflicts:
#	docs/i18n-translation-guidelines.md
This commit is contained in:
Will Miao
2026-10-03 21:48:37 +08:00
30 changed files with 1334 additions and 230 deletions
+26
View File
@@ -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)
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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": "הערות נוספות",
+4 -1
View File
@@ -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": "追加メモ",
+4 -1
View File
@@ -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": "추가 메모",
+4 -1
View File
@@ -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": "Дополнительные заметки",
+4 -1
View File
@@ -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": "附加备注",
+4 -1
View File
@@ -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": "附加備註",
-1
View File
@@ -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",
}
+118 -18
View File
@@ -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."""
+20 -8
View File
@@ -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(
+115 -18
View File
@@ -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
+64 -8
View File
@@ -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
@@ -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
@@ -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;
-3
View File
@@ -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());
}
}
}
+45 -4
View File
@@ -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) {
</button>
</span>`);
}
const hashesMarkup = modelSha256 && hashEntries.length ? `
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
const normalizeCivitaiId = (value) => {
if (value === undefined || value === null) return '';
const normalized = String(value).trim();
// "0" is used as a placeholder for unknown ids in some metadata sources
return normalized && normalized !== '0' ? normalized : '';
};
const civitaiInfo = modelWithFullData.civitai || {};
const footnoteModelId = normalizeCivitaiId(civitaiInfo.modelId ?? civitaiInfo.model_id);
const footnoteVersionId = normalizeCivitaiId(civitaiInfo.id);
const copyCivitaiIdTitle = translate('modals.model.actions.copyCivitaiId', {}, 'Copy Civitai ID');
const civitaiIdCopiedToast = escapeAttribute(translate('modals.model.actions.civitaiIdCopied', {}, 'Civitai ID copied to clipboard'));
const civitaiIdEntries = [];
if (footnoteModelId) {
civitaiIdEntries.push(`
<span class="hash-entry civitai-id-entry">
<span class="hash-kind">${translate('modals.model.metadata.civitaiModelId', {}, 'Model ID')}</span>
<span class="model-hash-value" title="${escapeAttribute(footnoteModelId)}">${escapeHtml(footnoteModelId)}</span>
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteModelId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
<i class="fas fa-copy"></i>
</button>
</span>`);
}
if (footnoteVersionId) {
civitaiIdEntries.push(`
<span class="hash-entry civitai-id-entry">
<span class="hash-kind">${translate('modals.model.metadata.civitaiVersionId', {}, 'Version ID')}</span>
<span class="model-hash-value" title="${escapeAttribute(footnoteVersionId)}">${escapeHtml(footnoteVersionId)}</span>
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(footnoteVersionId)}" data-toast="${civitaiIdCopiedToast}" title="${copyCivitaiIdTitle}">
<i class="fas fa-copy"></i>
</button>
</span>`);
}
const footnoteParts = [];
if (hashEntries.length) {
footnoteParts.push(hashEntries.join('<span class="hash-sep">·</span>'));
}
if (civitaiIdEntries.length) {
footnoteParts.push(`<span class="civitai-id-group">${civitaiIdEntries.join('<span class="hash-sep">·</span>')}</span>`);
}
const hashesMarkup = footnoteParts.length ? `
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${footnoteParts.join('')}
</div>` : '';
const useNewIcons = state.global.settings.use_new_license_icons !== false;
const licenseIcons = useNewIcons
@@ -987,7 +1028,7 @@ function setupEventHandlers(filePath, modelType) {
break;
case 'copy-hash':
if (target.dataset.hash) {
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
copyToClipboard(target.dataset.hash, target.dataset.toast || 'Hash copied to clipboard');
}
break;
}
-1
View File
@@ -304,7 +304,6 @@ export class SearchManager {
pageState.searchOptions.modelname = options.modelname || false;
pageState.searchOptions.tags = options.tags || false;
pageState.searchOptions.creator = options.creator || false;
pageState.searchOptions.hash = options.hash || false;
}
}
-4
View File
@@ -124,7 +124,6 @@ export const state = {
modelname: true,
tags: false,
creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
},
filters: {
@@ -191,7 +190,6 @@ export const state = {
filename: true,
modelname: true,
creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
},
filters: {
@@ -231,7 +229,6 @@ export const state = {
modelname: true,
tags: false,
creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
},
filters: {
@@ -269,7 +266,6 @@ export const state = {
modelname: true,
tags: false,
creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
},
filters: {
-3
View File
@@ -203,20 +203,17 @@
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% elif request.path == '/embeddings' or request.path == '/other' %}
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% else %}
<!-- Default options for LoRAs page -->
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% endif %}
</div>
</div>
+6 -1
View File
@@ -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
@@ -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();
});
});
@@ -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());
+639
View File
@@ -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
+49 -12
View File
@@ -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."""
+109
View File
@@ -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
+9 -2
View File
@@ -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})