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 {