From 856c9a87acf646db00e542bb15e4adabd7cf5e6c Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 28 Aug 2026 22:24:07 +0800 Subject: [PATCH] fix(recipes): resolve stale LoRA hash on import and add hashInvalid state - import: prefer A1111 Lora hashes (12-char AutoV3) over conflicting Hashes JSON values; recover the quote-wrapped AutoV3 from CivitAI image API meta; merge EXIF-parsed LoRAs when the API-only parse yields none (meta=null) - rematch: treat entries whose hash failed CivitAI resolution (hashInvalid) as unresolved candidates; clear the flag on rematch/reconnect write-back - download: persist hashInvalid and show a distinct toast when hash lookup returns "Model not found", so unresolvable entries become recoverable - ui: add Unresolvable Hash badge styling and reconnect affordance - i18n: translate the new keys across all 10 locales --- locales/de.json | 3 + locales/en.json | 3 + locales/es.json | 3 + locales/fr.json | 3 + locales/he.json | 3 + locales/ja.json | 3 + locales/ko.json | 3 + locales/ru.json | 3 + locales/zh-CN.json | 3 + locales/zh-TW.json | 3 + py/recipes/parsers/automatic.py | 14 +- py/recipes/parsers/civitai_image.py | 21 ++ py/routes/handlers/recipe_handlers.py | 53 +++++- py/routes/recipe_route_registrar.py | 3 + py/services/recipe_scanner.py | 75 +++++++- py/services/recipes/analysis_service.py | 16 ++ py/services/recipes/persistence_service.py | 31 +++ static/css/components/recipe-modal.css | 35 +++- static/js/components/RecipeModal.js | 45 ++++- .../recipeModal.resourceItems.test.js | 69 ++++++- .../test_automatic_metadata_parser.py | 71 +++++++ tests/services/test_civitai_image_parser.py | 81 ++++++++ tests/services/test_recipe_scanner.py | 61 ++++++ tests/services/test_recipe_services.py | 180 ++++++++++++++++++ 24 files changed, 757 insertions(+), 28 deletions(-) diff --git a/locales/de.json b/locales/de.json index 17d24aa9..f982bfe3 100644 --- a/locales/de.json +++ b/locales/de.json @@ -889,9 +889,11 @@ "inLibrary": "In Bibliothek", "notInLibrary": "Nicht in Bibliothek", "deleted": "Gelöscht", + "hashInvalid": "Nicht auflösbarer Hash", "inLibraryTooltip": "Dieses Modell ist in deiner lokalen Bibliothek vorhanden", "notInLibraryTooltip": "Dieses Modell ist nicht in deiner Bibliothek", "deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden", + "hashInvalidTooltip": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert", "download": "Herunterladen", "downloadLoraTooltip": "Dieses LoRA herunterladen", "preparingDownload": "Download wird vorbereitet…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "Checkpoint-Informationen fehlen", "downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}", "missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen", + "hashNotFoundOnCivitai": "Dieser LoRA-Hash kann auf CivitAI nicht aufgelöst werden - das Modell wurde möglicherweise aktualisiert oder der Hash ist ungültig", "downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}", "cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID", "deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung", diff --git a/locales/en.json b/locales/en.json index 2006a378..0a6dc5ba 100644 --- a/locales/en.json +++ b/locales/en.json @@ -889,9 +889,11 @@ "inLibrary": "In Library", "notInLibrary": "Not in Library", "deleted": "Deleted", + "hashInvalid": "Unresolvable Hash", "inLibraryTooltip": "This model exists in your local library", "notInLibraryTooltip": "This model is not in your library", "deletedTooltip": "This LoRA was deleted from the source and is no longer available for download", + "hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated", "download": "Download", "downloadLoraTooltip": "Download this LoRA", "preparingDownload": "Preparing download...", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "Missing checkpoint information", "downloadCheckpointFailed": "Failed to download checkpoint: {message}", "missingLoraDownloadInfo": "Missing download information for this LoRA", + "hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid", "downloadLoraFailed": "Failed to download LoRA: {message}", "cannotDelete": "Cannot delete recipe: Missing recipe ID", "deleteConfirmationError": "Error showing delete confirmation", diff --git a/locales/es.json b/locales/es.json index 80d4c4e5..3b13b8d8 100644 --- a/locales/es.json +++ b/locales/es.json @@ -889,9 +889,11 @@ "inLibrary": "En la biblioteca", "notInLibrary": "No en la biblioteca", "deleted": "Eliminado", + "hashInvalid": "Hash irresoluble", "inLibraryTooltip": "Este modelo existe en tu biblioteca local", "notInLibraryTooltip": "Este modelo no está en tu biblioteca", "deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar", + "hashInvalidTooltip": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado", "download": "Descargar", "downloadLoraTooltip": "Descargar este LoRA", "preparingDownload": "Preparando descarga…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "Falta información del checkpoint", "downloadCheckpointFailed": "Error al descargar el checkpoint: {message}", "missingLoraDownloadInfo": "Falta la información de descarga de este LoRA", + "hashNotFoundOnCivitai": "Este hash de LoRA no se puede resolver en CivitAI - el modelo puede haber sido actualizado o el hash no es válido", "downloadLoraFailed": "Error al descargar el LoRA: {message}", "cannotDelete": "No se puede eliminar receta: Falta ID de receta", "deleteConfirmationError": "Error mostrando confirmación de eliminación", diff --git a/locales/fr.json b/locales/fr.json index df22bb5b..fbf53041 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -889,9 +889,11 @@ "inLibrary": "Dans la bibliothèque", "notInLibrary": "Pas dans la bibliothèque", "deleted": "Supprimé", + "hashInvalid": "Hash irrésolu", "inLibraryTooltip": "Ce modèle existe dans votre bibliothèque locale", "notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque", "deletedTooltip": "Ce LoRA a été supprimé de la source et ne peut plus être téléchargé", + "hashInvalidTooltip": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour", "download": "Télécharger", "downloadLoraTooltip": "Télécharger ce LoRA", "preparingDownload": "Préparation du téléchargement…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "Informations sur le checkpoint manquantes", "downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}", "missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA", + "hashNotFoundOnCivitai": "Ce hash de LoRA ne peut pas être résolu sur CivitAI - le modèle a peut-être été mis à jour ou le hash est invalide", "downloadLoraFailed": "Échec du téléchargement du LoRA : {message}", "cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant", "deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression", diff --git a/locales/he.json b/locales/he.json index 52a42a92..b7106c00 100644 --- a/locales/he.json +++ b/locales/he.json @@ -889,9 +889,11 @@ "inLibrary": "בספרייה", "notInLibrary": "לא בספרייה", "deleted": "נמחק", + "hashInvalid": "גיבוב לא ניתן לפתרון", "inLibraryTooltip": "מודל זה קיים בספרייה המקומית שלך", "notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך", "deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה", + "hashInvalidTooltip": "לא ניתן לפתור את הגיבוב של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן", "download": "הורדה", "downloadLoraTooltip": "הורד את ה-LoRA הזה", "preparingDownload": "מכין את ההורדה…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "חסרים פרטי checkpoint", "downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}", "missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה", + "hashNotFoundOnCivitai": "לא ניתן לפתור את הגיבוב של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שהגיבוב אינו תקין", "downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}", "cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון", "deleteConfirmationError": "שגיאה בהצגת אישור המחיקה", diff --git a/locales/ja.json b/locales/ja.json index b19664dd..98c6291c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -889,9 +889,11 @@ "inLibrary": "ライブラリ内", "notInLibrary": "ライブラリ外", "deleted": "削除済み", + "hashInvalid": "解決不能なハッシュ", "inLibraryTooltip": "このモデルはローカルライブラリに存在します", "notInLibraryTooltip": "このモデルはライブラリにありません", "deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません", + "hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります", "download": "ダウンロード", "downloadLoraTooltip": "この LoRA をダウンロード", "preparingDownload": "ダウンロードを準備中…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "チェックポイント情報が不足しています", "downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}", "missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません", + "hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります", "downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}", "cannotDelete": "レシピを削除できません:レシピIDがありません", "deleteConfirmationError": "削除確認の表示中にエラーが発生しました", diff --git a/locales/ko.json b/locales/ko.json index 9951a293..40af20f7 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -889,9 +889,11 @@ "inLibrary": "라이브러리에 있음", "notInLibrary": "라이브러리에 없음", "deleted": "삭제됨", + "hashInvalid": "해석할 수 없는 해시", "inLibraryTooltip": "이 모델은 로컬 라이브러리에 있습니다", "notInLibraryTooltip": "이 모델은 라이브러리에 없습니다", "deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다", + "hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다", "download": "다운로드", "downloadLoraTooltip": "이 LoRA 다운로드", "preparingDownload": "다운로드 준비 중…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "체크포인트 정보가 부족합니다", "downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}", "missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다", + "hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다", "downloadLoraFailed": "LoRA 다운로드 실패: {message}", "cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락", "deleteConfirmationError": "삭제 확인 표시 오류", diff --git a/locales/ru.json b/locales/ru.json index fd89309c..9a2e9bd6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -889,9 +889,11 @@ "inLibrary": "В библиотеке", "notInLibrary": "Не в библиотеке", "deleted": "Удалено", + "hashInvalid": "Нераспознанный хэш", "inLibraryTooltip": "Эта модель есть в вашей локальной библиотеке", "notInLibraryTooltip": "Этой модели нет в вашей библиотеке", "deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания", + "hashInvalidTooltip": "Этот хэш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена", "download": "Скачать", "downloadLoraTooltip": "Скачать этот LoRA", "preparingDownload": "Подготовка к скачиванию…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "Отсутствуют данные о чекпойнте", "downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}", "missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA", + "hashNotFoundOnCivitai": "Этот хэш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хэш недействителен", "downloadLoraFailed": "Не удалось скачать LoRA: {message}", "cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта", "deleteConfirmationError": "Ошибка отображения подтверждения удаления", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index fc8cd036..96ba79dc 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -889,9 +889,11 @@ "inLibrary": "在库中", "notInLibrary": "不在库中", "deleted": "已删除", + "hashInvalid": "无法解析的哈希", "inLibraryTooltip": "该模型已存在于本地库中", "notInLibraryTooltip": "该模型不在你的本地库中", "deletedTooltip": "该 LoRA 已从来源站删除,无法下载", + "hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新", "download": "下载", "downloadLoraTooltip": "下载此 LoRA", "preparingDownload": "正在准备下载…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "缺少检查点信息", "downloadCheckpointFailed": "下载检查点失败:{message}", "missingLoraDownloadInfo": "缺少此 LoRA 的下载信息", + "hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效", "downloadLoraFailed": "下载 LoRA 失败:{message}", "cannotDelete": "无法删除配方:缺少配方 ID", "deleteConfirmationError": "显示删除确认出错", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ea56ad75..4a8e3d9b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -889,9 +889,11 @@ "inLibrary": "已在庫存", "notInLibrary": "不在庫存", "deleted": "已刪除", + "hashInvalid": "無法解析的雜湊", "inLibraryTooltip": "此模型已存在於本地庫", "notInLibraryTooltip": "此模型不在你的本地庫中", "deletedTooltip": "此 LoRA 已從來源站刪除,無法下載", + "hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新", "download": "下載", "downloadLoraTooltip": "下載此 LoRA", "preparingDownload": "正在準備下載…", @@ -2046,6 +2048,7 @@ "missingCheckpointInfo": "缺少檢查點資訊", "downloadCheckpointFailed": "下載檢查點失敗:{message}", "missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊", + "hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效", "downloadLoraFailed": "下載 LoRA 失敗:{message}", "cannotDelete": "無法刪除配方:缺少配方 ID", "deleteConfirmationError": "顯示刪除確認時發生錯誤", diff --git a/py/recipes/parsers/automatic.py b/py/recipes/parsers/automatic.py index 02b5033d..74503261 100644 --- a/py/recipes/parsers/automatic.py +++ b/py/recipes/parsers/automatic.py @@ -146,15 +146,13 @@ class AutomaticMetadataParser(RecipeMetadataParser): # Initialize hashes dict if it doesn't exist if "hashes" not in metadata: metadata["hashes"] = {} - # Add as lora type in the same format as - # regular hashes. Only override an - # existing entry if its value is empty - # (Lora hashes is the more reliable - # source when Hashes JSON has blanks). + # Lora hashes carries the 12-char AutoV3 + # hash (resolvable on CivitAI and the local + # autov3 index); the Hashes JSON value is + # only the 10-char AutoV2 prefix, so on + # conflict the Lora hashes value wins. key = f"lora:{lora_name}" - existing = metadata["hashes"].get(key, "") - if not existing: - metadata["hashes"][key] = lora_hash + metadata["hashes"][key] = lora_hash # Remove lora hashes from params section params_section = params_section.replace(lora_hashes_match.group(0), '') diff --git a/py/recipes/parsers/civitai_image.py b/py/recipes/parsers/civitai_image.py index dab68ef9..bb49e822 100644 --- a/py/recipes/parsers/civitai_image.py +++ b/py/recipes/parsers/civitai_image.py @@ -115,6 +115,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser): ): metadata = inner_meta + # Civitai's image API meta parser mangles the A1111 "Lora hashes" + # text field into a quote-wrapped dict entry: + # '"Daphne Blake Cosplay_v1": "e67ebd5e315f"' + # The 12-char AutoV3 it carries is more reliable than the stale + # 10-char AutoV2 value in the "hashes" dict, so recover it and + # let it override the conflicting entry. + if isinstance(metadata, dict): + for key, hash_value in list(metadata.items()): + if ( + isinstance(key, str) + and key.startswith('"') + and isinstance(hash_value, str) + and hash_value.endswith('"') + ): + clean_name = key.strip('"').strip() + clean_hash = hash_value.strip('"').strip() + if clean_name and clean_hash: + hashes_dict = metadata.get("hashes") + if isinstance(hashes_dict, dict): + hashes_dict[f"lora:{clean_name}"] = clean_hash + # Initialize result structure result: Dict[str, Any] = { "base_model": None, diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 2a53e384..4a25c0e2 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -113,6 +113,7 @@ class RecipeHandlerSet: "update_recipe": self.management.update_recipe, "record_recipe_open": self.management.record_recipe_open, "reconnect_lora": self.management.reconnect_lora, + "mark_lora_hash_invalid": self.management.mark_lora_hash_invalid, "find_duplicates": self.query.find_duplicates, "move_recipes_bulk": self.management.move_recipes_bulk, "bulk_delete": self.management.bulk_delete, @@ -1592,6 +1593,35 @@ class RecipeManagementHandler: self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True) return web.json_response({"error": str(exc)}, status=500) + async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + data = await request.json() + for field in ("recipe_id", "lora_index"): + if field not in data: + raise RecipeValidationError(f"Missing required field: {field}") + + result = await self._persistence_service.mark_lora_hash_invalid( + recipe_scanner=recipe_scanner, + recipe_id=data["recipe_id"], + lora_index=int(data["lora_index"]), + hash_invalid=bool(data.get("hash_invalid", True)), + ) + return web.json_response(result.payload, status=result.status) + except RecipeValidationError as exc: + return web.json_response({"error": str(exc)}, status=400) + except RecipeNotFoundError as exc: + return web.json_response({"error": str(exc)}, status=404) + except Exception as exc: + self._logger.error( + "Error marking LoRA hash invalid: %s", exc, exc_info=True + ) + return web.json_response({"error": str(exc)}, status=500) + async def bulk_delete(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() @@ -2183,14 +2213,21 @@ class RecipeManagementHandler: civitai_base_model = civitai_parsed.get("base_model") if civitai_base_model and not metadata.get("base_model"): metadata["base_model"] = civitai_base_model - elif parsed_embedded: - parsed_loras = parsed_embedded.get("loras") - if parsed_loras and not metadata.get("loras"): - metadata["loras"] = parsed_loras - parsed_model = parsed_embedded.get("model") - if parsed_model and not metadata.get("checkpoint"): - metadata["checkpoint"] = parsed_model - if parsed_embedded.get("base_model") and not metadata.get("base_model"): + + # EXIF fills whatever the API-only parse left open — when the image + # API meta is null (only modelVersionIds present) the API parse + # yields a checkpoint but no LoRAs, while the image EXIF carries the + # full resource list. + if parsed_embedded: + if not metadata.get("loras"): + parsed_loras = parsed_embedded.get("loras") + if parsed_loras: + metadata["loras"] = parsed_loras + if not metadata.get("checkpoint"): + parsed_model = parsed_embedded.get("model") + if parsed_model: + metadata["checkpoint"] = parsed_model + if not metadata.get("base_model") and parsed_embedded.get("base_model"): metadata["base_model"] = parsed_embedded["base_model"] civitai_client = self._civitai_client_getter() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 73420ace..5ed9e27f 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -49,6 +49,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), + RouteDefinition( + "POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid" + ), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition( diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 53e520f7..c9acd075 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -18,7 +18,11 @@ from ..utils.file_utils import calculate_autov3 from ..utils.recipe_open_stats import RecipeOpenStats from .model_scanner import WEIGHT_FILE_EXTENSIONS from .recipe_cache import RecipeCache -from .recipes.errors import RecipeNotFoundError, RecipePersistenceError +from .recipes.errors import ( + RecipeNotFoundError, + RecipePersistenceError, + RecipeValidationError, +) from .websocket_manager import ws_manager from natsort import natsorted import sys @@ -241,11 +245,23 @@ class RecipeScanner: return cache def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool: - """Return True when a recipe entry is eligible for local re-matching.""" + """Return True when a recipe entry is eligible for local re-matching. + + An entry counts as unresolved when its identity is known to be + broken (``isDeleted`` or ``hashInvalid``) or when it is missing + identity fields (``hash``/``file_name``). A healthy entry whose + hash is simply not present in the local library is NOT a candidate: + it may be a recipe imported without downloading the model yet, and + its CivitAI-valid hash must never be overwritten by the imprecise + filename fallback. + """ if not isinstance(entry, dict): return False unresolved = ( - entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name") + entry.get("isDeleted") + or entry.get("hashInvalid") + or not entry.get("hash") + or not entry.get("file_name") ) has_identifier = ( entry.get("hash") @@ -1262,6 +1278,7 @@ class RecipeScanner: ) -> None: """Write back a matched local model to a lora recipe entry.""" entry["isDeleted"] = False + entry["hashInvalid"] = False # Only truthy hashes are written — pending/failed items carry an empty # sha256 and an unconditional write would wipe a valid stored hash. @@ -3661,6 +3678,7 @@ class RecipeScanner: lora_entry = loras[lora_index] lora_entry["isDeleted"] = False + lora_entry["hashInvalid"] = False lora_entry["exclude"] = False lora_entry["file_name"] = target_name @@ -3712,6 +3730,57 @@ class RecipeScanner: updated_lora = self._enrich_lora_entry(updated_lora) return recipe_data, updated_lora + async def set_lora_entry_hash_invalid( + self, + recipe_id: str, + lora_index: int, + hash_invalid: bool, + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """Set the ``hashInvalid`` flag on a specific LoRA entry. + + ``hashInvalid`` records that the entry's hash could not be resolved + on CivitAI (e.g. a download attempt returned "Model not found"). + Marking it makes the entry an unresolved rematch candidate without + touching its stored hash/file_name. + + Returns: + The updated recipe data and the refreshed LoRA metadata. + """ + recipe_json_path = await self.get_recipe_json_path(recipe_id) + if not recipe_json_path or not os.path.exists(recipe_json_path): + raise RecipeNotFoundError("Recipe not found") + + async with self._mutation_lock: + with open(recipe_json_path, "r", encoding="utf-8") as file_obj: + recipe_data = json.load(file_obj) + + loras = recipe_data.get("loras", []) + if lora_index >= len(loras): + raise RecipeNotFoundError("LoRA index out of range in recipe") + + lora_entry = loras[lora_index] + if not isinstance(lora_entry, dict): + raise RecipeValidationError("LoRA entry is not a dict") + + lora_entry["hashInvalid"] = bool(hash_invalid) + recipe_data["modified"] = time.time() + + with open(recipe_json_path, "w", encoding="utf-8") as file_obj: + json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False) + + cache = await self.get_cached_data() + replaced = await cache.replace_recipe(recipe_id, recipe_data, resort=False) + if not replaced: + await cache.add_recipe(recipe_data, resort=False) + self._schedule_resort() + + if self._persistent_cache: + self._persistent_cache.update_recipe(recipe_data, recipe_json_path) + self._json_path_map[recipe_id] = recipe_json_path + + updated_lora = self._enrich_lora_entry(dict(lora_entry)) + return recipe_data, updated_lora + async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]: """Return recipes that reference a given LoRA hash.""" diff --git a/py/services/recipes/analysis_service.py b/py/services/recipes/analysis_service.py index 99072720..b45a3692 100644 --- a/py/services/recipes/analysis_service.py +++ b/py/services/recipes/analysis_service.py @@ -270,6 +270,22 @@ class RecipeAnalysisService: if merged_gp: result.payload["gen_params"] = merged_gp + # The API-only parse (meta=null with only modelVersionIds) + # yields a checkpoint but no LoRAs; the image EXIF carries the + # full resource list. Fill the gaps the API parse left open. + if not result.payload.get("loras"): + exif_loras = exif_parsed_result.get("loras") or [] + if exif_loras: + result.payload["loras"] = exif_loras + if not result.payload.get("checkpoint") and not result.payload.get("model"): + exif_checkpoint = exif_parsed_result.get("model") or exif_parsed_result.get( + "checkpoint" + ) + if exif_checkpoint: + result.payload["checkpoint"] = exif_checkpoint + if not result.payload.get("base_model") and exif_parsed_result.get("base_model"): + result.payload["base_model"] = exif_parsed_result["base_model"] + if civitai_image_id and image_info and not result.payload.get("error"): # Use the metadata dict we built (may contain modelVersionIds # and browsingLevel from the API root level). Do NOT pass diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 664b0dc3..33c3dcbd 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -470,6 +470,36 @@ class RecipePersistenceService: } ) + async def mark_lora_hash_invalid( + self, + *, + recipe_scanner, + recipe_id: str, + lora_index: int, + hash_invalid: bool = True, + ) -> PersistenceResult: + """Mark a recipe LoRA entry's hash as unresolvable on CivitAI. + + Called when a download attempt by hash returned "Model not found". + The flag makes the entry an unresolved rematch candidate without + altering its stored hash/file_name. + """ + + recipe_data, updated_lora = await recipe_scanner.set_lora_entry_hash_invalid( + recipe_id, + lora_index, + hash_invalid=hash_invalid, + ) + + return PersistenceResult( + { + "success": True, + "recipe_id": recipe_id, + "hash_invalid": bool(hash_invalid), + "updated_lora": updated_lora, + } + ) + async def bulk_delete( self, *, @@ -793,6 +823,7 @@ class RecipePersistenceService: "modelName": lora.get("name", ""), "modelVersionName": lora.get("version", ""), "isDeleted": lora.get("isDeleted", False), + "hashInvalid": lora.get("hashInvalid", False), "exclude": lora.get("exclude", False), } diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 6078d3b6..191ea3bb 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -850,7 +850,8 @@ } .local-badge, -.missing-badge { +.missing-badge, +.invalid-hash-badge { position: absolute; right: 0; top: 0; @@ -861,7 +862,8 @@ /* Specific styles for recipe modal badges - update z-index */ .recipe-lora-header .local-badge, -.recipe-lora-header .missing-badge { +.recipe-lora-header .missing-badge, +.recipe-lora-header .invalid-hash-badge { z-index: 2; /* Ensure the badge is above other elements */ backface-visibility: hidden; } @@ -903,6 +905,26 @@ font-size: 0.9em; } +/* Unresolvable-hash badge: the entry has identity fields, but its hash is + not registered on CivitAI (stale or invalid). */ +.invalid-hash-badge { + display: inline-flex; + align-items: center; + background: var(--lora-warning); + color: white; + padding: 3px 6px; + border-radius: var(--border-radius-xs); + font-size: 0.75em; + font-weight: 500; + white-space: nowrap; + flex-shrink: 0; +} + +.invalid-hash-badge i { + margin-right: 4px; + font-size: 0.9em; +} + /* Deleted badge is a pure status indicator; the reconnect action lives on an explicit ghost button in the item's action row. */ @@ -1124,7 +1146,8 @@ /* Badges are pure status indicators; actions live in .recipe-lora-actions */ .badge-container .local-badge, .badge-container .missing-badge, -.badge-container .deleted-badge { +.badge-container .deleted-badge, +.badge-container .invalid-hash-badge { position: static; /* Override absolute positioning */ transform: none; /* Remove the transform */ } @@ -1150,6 +1173,12 @@ border: 1px solid rgba(127, 127, 127, 0.35); } +.badge-container .invalid-hash-badge { + background: oklch(var(--lora-warning) / 0.14); + color: var(--lora-warning); + border: 1px solid oklch(var(--lora-warning) / 0.35); +} + /* Missing LoRAs status is a real button: the affordance must be visible at rest (persistent border), not only on hover. */ .recipe-status.missing.clickable { diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index c6882560..f4845d6d 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -897,6 +897,11 @@ class RecipeModal {
${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
`; + } else if (lora.hashInvalid) { + statusBadge = ` +
+ ${escapeHtml(translate('recipes.resources.hashInvalid', {}, 'Unresolvable Hash'))} +
`; } else { statusBadge = `
@@ -1982,7 +1987,7 @@ class RecipeModal { } const controls = []; - if (isDeleted) { + if (isDeleted || lora.hashInvalid) { const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); controls.push(` @@ -2032,7 +2037,7 @@ class RecipeModal { const loraIndex = parseInt(button.dataset.loraIndex, 10); const lora = this.currentRecipe?.loras?.[loraIndex]; if (lora) { - this.downloadRecipeLora(lora, button); + this.downloadRecipeLora(lora, button, loraIndex); } }); }); @@ -2112,7 +2117,7 @@ class RecipeModal { } } - async downloadRecipeLora(lora, button) { + async downloadRecipeLora(lora, button, loraIndex) { if (!this.canDownloadLora(lora)) { showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); return; @@ -2141,7 +2146,12 @@ class RecipeModal { state.loadingManager.hide(); } if (!identifiers) { - showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); + if (!hasDirectIds && lora.hash) { + await this.markLoraHashInvalid(loraIndex); + showToast('toast.recipes.hashNotFoundOnCivitai', {}, 'error'); + } else { + showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); + } return; } @@ -2170,6 +2180,33 @@ class RecipeModal { } } + async markLoraHashInvalid(loraIndex) { + const recipeId = + this.recipeId || + extractRecipeId(this.listFilePath || this.currentRecipe?.file_path); + if (!recipeId) { + return; + } + try { + await fetch('/api/lm/recipe/lora/mark-hash-invalid', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipe_id: recipeId, + lora_index: loraIndex, + }), + }); + if (this.currentRecipe?.loras?.[loraIndex]) { + this.currentRecipe.loras[loraIndex].hashInvalid = true; + this.syncResourcesSection(this.currentRecipe); + } + } catch (error) { + console.warn('Failed to mark LoRA hash invalid:', error); + } + } + navigateToCheckpointPage(checkpoint) { const checkpointHash = this._getCheckpointHash(checkpoint); diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js index e7b423eb..5ab666b2 100644 --- a/tests/frontend/components/recipeModal.resourceItems.test.js +++ b/tests/frontend/components/recipeModal.resourceItems.test.js @@ -142,6 +142,14 @@ const hashOnlyLora = { hash: 'deadbeefcafe', }; +const hashInvalidLora = { + name: 'invalid-hash-lora', + modelName: 'Invalid Hash LoRA', + inLibrary: false, + hash: 'a2a12bfa01', + hashInvalid: true, +}; + const recipeWithResources = { id: 'recipe-resources', file_path: '/recipes/resources.json', @@ -157,6 +165,7 @@ const recipeWithResources = { { name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' }, missingLora, { name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true }, + hashInvalidLora, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, hashOnlyLora, ], @@ -321,11 +330,67 @@ describe('RecipeModal resource item interactions', () => { expect(container.classList.contains('active')).toBe(true); }); + it('renders hash-invalid LoRAs with a dedicated badge and reconnect instead of download', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeWithResources); + await flushWiring(); + + const invalidItem = document.querySelector('[data-lora-index="3"]'); + const badge = invalidItem.querySelector('.invalid-hash-badge'); + expect(badge).not.toBeNull(); + expect(badge.title).toContain('cannot be resolved on CivitAI'); + expect(badge.textContent).toContain('Unresolvable Hash'); + + expect(invalidItem.querySelector('.lora-download')).toBeNull(); + expect(invalidItem.querySelector('.lora-reconnect')).not.toBeNull(); + }); + + it('marks the entry hash-invalid when hash resolution returns Model not found', async () => { + const recipeModal = await createRecipeModal(); + const requests = []; + // Deep copy so the mark step mutating loras[5].hashInvalid does not + // leak into the shared fixture used by later tests. + const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources)); + fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe); + global.fetch = vi.fn(async (url, options) => { + requests.push({ url: String(url), options }); + const urlStr = String(url); + if (urlStr.includes('/civitai/model/hash/')) { + return { ok: false, json: async () => ({ success: false, error: 'Model not found' }) }; + } + return { ok: true, json: async () => ({}) }; + }); + recipeModal.showRecipeDetails(isolatedRecipe); + await flushWiring(); + + const hashItem = document.querySelector('[data-lora-index="5"]'); + const downloadButton = hashItem.querySelector('.lora-download'); + downloadButton.click(); + + await vi.waitFor(() => { + expect( + requests.some(r => r.url.includes('/recipe/lora/mark-hash-invalid')) + ).toBe(true); + }); + + const markRequest = requests.find(r => r.url.includes('/mark-hash-invalid')); + expect(JSON.parse(markRequest.options.body)).toEqual({ + recipe_id: 'recipe-resources', + lora_index: 5, + }); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.recipes.hashNotFoundOnCivitai', + {}, + 'error' + ); + expect(downloadVersionWithDefaultsMock).not.toHaveBeenCalled(); + }); + it('renders no action row when neither identifiers nor hash are available', async () => { const recipeModal = await createRecipeModal(); recipeModal.showRecipeDetails(recipeWithResources); - const mysteryItem = document.querySelector('[data-lora-index="3"]'); + const mysteryItem = document.querySelector('[data-lora-index="4"]'); expect(mysteryItem.querySelector('.lora-download')).toBeNull(); // No actions at all -> no empty action row taking vertical space expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull(); @@ -348,7 +413,7 @@ describe('RecipeModal resource item interactions', () => { recipeModal.showRecipeDetails(recipeWithResources); await flushWiring(); - const hashItem = document.querySelector('[data-lora-index="4"]'); + const hashItem = document.querySelector('[data-lora-index="5"]'); const downloadButton = hashItem.querySelector('.lora-download'); expect(downloadButton).not.toBeNull(); diff --git a/tests/services/test_automatic_metadata_parser.py b/tests/services/test_automatic_metadata_parser.py index aed235e4..a337985b 100644 --- a/tests/services/test_automatic_metadata_parser.py +++ b/tests/services/test_automatic_metadata_parser.py @@ -166,6 +166,77 @@ async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypa assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped" +@pytest.mark.asyncio +async def test_parse_metadata_lora_hashes_override_conflicting_hashes_json(monkeypatch): + """When Hashes JSON carries a non-empty but stale hash and the Lora + hashes text field carries the real 12-char AutoV3 hash, the Lora hashes + value must win: CivitAI is queried with it and the entry is resolved + instead of being poisoned by the stale hash.""" + lora_version_info = { + "id": 359072, + "modelId": 320224, + "model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "LORA"}, + "name": "v1.0", + "images": [{"url": "https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/original=true"}], + "baseModel": "SD 1.5", + "downloadUrl": "https://civitai.com/api/download/models/359072", + "files": [ + { + "type": "Model", + "primary": True, + "sizeKB": 1024, + "name": "Daphne Blake Cosplay_v1.safetensors", + "hashes": {"SHA256": "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276"}, + } + ], + } + + queried_hashes = [] + + async def fake_metadata_provider(): + class Provider: + async def get_model_by_hash(self, model_hash): + queried_hashes.append(model_hash) + if model_hash == "e67ebd5e315f": + return lora_version_info, None + return None, "Model not found" + + return Provider() + + monkeypatch.setattr( + "py.recipes.parsers.automatic.get_default_metadata_provider", + fake_metadata_provider, + ) + + parser = AutomaticMetadataParser() + + metadata_text = ( + "woman, natural blonde hair, ice blue eyes, " + "daphne blake cosplay, upper body\n" + "Negative prompt: low quality\n" + "Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, " + "Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, " + 'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", ' + 'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}' + ) + + result = await parser.parse_metadata(metadata_text) + + assert "e67ebd5e315f" in queried_hashes, ( + f"CivitAI must be queried with the Lora hashes value, got {queried_hashes}" + ) + assert "a2a12bfa01" not in queried_hashes, ( + "the stale Hashes JSON value must never be used for CivitAI lookup" + ) + loras = result.get("loras", []) + assert len(loras) == 1 + lora = loras[0] + assert lora["hash"] == "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276" + assert lora["id"] == 359072 + assert lora["modelId"] == 320224 + assert lora.get("isDeleted") in (None, False) + + @pytest.mark.asyncio async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch): async def fake_metadata_provider(): diff --git a/tests/services/test_civitai_image_parser.py b/tests/services/test_civitai_image_parser.py index 7073c1dd..7a4b7cf1 100644 --- a/tests/services/test_civitai_image_parser.py +++ b/tests/services/test_civitai_image_parser.py @@ -898,3 +898,84 @@ async def test_local_cache_dedup_same_hash_produces_one_entry_on_miss(monkeypatc assert provider.hash_calls == ["missdedup123"] assert len(result["loras"]) == 1 + + + +@pytest.mark.asyncio +async def test_quote_wrapped_lora_hashes_override_stale_hash(monkeypatch): + """CivitAI's image API meta parser mangles the A1111 'Lora hashes' text + field into a quote-wrapped dict entry ('"Daphne Blake Cosplay_v1": + "e67ebd5e315f"'). The recovered 12-char AutoV3 must override the stale + 10-char value in the hashes dict, so the lora resolves instead of being + marked deleted.""" + current_sha256 = ( + "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276" + ) + + class Provider: + def __init__(self): + self.hash_calls = [] + + async def get_model_version_info(self, version_id): + if version_id == "138176": + return { + "id": 138176, + "modelId": 15003, + "model": {"name": "CyberRealistic", "type": "checkpoint"}, + "name": "v3.3", + "baseModel": "SD 1.5", + "files": [ + { + "type": "Model", + "primary": True, + "name": "cyberrealistic_v33.safetensors", + "hashes": {"SHA256": "3c8530cb2239b686d23a94627e29883fe44a1605f31a777727b6709f80d11679"}, + } + ], + }, None + return None, "Model not found" + + async def get_model_by_hash(self, model_hash): + self.hash_calls.append(model_hash) + if model_hash == "e67ebd5e315f": + return { + "id": 359072, + "modelId": 320224, + "model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "lora"}, + "name": "v1.0", + "baseModel": "SD 1.5", + "downloadUrl": "https://civitai.com/api/download/359072", + "files": [ + { + "type": "Model", + "primary": True, + "name": "Daphne Blake Cosplay_v1.safetensors", + "hashes": {"SHA256": current_sha256.upper()}, + } + ], + }, None + return None, "Model not found" + + metadata = { + "prompt": "test", + "steps": 20, + "sampler": "DPM++ 2M Karras", + "hashes": { + "model": "3c8530cb22", + "lora:Daphne Blake Cosplay_v1": "a2a12bfa01", + }, + '"Daphne Blake Cosplay_v1': 'e67ebd5e315f"', + "modelVersionIds": [138176], + "browsingLevel": 1, + } + + provider = Provider() + result = await _parse_with_cache(monkeypatch, provider, metadata, local_cache={}) + + assert len(result["loras"]) == 1 + lora = result["loras"][0] + assert lora["hash"] == current_sha256 + assert lora["id"] == 359072 + assert lora.get("isDeleted") in (None, False) + assert "e67ebd5e315f" in provider.hash_calls + assert "a2a12bfa01" not in provider.hash_calls diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index a6ef40dd..def8ee42 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -331,6 +331,49 @@ async def test_update_lora_entry_updates_cache_and_file(tmp_path: Path, recipe_s assert cached_recipe["fingerprint"] == expected_fingerprint +async def test_set_lora_entry_hash_invalid_persists_flag(tmp_path: Path, recipe_scanner): + scanner, _ = recipe_scanner + recipes_dir = Path(config.loras_roots[0]) / "recipes" + recipes_dir.mkdir(parents=True, exist_ok=True) + + recipe_id = "hash-invalid-1" + recipe_path = recipes_dir / f"{recipe_id}.recipe.json" + recipe_data = { + "id": recipe_id, + "file_path": str(tmp_path / "image.png"), + "title": "Hash invalid", + "modified": 0.0, + "created_date": 0.0, + "loras": [ + { + "file_name": "Daphne Blake Cosplay_v1", + "strength": 1.0, + "hash": "a2a12bfa01", + }, + ], + } + recipe_path.write_text(json.dumps(recipe_data)) + await scanner.add_recipe(dict(recipe_data)) + + updated_recipe, updated_lora = await scanner.set_lora_entry_hash_invalid( + recipe_id, 0, True + ) + + assert updated_lora["hashInvalid"] is True + assert updated_recipe["loras"][0]["hashInvalid"] is True + with recipe_path.open("r", encoding="utf-8") as file_obj: + persisted = json.load(file_obj) + assert persisted["loras"][0]["hashInvalid"] is True + assert persisted["loras"][0]["hash"] == "a2a12bfa01" + + cache = await scanner.get_cached_data() + cached_recipe = next(item for item in cache.raw_data if item["id"] == recipe_id) + assert cached_recipe["loras"][0]["hashInvalid"] is True + + _, cleared_lora = await scanner.set_lora_entry_hash_invalid(recipe_id, 0, False) + assert cleared_lora["hashInvalid"] is False + + @pytest.mark.asyncio async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner): scanner, _ = recipe_scanner @@ -2212,6 +2255,24 @@ async def test_is_rematch_candidate_rejects_non_dict(tmp_path: Path): assert not scanner._is_rematch_candidate(malformed) +async def test_is_rematch_candidate_hash_invalid_passes(tmp_path: Path): + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + assert scanner._is_rematch_candidate( + {"hash": "abc", "file_name": "m.safetensors", "hashInvalid": True} + ) + + +async def test_is_rematch_candidate_healthy_not_in_library_rejected(tmp_path: Path): + # A healthy entry whose hash is simply absent from the local library + # (recipe imported without downloading the model) must not become a + # candidate: its CivitAI-valid hash would be at risk of being + # overwritten by the imprecise filename fallback. + scanner, _, _ = _make_rematch_scanner([], [], tmp_path) + assert not scanner._is_rematch_candidate( + {"hash": "abc", "file_name": "m.safetensors", "hashInvalid": False} + ) + + # _match_rematch_entry — L1 hash-cache lookup diff --git a/tests/services/test_recipe_services.py b/tests/services/test_recipe_services.py index 7b8b9f98..6675a811 100644 --- a/tests/services/test_recipe_services.py +++ b/tests/services/test_recipe_services.py @@ -1313,3 +1313,183 @@ async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp await service.reconnect_lora( recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing" ) + + +@pytest.mark.asyncio +async def test_mark_lora_hash_invalid_delegates_and_reports(tmp_path): + service = RecipePersistenceService( + exif_utils=DummyExifUtils(), + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + class DummyScanner: + async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid): + assert recipe_id == "r1" + assert lora_index == 0 + assert hash_invalid is True + return ( + {"id": "r1", "loras": [{"file_name": "m", "hashInvalid": True}]}, + {"file_name": "m", "hashInvalid": True}, + ) + + result = await service.mark_lora_hash_invalid( + recipe_scanner=DummyScanner(), recipe_id="r1", lora_index=0 + ) + + assert result.payload["success"] is True + assert result.payload["recipe_id"] == "r1" + assert result.payload["hash_invalid"] is True + assert result.payload["updated_lora"]["hashInvalid"] is True + + +@pytest.mark.asyncio +async def test_mark_lora_hash_invalid_can_clear_flag(tmp_path): + service = RecipePersistenceService( + exif_utils=DummyExifUtils(), + card_preview_width=512, + logger=logging.getLogger("test"), + ) + + class DummyScanner: + async def set_lora_entry_hash_invalid(self, recipe_id, lora_index, hash_invalid): + assert hash_invalid is False + return ( + {"id": "r1", "loras": [{"file_name": "m", "hashInvalid": False}]}, + {"file_name": "m", "hashInvalid": False}, + ) + + result = await service.mark_lora_hash_invalid( + recipe_scanner=DummyScanner(), + recipe_id="r1", + lora_index=0, + hash_invalid=False, + ) + + assert result.payload["hash_invalid"] is False + assert result.payload["updated_lora"]["hashInvalid"] is False + + +@pytest.mark.asyncio +async def test_analyze_remote_image_meta_null_keeps_exif_loras(tmp_path, monkeypatch): + """When the CivitAI image API meta is null (only modelVersionIds + present), the EXIF-parsed LoRAs must be merged into the result — they + were previously dropped because the API-only parse yields a checkpoint + but no LoRAs.""" + A1111_METADATA = ( + "woman, natural blonde hair, ice blue eyes, daphne blake cosplay, upper body\n" + "Negative prompt: low quality\n" + "Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 4140408634, " + "Size: 512x768, Model hash: 3c8530cb22, Model: cyberrealistic_v33, " + 'Lora hashes: "Daphne Blake Cosplay_v1: e67ebd5e315f", ' + 'Hashes: {"lora:Daphne Blake Cosplay_v1": "a2a12bfa01"}' + ) + LORA_SHA256 = "533317d3f7d269f9f504bdc432514774d3ada3738ebd80f3f1a37ff848e88276" + + class FakeExif: + def extract_image_metadata(self, path): + return A1111_METADATA + + class FakeDownloader: + async def download_file(self, url, path, use_auth=False): + with open(path, "wb") as fh: + fh.write(b"fake-image") + return True, None + + async def downloader_factory(): + return FakeDownloader() + + class FakeCivitaiClient: + async def get_image_info(self, image_id, source_url=None): + return { + "id": 7076441, + "url": "https://image.civitai.com/x/original=true/x.jpeg", + "type": "image", + "meta": None, + "modelVersionIds": [138176], + "browsingLevel": 1, + } + + async def fake_metadata_provider(): + class Provider: + async def get_model_version_info(self, version_id): + if version_id == "138176": + return { + "id": 138176, + "modelId": 15003, + "model": {"name": "CyberRealistic", "type": "checkpoint"}, + "name": "v3.3", + "baseModel": "SD 1.5", + "files": [ + { + "type": "Model", + "primary": True, + "name": "cyberrealistic_v33.safetensors", + "hashes": {"SHA256": "3c8530cb2239b686d23a94627e29883fe44a1605f31a777727b6709f80d11679"}, + } + ], + }, None + return None, "Model not found" + + async def get_model_by_hash(self, model_hash): + if model_hash == "e67ebd5e315f": + return { + "id": 359072, + "modelId": 320224, + "model": {"name": "Daphne Blake Cosplay (Scooby Doo)", "type": "lora"}, + "name": "v1.0", + "baseModel": "SD 1.5", + "downloadUrl": "https://civitai.com/api/download/359072", + "files": [ + { + "type": "Model", + "primary": True, + "name": "Daphne Blake Cosplay_v1.safetensors", + "hashes": {"SHA256": LORA_SHA256.upper()}, + } + ], + }, None + return None, "Model not found" + + return Provider() + + monkeypatch.setattr( + "py.recipes.parsers.automatic.get_default_metadata_provider", + fake_metadata_provider, + ) + + class DummyScanner: + async def build_local_hash_cache(self): + return {} + + async def find_recipes_by_fingerprint(self, fp): + return [] + + async def get_local_lora(self, name, base_model=None): + return None + + async def get_local_lora_by_hash(self, hash_value): + return None + + from py.recipes.factory import RecipeParserFactory + + service = RecipeAnalysisService( + exif_utils=FakeExif(), + recipe_parser_factory=RecipeParserFactory(), + downloader_factory=downloader_factory, + logger=logging.getLogger("test"), + ) + + result = await service.analyze_remote_image( + url="https://civitai.red/images/7076441", + recipe_scanner=DummyScanner(), + civitai_client=FakeCivitaiClient(), + ) + payload = result.payload + + assert payload.get("error") is None + loras = payload.get("loras") or [] + assert len(loras) == 1 + assert loras[0]["hash"] == LORA_SHA256 + assert loras[0].get("isDeleted") in (None, False) + assert "Daphne" in str(payload.get("gen_params", {}).get("prompt"))