mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-29 08:51:27 -03:00
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "שגיאה בהצגת אישור המחיקה",
|
||||
|
||||
@@ -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": "削除確認の表示中にエラーが発生しました",
|
||||
|
||||
@@ -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": "삭제 확인 표시 오류",
|
||||
|
||||
@@ -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": "Ошибка отображения подтверждения удаления",
|
||||
|
||||
@@ -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": "显示删除确认出错",
|
||||
|
||||
@@ -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": "顯示刪除確認時發生錯誤",
|
||||
|
||||
@@ -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), '')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -897,6 +897,11 @@ class RecipeModal {
|
||||
<div class="deleted-badge" title="${escapeHtml(translate('recipes.resources.deletedTooltip', {}, 'This LoRA was deleted from the source and is no longer available for download'))}">
|
||||
<i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
|
||||
</div>`;
|
||||
} else if (lora.hashInvalid) {
|
||||
statusBadge = `
|
||||
<div class="invalid-hash-badge" title="${escapeHtml(translate('recipes.resources.hashInvalidTooltip', {}, 'This LoRA hash cannot be resolved on CivitAI - the model may have been updated'))}">
|
||||
<i class="fas fa-question-circle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.hashInvalid', {}, 'Unresolvable Hash'))}
|
||||
</div>`;
|
||||
} else {
|
||||
statusBadge = `
|
||||
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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, <lora:Daphne Blake Cosplay_v1:1> "
|
||||
"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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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, <lora:Daphne Blake Cosplay_v1:1> 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"))
|
||||
|
||||
Reference in New Issue
Block a user