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:
Will Miao
2026-08-28 22:24:07 +08:00
parent a7d65fe84a
commit 856c9a87ac
24 changed files with 757 additions and 28 deletions
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "In Bibliothek", "inLibrary": "In Bibliothek",
"notInLibrary": "Nicht in Bibliothek", "notInLibrary": "Nicht in Bibliothek",
"deleted": "Gelöscht", "deleted": "Gelöscht",
"hashInvalid": "Nicht auflösbarer Hash",
"inLibraryTooltip": "Dieses Modell ist in deiner lokalen Bibliothek vorhanden", "inLibraryTooltip": "Dieses Modell ist in deiner lokalen Bibliothek vorhanden",
"notInLibraryTooltip": "Dieses Modell ist nicht in deiner Bibliothek", "notInLibraryTooltip": "Dieses Modell ist nicht in deiner Bibliothek",
"deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden", "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", "download": "Herunterladen",
"downloadLoraTooltip": "Dieses LoRA herunterladen", "downloadLoraTooltip": "Dieses LoRA herunterladen",
"preparingDownload": "Download wird vorbereitet…", "preparingDownload": "Download wird vorbereitet…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "Checkpoint-Informationen fehlen", "missingCheckpointInfo": "Checkpoint-Informationen fehlen",
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}", "downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
"missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen", "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}", "downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}",
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID", "cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung", "deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "In Library", "inLibrary": "In Library",
"notInLibrary": "Not in Library", "notInLibrary": "Not in Library",
"deleted": "Deleted", "deleted": "Deleted",
"hashInvalid": "Unresolvable Hash",
"inLibraryTooltip": "This model exists in your local library", "inLibraryTooltip": "This model exists in your local library",
"notInLibraryTooltip": "This model is not in your library", "notInLibraryTooltip": "This model is not in your library",
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download", "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", "download": "Download",
"downloadLoraTooltip": "Download this LoRA", "downloadLoraTooltip": "Download this LoRA",
"preparingDownload": "Preparing download...", "preparingDownload": "Preparing download...",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "Missing checkpoint information", "missingCheckpointInfo": "Missing checkpoint information",
"downloadCheckpointFailed": "Failed to download checkpoint: {message}", "downloadCheckpointFailed": "Failed to download checkpoint: {message}",
"missingLoraDownloadInfo": "Missing download information for this LoRA", "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}", "downloadLoraFailed": "Failed to download LoRA: {message}",
"cannotDelete": "Cannot delete recipe: Missing recipe ID", "cannotDelete": "Cannot delete recipe: Missing recipe ID",
"deleteConfirmationError": "Error showing delete confirmation", "deleteConfirmationError": "Error showing delete confirmation",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "En la biblioteca", "inLibrary": "En la biblioteca",
"notInLibrary": "No en la biblioteca", "notInLibrary": "No en la biblioteca",
"deleted": "Eliminado", "deleted": "Eliminado",
"hashInvalid": "Hash irresoluble",
"inLibraryTooltip": "Este modelo existe en tu biblioteca local", "inLibraryTooltip": "Este modelo existe en tu biblioteca local",
"notInLibraryTooltip": "Este modelo no está en tu biblioteca", "notInLibraryTooltip": "Este modelo no está en tu biblioteca",
"deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar", "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", "download": "Descargar",
"downloadLoraTooltip": "Descargar este LoRA", "downloadLoraTooltip": "Descargar este LoRA",
"preparingDownload": "Preparando descarga…", "preparingDownload": "Preparando descarga…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "Falta información del checkpoint", "missingCheckpointInfo": "Falta información del checkpoint",
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}", "downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
"missingLoraDownloadInfo": "Falta la información de descarga de este LoRA", "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}", "downloadLoraFailed": "Error al descargar el LoRA: {message}",
"cannotDelete": "No se puede eliminar receta: Falta ID de receta", "cannotDelete": "No se puede eliminar receta: Falta ID de receta",
"deleteConfirmationError": "Error mostrando confirmación de eliminación", "deleteConfirmationError": "Error mostrando confirmación de eliminación",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "Dans la bibliothèque", "inLibrary": "Dans la bibliothèque",
"notInLibrary": "Pas dans la bibliothèque", "notInLibrary": "Pas dans la bibliothèque",
"deleted": "Supprimé", "deleted": "Supprimé",
"hashInvalid": "Hash irrésolu",
"inLibraryTooltip": "Ce modèle existe dans votre bibliothèque locale", "inLibraryTooltip": "Ce modèle existe dans votre bibliothèque locale",
"notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque", "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é", "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", "download": "Télécharger",
"downloadLoraTooltip": "Télécharger ce LoRA", "downloadLoraTooltip": "Télécharger ce LoRA",
"preparingDownload": "Préparation du téléchargement…", "preparingDownload": "Préparation du téléchargement…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "Informations sur le checkpoint manquantes", "missingCheckpointInfo": "Informations sur le checkpoint manquantes",
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}", "downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
"missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA", "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}", "downloadLoraFailed": "Échec du téléchargement du LoRA : {message}",
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant", "cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression", "deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "בספרייה", "inLibrary": "בספרייה",
"notInLibrary": "לא בספרייה", "notInLibrary": "לא בספרייה",
"deleted": "נמחק", "deleted": "נמחק",
"hashInvalid": "גיבוב לא ניתן לפתרון",
"inLibraryTooltip": "מודל זה קיים בספרייה המקומית שלך", "inLibraryTooltip": "מודל זה קיים בספרייה המקומית שלך",
"notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך", "notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך",
"deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה", "deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה",
"hashInvalidTooltip": "לא ניתן לפתור את הגיבוב של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן",
"download": "הורדה", "download": "הורדה",
"downloadLoraTooltip": "הורד את ה-LoRA הזה", "downloadLoraTooltip": "הורד את ה-LoRA הזה",
"preparingDownload": "מכין את ההורדה…", "preparingDownload": "מכין את ההורדה…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "חסרים פרטי checkpoint", "missingCheckpointInfo": "חסרים פרטי checkpoint",
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}", "downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה", "missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
"hashNotFoundOnCivitai": "לא ניתן לפתור את הגיבוב של ה-LoRA ב-CivitAI - ייתכן שהמודל עודכן או שהגיבוב אינו תקין",
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}", "downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון", "cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה", "deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "ライブラリ内", "inLibrary": "ライブラリ内",
"notInLibrary": "ライブラリ外", "notInLibrary": "ライブラリ外",
"deleted": "削除済み", "deleted": "削除済み",
"hashInvalid": "解決不能なハッシュ",
"inLibraryTooltip": "このモデルはローカルライブラリに存在します", "inLibraryTooltip": "このモデルはローカルライブラリに存在します",
"notInLibraryTooltip": "このモデルはライブラリにありません", "notInLibraryTooltip": "このモデルはライブラリにありません",
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません", "deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
"download": "ダウンロード", "download": "ダウンロード",
"downloadLoraTooltip": "この LoRA をダウンロード", "downloadLoraTooltip": "この LoRA をダウンロード",
"preparingDownload": "ダウンロードを準備中…", "preparingDownload": "ダウンロードを準備中…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "チェックポイント情報が不足しています", "missingCheckpointInfo": "チェックポイント情報が不足しています",
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}", "downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません", "missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}", "downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
"cannotDelete": "レシピを削除できません:レシピIDがありません", "cannotDelete": "レシピを削除できません:レシピIDがありません",
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました", "deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "라이브러리에 있음", "inLibrary": "라이브러리에 있음",
"notInLibrary": "라이브러리에 없음", "notInLibrary": "라이브러리에 없음",
"deleted": "삭제됨", "deleted": "삭제됨",
"hashInvalid": "해석할 수 없는 해시",
"inLibraryTooltip": "이 모델은 로컬 라이브러리에 있습니다", "inLibraryTooltip": "이 모델은 로컬 라이브러리에 있습니다",
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다", "notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다", "deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
"hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
"download": "다운로드", "download": "다운로드",
"downloadLoraTooltip": "이 LoRA 다운로드", "downloadLoraTooltip": "이 LoRA 다운로드",
"preparingDownload": "다운로드 준비 중…", "preparingDownload": "다운로드 준비 중…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "체크포인트 정보가 부족합니다", "missingCheckpointInfo": "체크포인트 정보가 부족합니다",
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}", "downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다", "missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
"downloadLoraFailed": "LoRA 다운로드 실패: {message}", "downloadLoraFailed": "LoRA 다운로드 실패: {message}",
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락", "cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
"deleteConfirmationError": "삭제 확인 표시 오류", "deleteConfirmationError": "삭제 확인 표시 오류",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "В библиотеке", "inLibrary": "В библиотеке",
"notInLibrary": "Не в библиотеке", "notInLibrary": "Не в библиотеке",
"deleted": "Удалено", "deleted": "Удалено",
"hashInvalid": "Нераспознанный хэш",
"inLibraryTooltip": "Эта модель есть в вашей локальной библиотеке", "inLibraryTooltip": "Эта модель есть в вашей локальной библиотеке",
"notInLibraryTooltip": "Этой модели нет в вашей библиотеке", "notInLibraryTooltip": "Этой модели нет в вашей библиотеке",
"deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания", "deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания",
"hashInvalidTooltip": "Этот хэш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена",
"download": "Скачать", "download": "Скачать",
"downloadLoraTooltip": "Скачать этот LoRA", "downloadLoraTooltip": "Скачать этот LoRA",
"preparingDownload": "Подготовка к скачиванию…", "preparingDownload": "Подготовка к скачиванию…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте", "missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}", "downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA", "missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
"hashNotFoundOnCivitai": "Этот хэш LoRA не удаётся распознать на CivitAI - возможно, модель была обновлена или хэш недействителен",
"downloadLoraFailed": "Не удалось скачать LoRA: {message}", "downloadLoraFailed": "Не удалось скачать LoRA: {message}",
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта", "cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
"deleteConfirmationError": "Ошибка отображения подтверждения удаления", "deleteConfirmationError": "Ошибка отображения подтверждения удаления",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "在库中", "inLibrary": "在库中",
"notInLibrary": "不在库中", "notInLibrary": "不在库中",
"deleted": "已删除", "deleted": "已删除",
"hashInvalid": "无法解析的哈希",
"inLibraryTooltip": "该模型已存在于本地库中", "inLibraryTooltip": "该模型已存在于本地库中",
"notInLibraryTooltip": "该模型不在你的本地库中", "notInLibraryTooltip": "该模型不在你的本地库中",
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载", "deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
"download": "下载", "download": "下载",
"downloadLoraTooltip": "下载此 LoRA", "downloadLoraTooltip": "下载此 LoRA",
"preparingDownload": "正在准备下载…", "preparingDownload": "正在准备下载…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "缺少检查点信息", "missingCheckpointInfo": "缺少检查点信息",
"downloadCheckpointFailed": "下载检查点失败:{message}", "downloadCheckpointFailed": "下载检查点失败:{message}",
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息", "missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
"downloadLoraFailed": "下载 LoRA 失败:{message}", "downloadLoraFailed": "下载 LoRA 失败:{message}",
"cannotDelete": "无法删除配方:缺少配方 ID", "cannotDelete": "无法删除配方:缺少配方 ID",
"deleteConfirmationError": "显示删除确认出错", "deleteConfirmationError": "显示删除确认出错",
+3
View File
@@ -889,9 +889,11 @@
"inLibrary": "已在庫存", "inLibrary": "已在庫存",
"notInLibrary": "不在庫存", "notInLibrary": "不在庫存",
"deleted": "已刪除", "deleted": "已刪除",
"hashInvalid": "無法解析的雜湊",
"inLibraryTooltip": "此模型已存在於本地庫", "inLibraryTooltip": "此模型已存在於本地庫",
"notInLibraryTooltip": "此模型不在你的本地庫中", "notInLibraryTooltip": "此模型不在你的本地庫中",
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載", "deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
"download": "下載", "download": "下載",
"downloadLoraTooltip": "下載此 LoRA", "downloadLoraTooltip": "下載此 LoRA",
"preparingDownload": "正在準備下載…", "preparingDownload": "正在準備下載…",
@@ -2046,6 +2048,7 @@
"missingCheckpointInfo": "缺少檢查點資訊", "missingCheckpointInfo": "缺少檢查點資訊",
"downloadCheckpointFailed": "下載檢查點失敗:{message}", "downloadCheckpointFailed": "下載檢查點失敗:{message}",
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊", "missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
"downloadLoraFailed": "下載 LoRA 失敗:{message}", "downloadLoraFailed": "下載 LoRA 失敗:{message}",
"cannotDelete": "無法刪除配方:缺少配方 ID", "cannotDelete": "無法刪除配方:缺少配方 ID",
"deleteConfirmationError": "顯示刪除確認時發生錯誤", "deleteConfirmationError": "顯示刪除確認時發生錯誤",
+6 -8
View File
@@ -146,15 +146,13 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Initialize hashes dict if it doesn't exist # Initialize hashes dict if it doesn't exist
if "hashes" not in metadata: if "hashes" not in metadata:
metadata["hashes"] = {} metadata["hashes"] = {}
# Add as lora type in the same format as # Lora hashes carries the 12-char AutoV3
# regular hashes. Only override an # hash (resolvable on CivitAI and the local
# existing entry if its value is empty # autov3 index); the Hashes JSON value is
# (Lora hashes is the more reliable # only the 10-char AutoV2 prefix, so on
# source when Hashes JSON has blanks). # conflict the Lora hashes value wins.
key = f"lora:{lora_name}" key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "") metadata["hashes"][key] = lora_hash
if not existing:
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section # Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '') params_section = params_section.replace(lora_hashes_match.group(0), '')
+21
View File
@@ -115,6 +115,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
): ):
metadata = inner_meta 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 # Initialize result structure
result: Dict[str, Any] = { result: Dict[str, Any] = {
"base_model": None, "base_model": None,
+45 -8
View File
@@ -113,6 +113,7 @@ class RecipeHandlerSet:
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open, "record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk, "move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
@@ -1592,6 +1593,35 @@ class RecipeManagementHandler:
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True) self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) 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: async def bulk_delete(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -2183,14 +2213,21 @@ class RecipeManagementHandler:
civitai_base_model = civitai_parsed.get("base_model") civitai_base_model = civitai_parsed.get("base_model")
if civitai_base_model and not metadata.get("base_model"): if civitai_base_model and not metadata.get("base_model"):
metadata["base_model"] = civitai_base_model metadata["base_model"] = civitai_base_model
elif parsed_embedded:
parsed_loras = parsed_embedded.get("loras") # EXIF fills whatever the API-only parse left open — when the image
if parsed_loras and not metadata.get("loras"): # API meta is null (only modelVersionIds present) the API parse
metadata["loras"] = parsed_loras # yields a checkpoint but no LoRAs, while the image EXIF carries the
parsed_model = parsed_embedded.get("model") # full resource list.
if parsed_model and not metadata.get("checkpoint"): if parsed_embedded:
metadata["checkpoint"] = parsed_model if not metadata.get("loras"):
if parsed_embedded.get("base_model") and not metadata.get("base_model"): 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"] metadata["base_model"] = parsed_embedded["base_model"]
civitai_client = self._civitai_client_getter() civitai_client = self._civitai_client_getter()
+3
View File
@@ -49,6 +49,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), 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/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("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition( RouteDefinition(
+72 -3
View File
@@ -18,7 +18,11 @@ from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats from ..utils.recipe_open_stats import RecipeOpenStats
from .model_scanner import WEIGHT_FILE_EXTENSIONS from .model_scanner import WEIGHT_FILE_EXTENSIONS
from .recipe_cache import RecipeCache 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 .websocket_manager import ws_manager
from natsort import natsorted from natsort import natsorted
import sys import sys
@@ -241,11 +245,23 @@ class RecipeScanner:
return cache return cache
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool: 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): if not isinstance(entry, dict):
return False return False
unresolved = ( 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 = ( has_identifier = (
entry.get("hash") entry.get("hash")
@@ -1262,6 +1278,7 @@ class RecipeScanner:
) -> None: ) -> None:
"""Write back a matched local model to a lora recipe entry.""" """Write back a matched local model to a lora recipe entry."""
entry["isDeleted"] = False entry["isDeleted"] = False
entry["hashInvalid"] = False
# Only truthy hashes are written — pending/failed items carry an empty # Only truthy hashes are written — pending/failed items carry an empty
# sha256 and an unconditional write would wipe a valid stored hash. # sha256 and an unconditional write would wipe a valid stored hash.
@@ -3661,6 +3678,7 @@ class RecipeScanner:
lora_entry = loras[lora_index] lora_entry = loras[lora_index]
lora_entry["isDeleted"] = False lora_entry["isDeleted"] = False
lora_entry["hashInvalid"] = False
lora_entry["exclude"] = False lora_entry["exclude"] = False
lora_entry["file_name"] = target_name lora_entry["file_name"] = target_name
@@ -3712,6 +3730,57 @@ class RecipeScanner:
updated_lora = self._enrich_lora_entry(updated_lora) updated_lora = self._enrich_lora_entry(updated_lora)
return recipe_data, 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]]: async def get_recipes_for_lora(self, lora_hash: str) -> List[Dict[str, Any]]:
"""Return recipes that reference a given LoRA hash.""" """Return recipes that reference a given LoRA hash."""
+16
View File
@@ -270,6 +270,22 @@ class RecipeAnalysisService:
if merged_gp: if merged_gp:
result.payload["gen_params"] = 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"): if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds # Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass # 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( async def bulk_delete(
self, self,
*, *,
@@ -793,6 +823,7 @@ class RecipePersistenceService:
"modelName": lora.get("name", ""), "modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""), "modelVersionName": lora.get("version", ""),
"isDeleted": lora.get("isDeleted", False), "isDeleted": lora.get("isDeleted", False),
"hashInvalid": lora.get("hashInvalid", False),
"exclude": lora.get("exclude", False), "exclude": lora.get("exclude", False),
} }
+32 -3
View File
@@ -850,7 +850,8 @@
} }
.local-badge, .local-badge,
.missing-badge { .missing-badge,
.invalid-hash-badge {
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
@@ -861,7 +862,8 @@
/* Specific styles for recipe modal badges - update z-index */ /* Specific styles for recipe modal badges - update z-index */
.recipe-lora-header .local-badge, .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 */ z-index: 2; /* Ensure the badge is above other elements */
backface-visibility: hidden; backface-visibility: hidden;
} }
@@ -903,6 +905,26 @@
font-size: 0.9em; 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 /* Deleted badge is a pure status indicator; the reconnect action lives on
an explicit ghost button in the item's action row. */ 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 */ /* Badges are pure status indicators; actions live in .recipe-lora-actions */
.badge-container .local-badge, .badge-container .local-badge,
.badge-container .missing-badge, .badge-container .missing-badge,
.badge-container .deleted-badge { .badge-container .deleted-badge,
.badge-container .invalid-hash-badge {
position: static; /* Override absolute positioning */ position: static; /* Override absolute positioning */
transform: none; /* Remove the transform */ transform: none; /* Remove the transform */
} }
@@ -1150,6 +1173,12 @@
border: 1px solid rgba(127, 127, 127, 0.35); 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 /* Missing LoRAs status is a real button: the affordance must be visible at
rest (persistent border), not only on hover. */ rest (persistent border), not only on hover. */
.recipe-status.missing.clickable { .recipe-status.missing.clickable {
+41 -4
View File
@@ -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'))}"> <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'))} <i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
</div>`; </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 { } else {
statusBadge = ` statusBadge = `
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}"> <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 = []; const controls = [];
if (isDeleted) { if (isDeleted || lora.hashInvalid) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect'); const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA'); const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(` controls.push(`
@@ -2032,7 +2037,7 @@ class RecipeModal {
const loraIndex = parseInt(button.dataset.loraIndex, 10); const loraIndex = parseInt(button.dataset.loraIndex, 10);
const lora = this.currentRecipe?.loras?.[loraIndex]; const lora = this.currentRecipe?.loras?.[loraIndex];
if (lora) { 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)) { if (!this.canDownloadLora(lora)) {
showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error'); showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error');
return; return;
@@ -2141,7 +2146,12 @@ class RecipeModal {
state.loadingManager.hide(); state.loadingManager.hide();
} }
if (!identifiers) { 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; 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) { navigateToCheckpointPage(checkpoint) {
const checkpointHash = this._getCheckpointHash(checkpoint); const checkpointHash = this._getCheckpointHash(checkpoint);
@@ -142,6 +142,14 @@ const hashOnlyLora = {
hash: 'deadbeefcafe', hash: 'deadbeefcafe',
}; };
const hashInvalidLora = {
name: 'invalid-hash-lora',
modelName: 'Invalid Hash LoRA',
inLibrary: false,
hash: 'a2a12bfa01',
hashInvalid: true,
};
const recipeWithResources = { const recipeWithResources = {
id: 'recipe-resources', id: 'recipe-resources',
file_path: '/recipes/resources.json', file_path: '/recipes/resources.json',
@@ -157,6 +165,7 @@ const recipeWithResources = {
{ name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' }, { name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' },
missingLora, missingLora,
{ name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true }, { name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true },
hashInvalidLora,
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
hashOnlyLora, hashOnlyLora,
], ],
@@ -321,11 +330,67 @@ describe('RecipeModal resource item interactions', () => {
expect(container.classList.contains('active')).toBe(true); 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 () => { it('renders no action row when neither identifiers nor hash are available', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources); 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(); expect(mysteryItem.querySelector('.lora-download')).toBeNull();
// No actions at all -> no empty action row taking vertical space // No actions at all -> no empty action row taking vertical space
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull(); expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
@@ -348,7 +413,7 @@ describe('RecipeModal resource item interactions', () => {
recipeModal.showRecipeDetails(recipeWithResources); recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring(); await flushWiring();
const hashItem = document.querySelector('[data-lora-index="4"]'); const hashItem = document.querySelector('[data-lora-index="5"]');
const downloadButton = hashItem.querySelector('.lora-download'); const downloadButton = hashItem.querySelector('.lora-download');
expect(downloadButton).not.toBeNull(); 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" 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 @pytest.mark.asyncio
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch): async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
async def fake_metadata_provider(): 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 provider.hash_calls == ["missdedup123"]
assert len(result["loras"]) == 1 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
+61
View File
@@ -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 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 @pytest.mark.asyncio
async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner): async def test_load_recipe_rewrites_missing_image_path(tmp_path: Path, recipe_scanner):
scanner, _ = 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) 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 # _match_rematch_entry — L1 hash-cache lookup
+180
View File
@@ -1313,3 +1313,183 @@ async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp
await service.reconnect_lora( await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing" 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"))