mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: Add recipe metadata repair functionality with UI, API, and progress tracking.
This commit is contained in:
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Recipe-Daten reparieren",
|
||||||
|
"loading": "Recipe-Daten werden repariert...",
|
||||||
|
"success": "{count} Rezepte erfolgreich repariert.",
|
||||||
|
"error": "Recipe-Reparatur fehlgeschlagen: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "Vorschau ersetzen",
|
"replacePreview": "Vorschau ersetzen",
|
||||||
"setContentRating": "Inhaltsbewertung festlegen",
|
"setContentRating": "Inhaltsbewertung festlegen",
|
||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
"deleteModel": "Modell löschen",
|
"deleteModel": "Modell löschen",
|
||||||
"shareRecipe": "Rezept teilen",
|
"shareRecipe": "Rezept teilen",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Repair recipes data",
|
||||||
|
"loading": "Repairing recipe data...",
|
||||||
|
"success": "Successfully repaired {count} recipes.",
|
||||||
|
"error": "Recipe repair failed: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "Replace Preview",
|
"replacePreview": "Replace Preview",
|
||||||
"setContentRating": "Set Content Rating",
|
"setContentRating": "Set Content Rating",
|
||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
|
"repairMetadata": "Repair metadata",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
"deleteModel": "Delete Model",
|
"deleteModel": "Delete Model",
|
||||||
"shareRecipe": "Share Recipe",
|
"shareRecipe": "Share Recipe",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
"prepareError": "Error preparing LoRAs for download: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Repairing recipe metadata...",
|
||||||
|
"success": "Recipe metadata repaired successfully",
|
||||||
|
"skipped": "Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "Failed to repair recipe: {message}",
|
||||||
|
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Reparar datos de recetas",
|
||||||
|
"loading": "Reparando datos de recetas...",
|
||||||
|
"success": "Se repararon con éxito {count} recetas.",
|
||||||
|
"error": "Error al reparar recetas: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "Reemplazar vista previa",
|
"replacePreview": "Reemplazar vista previa",
|
||||||
"setContentRating": "Establecer clasificación de contenido",
|
"setContentRating": "Establecer clasificación de contenido",
|
||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
"deleteModel": "Eliminar modelo",
|
"deleteModel": "Eliminar modelo",
|
||||||
"shareRecipe": "Compartir receta",
|
"shareRecipe": "Compartir receta",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Réparer les données de recettes",
|
||||||
|
"loading": "Réparation des données de recettes...",
|
||||||
|
"success": "{count} recettes réparées avec succès.",
|
||||||
|
"error": "Échec de la réparation des recettes : {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "Remplacer l'aperçu",
|
"replacePreview": "Remplacer l'aperçu",
|
||||||
"setContentRating": "Définir la classification du contenu",
|
"setContentRating": "Définir la classification du contenu",
|
||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
"deleteModel": "Supprimer le modèle",
|
"deleteModel": "Supprimer le modèle",
|
||||||
"shareRecipe": "Partager la recipe",
|
"shareRecipe": "Partager la recipe",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "תיקון נתוני מתכונים",
|
||||||
|
"loading": "מתקן נתוני מתכונים...",
|
||||||
|
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||||
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
"deleteModel": "מחק מודל",
|
"deleteModel": "מחק מודל",
|
||||||
"shareRecipe": "שתף מתכון",
|
"shareRecipe": "שתף מתכון",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "レシピデータの修復",
|
||||||
|
"loading": "レシピデータを修復中...",
|
||||||
|
"success": "{count} 件のレシピを正常に修復しました。",
|
||||||
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
"deleteModel": "モデルを削除",
|
"deleteModel": "モデルを削除",
|
||||||
"shareRecipe": "レシピを共有",
|
"shareRecipe": "レシピを共有",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "레시피 데이터 복구",
|
||||||
|
"loading": "레시피 데이터 복구 중...",
|
||||||
|
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||||
|
"error": "레시피 복구 실패: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
"deleteModel": "모델 삭제",
|
"deleteModel": "모델 삭제",
|
||||||
"shareRecipe": "레시피 공유",
|
"shareRecipe": "레시피 공유",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Восстановить данные рецептов",
|
||||||
|
"loading": "Восстановление данных рецептов...",
|
||||||
|
"success": "Успешно восстановлено {count} рецептов.",
|
||||||
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
"deleteModel": "Удалить модель",
|
"deleteModel": "Удалить модель",
|
||||||
"shareRecipe": "Поделиться рецептом",
|
"shareRecipe": "Поделиться рецептом",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,6 +159,12 @@
|
|||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "Updated license metadata for {count} {typePlural}",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修复配方数据",
|
||||||
|
"loading": "正在修复配方数据...",
|
||||||
|
"success": "成功修复了 {count} 个配方。",
|
||||||
|
"error": "配方修复失败:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "删除模型",
|
"deleteModel": "删除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "浏览器插件教程"
|
"learnMore": "浏览器插件教程"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,11 +154,17 @@
|
|||||||
"error": "清理範例圖片資料夾失敗:{message}"
|
"error": "清理範例圖片資料夾失敗:{message}"
|
||||||
},
|
},
|
||||||
"fetchMissingLicenses": {
|
"fetchMissingLicenses": {
|
||||||
"label": "Refresh license metadata",
|
"label": "重新整理授權中繼資料",
|
||||||
"loading": "Refreshing license metadata for {typePlural}...",
|
"loading": "正在重新整理 {typePlural} 的授權中繼資料...",
|
||||||
"success": "Updated license metadata for {count} {typePlural}",
|
"success": "已更新 {count} 個 {typePlural} 的授權中繼資料",
|
||||||
"none": "All {typePlural} already have license metadata",
|
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||||
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修復配方資料",
|
||||||
|
"loading": "正在修復配方資料...",
|
||||||
|
"success": "成功修復 {count} 個配方。",
|
||||||
|
"error": "配方修復失敗:{message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"header": {
|
"header": {
|
||||||
@@ -520,6 +526,7 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
|
"repairMetadata": "[TODO: Translate] Repair metadata",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
"deleteModel": "刪除模型",
|
"deleteModel": "刪除模型",
|
||||||
"shareRecipe": "分享配方",
|
"shareRecipe": "分享配方",
|
||||||
@@ -635,6 +642,13 @@
|
|||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||||
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "[TODO: Translate] Repairing recipe metadata...",
|
||||||
|
"success": "[TODO: Translate] Recipe metadata repaired successfully",
|
||||||
|
"skipped": "[TODO: Translate] Recipe already at latest version, no repair needed",
|
||||||
|
"failed": "[TODO: Translate] Failed to repair recipe: {message}",
|
||||||
|
"missingId": "[TODO: Translate] Cannot repair recipe: Missing recipe ID"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1498,4 +1512,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional
|
||||||
@@ -26,6 +27,7 @@ from ...services.metadata_service import get_default_metadata_provider
|
|||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
|
from ...services.websocket_manager import ws_manager as default_ws_manager
|
||||||
|
|
||||||
Logger = logging.Logger
|
Logger = logging.Logger
|
||||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
@@ -74,6 +76,9 @@ class RecipeHandlerSet:
|
|||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
"move_recipe": self.management.move_recipe,
|
"move_recipe": self.management.move_recipe,
|
||||||
|
"repair_recipes": self.management.repair_recipes,
|
||||||
|
"repair_recipe": self.management.repair_recipe,
|
||||||
|
"get_repair_progress": self.management.get_repair_progress,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -479,6 +484,7 @@ class RecipeManagementHandler:
|
|||||||
analysis_service: RecipeAnalysisService,
|
analysis_service: RecipeAnalysisService,
|
||||||
downloader_factory,
|
downloader_factory,
|
||||||
civitai_client_getter: CivitaiClientGetter,
|
civitai_client_getter: CivitaiClientGetter,
|
||||||
|
ws_manager=default_ws_manager,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||||
self._recipe_scanner_getter = recipe_scanner_getter
|
self._recipe_scanner_getter = recipe_scanner_getter
|
||||||
@@ -487,6 +493,7 @@ class RecipeManagementHandler:
|
|||||||
self._analysis_service = analysis_service
|
self._analysis_service = analysis_service
|
||||||
self._downloader_factory = downloader_factory
|
self._downloader_factory = downloader_factory
|
||||||
self._civitai_client_getter = civitai_client_getter
|
self._civitai_client_getter = civitai_client_getter
|
||||||
|
self._ws_manager = ws_manager
|
||||||
|
|
||||||
async def save_recipe(self, request: web.Request) -> web.Response:
|
async def save_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
@@ -514,6 +521,70 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
self._logger.error("Error saving recipe: %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 repair_recipes(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
# Check if already running
|
||||||
|
if self._ws_manager.get_recipe_repair_progress():
|
||||||
|
return web.json_response({"success": False, "error": "Recipe repair already in progress"}, status=409)
|
||||||
|
|
||||||
|
async def progress_callback(data):
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress(data)
|
||||||
|
|
||||||
|
# Run in background to avoid timeout
|
||||||
|
async def run_repair():
|
||||||
|
try:
|
||||||
|
await recipe_scanner.repair_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(f"Error in recipe repair task: {e}", exc_info=True)
|
||||||
|
await self._ws_manager.broadcast_recipe_repair_progress({
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
# Keep the final status for a while so the UI can see it
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
self._ws_manager.cleanup_recipe_repair_progress()
|
||||||
|
|
||||||
|
asyncio.create_task(run_repair())
|
||||||
|
|
||||||
|
return web.json_response({"success": True, "message": "Recipe repair started"})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
return web.json_response({"success": False, "error": "Recipe scanner unavailable"}, status=503)
|
||||||
|
|
||||||
|
recipe_id = request.match_info["recipe_id"]
|
||||||
|
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||||
|
return web.json_response(result)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
progress = self._ws_manager.get_recipe_repair_progress()
|
||||||
|
if progress:
|
||||||
|
return web.json_response({"success": True, "progress": progress})
|
||||||
|
return web.json_response({"success": False, "message": "No repair in progress"}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipes/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
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"),
|
||||||
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
RouteDefinition("POST", "/api/lm/recipes/save-from-widget", "save_recipe_from_widget"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
RouteDefinition("GET", "/api/lm/recipes/for-lora", "get_recipes_for_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from .recipes.errors import RecipeNotFoundError
|
|||||||
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
from ..utils.utils import calculate_recipe_fingerprint, fuzzy_match
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
import sys
|
import sys
|
||||||
|
import re
|
||||||
|
from ..recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -54,6 +56,8 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
REPAIR_VERSION = 2
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
lora_scanner: Optional[LoraScanner] = None,
|
lora_scanner: Optional[LoraScanner] = None,
|
||||||
@@ -109,6 +113,283 @@ class RecipeScanner:
|
|||||||
self._civitai_client = await ServiceRegistry.get_civitai_client()
|
self._civitai_client = await ServiceRegistry.get_civitai_client()
|
||||||
return self._civitai_client
|
return self._civitai_client
|
||||||
|
|
||||||
|
async def repair_all_recipes(
|
||||||
|
self,
|
||||||
|
progress_callback: Optional[Callable[[Dict], Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Repair all recipes by enrichment with Civitai and embedded metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
persistence_service: Service for saving updated recipes
|
||||||
|
progress_callback: Optional callback for progress updates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict summary of repair results
|
||||||
|
"""
|
||||||
|
async with self._mutation_lock:
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
all_recipes = list(cache.raw_data)
|
||||||
|
total = len(all_recipes)
|
||||||
|
repaired_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
errors_count = 0
|
||||||
|
|
||||||
|
civitai_client = await self._get_civitai_client()
|
||||||
|
|
||||||
|
for i, recipe in enumerate(all_recipes):
|
||||||
|
try:
|
||||||
|
# Report progress
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback({
|
||||||
|
"status": "processing",
|
||||||
|
"current": i + 1,
|
||||||
|
"total": total,
|
||||||
|
"recipe_name": recipe.get("name", "Unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
if await self._repair_single_recipe(recipe, civitai_client):
|
||||||
|
repaired_count += 1
|
||||||
|
else:
|
||||||
|
skipped_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error repairing recipe {recipe.get('file_path')}: {e}")
|
||||||
|
errors_count += 1
|
||||||
|
|
||||||
|
# Final progress update
|
||||||
|
if progress_callback:
|
||||||
|
await progress_callback({
|
||||||
|
"status": "completed",
|
||||||
|
"repaired": repaired_count,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors_count,
|
||||||
|
"total": total
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repaired": repaired_count,
|
||||||
|
"skipped": skipped_count,
|
||||||
|
"errors": errors_count,
|
||||||
|
"total": total
|
||||||
|
}
|
||||||
|
|
||||||
|
async def repair_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||||
|
"""Repair a single recipe by its ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe_id: ID of the recipe to repair
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict summary of repair result
|
||||||
|
"""
|
||||||
|
async with self._mutation_lock:
|
||||||
|
recipe = await self.get_recipe_by_id(recipe_id)
|
||||||
|
if not recipe:
|
||||||
|
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||||
|
|
||||||
|
civitai_client = await self._get_civitai_client()
|
||||||
|
success = await self._repair_single_recipe(recipe, civitai_client)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"repaired": 1 if success else 0,
|
||||||
|
"skipped": 0 if success else 1,
|
||||||
|
"recipe": recipe
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _repair_single_recipe(self, recipe: Dict[str, Any], civitai_client: Any) -> bool:
|
||||||
|
"""Internal helper to repair a single recipe object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
recipe: The recipe dictionary to repair (modified in-place)
|
||||||
|
civitai_client: Authenticated Civitai client
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if recipe was repaired or updated, False if skipped
|
||||||
|
"""
|
||||||
|
# 1. Skip if already at latest repair version
|
||||||
|
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 2. Identification: Is repair needed?
|
||||||
|
has_checkpoint = "checkpoint" in recipe and recipe["checkpoint"] and recipe["checkpoint"].get("name")
|
||||||
|
gen_params = recipe.get("gen_params", {})
|
||||||
|
has_prompt = bool(gen_params.get("prompt"))
|
||||||
|
|
||||||
|
needs_repair = not has_checkpoint or not has_prompt
|
||||||
|
|
||||||
|
if not needs_repair:
|
||||||
|
# Even if no repair needed, we mark it with version if it was processed
|
||||||
|
if "repair_version" not in recipe:
|
||||||
|
recipe["repair_version"] = self.REPAIR_VERSION
|
||||||
|
await self._save_recipe_persistently(recipe)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 3. Data Fetching & Merging
|
||||||
|
source_url = recipe.get("source_url", "")
|
||||||
|
civitai_meta = None
|
||||||
|
model_version_id = None
|
||||||
|
|
||||||
|
# Check if it's a Civitai image URL
|
||||||
|
image_id_match = re.search(r'civitai\.com/images/(\d+)', source_url)
|
||||||
|
if image_id_match:
|
||||||
|
image_id = image_id_match.group(1)
|
||||||
|
image_info = await civitai_client.get_image_info(image_id)
|
||||||
|
if image_info:
|
||||||
|
if "meta" in image_info:
|
||||||
|
civitai_meta = image_info["meta"]
|
||||||
|
model_version_id = image_info.get("modelVersionId")
|
||||||
|
|
||||||
|
# Merge with existing data
|
||||||
|
new_gen_params = GenParamsMerger.merge(
|
||||||
|
civitai_meta=civitai_meta,
|
||||||
|
embedded_metadata=gen_params
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = False
|
||||||
|
if new_gen_params != gen_params:
|
||||||
|
recipe["gen_params"] = new_gen_params
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# 4. Update checkpoint if missing or repairable
|
||||||
|
if not has_checkpoint:
|
||||||
|
metadata_provider = await get_default_metadata_provider()
|
||||||
|
|
||||||
|
target_version_id = model_version_id or new_gen_params.get("modelVersionId")
|
||||||
|
target_hash = new_gen_params.get("Model hash")
|
||||||
|
|
||||||
|
civitai_info = None
|
||||||
|
if target_version_id:
|
||||||
|
civitai_info = await metadata_provider.get_model_version_info(str(target_version_id))
|
||||||
|
elif target_hash:
|
||||||
|
civitai_info = await metadata_provider.get_model_by_hash(target_hash)
|
||||||
|
|
||||||
|
if civitai_info and not (isinstance(civitai_info, tuple) and civitai_info[1] == "Model not found"):
|
||||||
|
recipe["checkpoint"] = await self._populate_checkpoint(civitai_info)
|
||||||
|
updated = True
|
||||||
|
else:
|
||||||
|
# Fallback to name extraction
|
||||||
|
cp_name = new_gen_params.get("Checkpoint") or new_gen_params.get("checkpoint")
|
||||||
|
if cp_name:
|
||||||
|
recipe["checkpoint"] = {
|
||||||
|
"name": cp_name,
|
||||||
|
"file_name": os.path.splitext(cp_name)[0]
|
||||||
|
}
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
# 5. Mark version and save
|
||||||
|
recipe["repair_version"] = self.REPAIR_VERSION
|
||||||
|
await self._save_recipe_persistently(recipe)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||||
|
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||||
|
recipe_id = recipe.get("id")
|
||||||
|
if not recipe_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Sanitize for storage (remove runtime convenience fields)
|
||||||
|
clean_recipe = self._sanitize_recipe_for_storage(recipe)
|
||||||
|
|
||||||
|
# 2. Update the original dictionary so that we persist the clean version
|
||||||
|
# globally if needed, effectively overwriting it in-place.
|
||||||
|
recipe.clear()
|
||||||
|
recipe.update(clean_recipe)
|
||||||
|
|
||||||
|
# 3. Save JSON
|
||||||
|
with open(recipe_json_path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(recipe, f, indent=4, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 4. Update EXIF if image exists
|
||||||
|
image_path = recipe.get('file_path')
|
||||||
|
if image_path and os.path.exists(image_path):
|
||||||
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
ExifUtils.append_recipe_metadata(image_path, recipe)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error persisting recipe {recipe_id}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _populate_checkpoint(self, civitai_info_tuple: Any) -> Dict[str, Any]:
|
||||||
|
"""Helper to populate checkpoint info using common logic."""
|
||||||
|
civitai_data, error_msg = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||||
|
|
||||||
|
checkpoint = {
|
||||||
|
"name": "",
|
||||||
|
"file_name": "",
|
||||||
|
"isDeleted": False,
|
||||||
|
"hash": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if not civitai_data or error_msg == "Model not found":
|
||||||
|
checkpoint["isDeleted"] = True
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
|
try:
|
||||||
|
if "model" in civitai_data and "name" in civitai_data["model"]:
|
||||||
|
checkpoint["name"] = civitai_data["model"]["name"]
|
||||||
|
|
||||||
|
if "name" in civitai_data:
|
||||||
|
checkpoint["version"] = civitai_data.get("name", "")
|
||||||
|
|
||||||
|
if "images" in civitai_data and civitai_data["images"]:
|
||||||
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
image_url = civitai_data["images"][0].get("url")
|
||||||
|
if image_url:
|
||||||
|
rewritten_url, _ = rewrite_preview_url(image_url, media_type="image")
|
||||||
|
checkpoint["thumbnailUrl"] = rewritten_url or image_url
|
||||||
|
|
||||||
|
checkpoint["baseModel"] = civitai_data.get("baseModel", "")
|
||||||
|
checkpoint["modelId"] = civitai_data.get("modelId", 0)
|
||||||
|
checkpoint["id"] = civitai_data.get("id", 0)
|
||||||
|
|
||||||
|
if "files" in civitai_data:
|
||||||
|
model_file = next((f for f in civitai_data.get("files", []) if f.get("type") == "Model"), None)
|
||||||
|
if model_file:
|
||||||
|
sha256 = model_file.get("hashes", {}).get("SHA256")
|
||||||
|
if sha256:
|
||||||
|
checkpoint["hash"] = sha256.lower()
|
||||||
|
f_name = model_file.get("name", "")
|
||||||
|
if f_name:
|
||||||
|
checkpoint["file_name"] = os.path.splitext(f_name)[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error populating checkpoint: {e}")
|
||||||
|
|
||||||
|
return checkpoint
|
||||||
|
|
||||||
|
def _sanitize_recipe_for_storage(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Create a clean copy of the recipe without runtime convenience fields."""
|
||||||
|
import copy
|
||||||
|
clean = copy.deepcopy(recipe)
|
||||||
|
|
||||||
|
# 1. Clean LORAs
|
||||||
|
if "loras" in clean and isinstance(clean["loras"], list):
|
||||||
|
for lora in clean["loras"]:
|
||||||
|
# Fields to remove (runtime only)
|
||||||
|
for key in ("inLibrary", "preview_url", "localPath"):
|
||||||
|
lora.pop(key, None)
|
||||||
|
|
||||||
|
# Normalize weight/strength if mapping is desired (standard in persistence_service)
|
||||||
|
if "weight" in lora and "strength" not in lora:
|
||||||
|
lora["strength"] = float(lora.pop("weight"))
|
||||||
|
|
||||||
|
# 2. Clean Checkpoint
|
||||||
|
if "checkpoint" in clean and isinstance(clean["checkpoint"], dict):
|
||||||
|
cp = clean["checkpoint"]
|
||||||
|
# Fields to remove (runtime only)
|
||||||
|
for key in ("inLibrary", "localPath", "preview_url", "thumbnailUrl", "size", "downloadUrl"):
|
||||||
|
cp.pop(key, None)
|
||||||
|
|
||||||
|
return clean
|
||||||
|
|
||||||
async def initialize_in_background(self) -> None:
|
async def initialize_in_background(self) -> None:
|
||||||
"""Initialize cache in background using thread pool"""
|
"""Initialize cache in background using thread pool"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict] = {}
|
self._last_init_progress: Dict[str, Dict] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict] = None
|
self._auto_organize_progress: Optional[Dict] = None
|
||||||
|
# Add recipe repair progress tracking
|
||||||
|
self._recipe_repair_progress: Optional[Dict] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
async def handle_connection(self, request: web.Request) -> web.WebSocketResponse:
|
||||||
@@ -189,6 +191,14 @@ class WebSocketManager:
|
|||||||
# Broadcast via WebSocket
|
# Broadcast via WebSocket
|
||||||
await self.broadcast(data)
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
async def broadcast_recipe_repair_progress(self, data: Dict):
|
||||||
|
"""Broadcast recipe repair progress to connected clients"""
|
||||||
|
# Store progress data in memory
|
||||||
|
self._recipe_repair_progress = data
|
||||||
|
|
||||||
|
# Broadcast via WebSocket
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
def get_auto_organize_progress(self) -> Optional[Dict]:
|
def get_auto_organize_progress(self) -> Optional[Dict]:
|
||||||
"""Get current auto-organize progress"""
|
"""Get current auto-organize progress"""
|
||||||
return self._auto_organize_progress
|
return self._auto_organize_progress
|
||||||
@@ -197,6 +207,14 @@ class WebSocketManager:
|
|||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
def get_recipe_repair_progress(self) -> Optional[Dict]:
|
||||||
|
"""Get current recipe repair progress"""
|
||||||
|
return self._recipe_repair_progress
|
||||||
|
|
||||||
|
def cleanup_recipe_repair_progress(self):
|
||||||
|
"""Clear recipe repair progress data"""
|
||||||
|
self._recipe_repair_progress = None
|
||||||
|
|
||||||
def is_auto_organize_running(self) -> bool:
|
def is_auto_organize_running(self) -> bool:
|
||||||
"""Check if auto-organize is currently running"""
|
"""Check if auto-organize is currently running"""
|
||||||
if not self._auto_organize_progress:
|
if not self._auto_organize_progress:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden; /* Disable default scrolling */
|
overflow: hidden;
|
||||||
|
/* Disable default scrolling */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 针对Firefox */
|
/* 针对Firefox */
|
||||||
@@ -58,12 +60,12 @@ html, body {
|
|||||||
--badge-update-bg: oklch(72% 0.2 220);
|
--badge-update-bg: oklch(72% 0.2 220);
|
||||||
--badge-update-text: oklch(28% 0.03 220);
|
--badge-update-text: oklch(28% 0.03 220);
|
||||||
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
--badge-update-glow: oklch(72% 0.2 220 / 0.28);
|
||||||
|
|
||||||
/* Spacing Scale */
|
/* Spacing Scale */
|
||||||
--space-1: calc(8px * 1);
|
--space-1: calc(8px * 1);
|
||||||
--space-2: calc(8px * 2);
|
--space-2: calc(8px * 2);
|
||||||
--space-3: calc(8px * 3);
|
--space-3: calc(8px * 3);
|
||||||
|
|
||||||
/* Z-index Scale */
|
/* Z-index Scale */
|
||||||
--z-base: 10;
|
--z-base: 10;
|
||||||
--z-header: 100;
|
--z-header: 100;
|
||||||
@@ -75,8 +77,9 @@ html, body {
|
|||||||
--border-radius-sm: 8px;
|
--border-radius-sm: 8px;
|
||||||
--border-radius-xs: 4px;
|
--border-radius-xs: 4px;
|
||||||
|
|
||||||
--scrollbar-width: 8px; /* 添加滚动条宽度变量 */
|
--scrollbar-width: 8px;
|
||||||
|
/* 添加滚动条宽度变量 */
|
||||||
|
|
||||||
/* Shortcut styles */
|
/* Shortcut styles */
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.12);
|
||||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.25);
|
||||||
@@ -104,7 +107,8 @@ html[data-theme="light"] {
|
|||||||
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
--lora-surface: oklch(25% 0.02 256 / 0.98);
|
||||||
--lora-border: oklch(90% 0.02 256 / 0.15);
|
--lora-border: oklch(90% 0.02 256 / 0.15);
|
||||||
--lora-text: oklch(98% 0.02 256);
|
--lora-text: oklch(98% 0.02 256);
|
||||||
--lora-warning: oklch(75% 0.25 80); /* Modified to be used with oklch() */
|
--lora-warning: oklch(75% 0.25 80);
|
||||||
|
/* Modified to be used with oklch() */
|
||||||
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
--lora-error-bg: color-mix(in oklch, var(--lora-error) 15%, transparent);
|
||||||
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
--lora-error-border: color-mix(in oklch, var(--lora-error) 40%, transparent);
|
||||||
--badge-update-bg: oklch(62% 0.18 220);
|
--badge-update-bg: oklch(62% 0.18 220);
|
||||||
@@ -118,5 +122,10 @@ body {
|
|||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-top: 0; /* Remove the padding-top */
|
padding-top: 0;
|
||||||
|
/* Remove the padding-top */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
@@ -15,6 +15,29 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
showMenu(x, y, origin = null) {
|
showMenu(x, y, origin = null) {
|
||||||
const contextOrigin = origin || { type: 'global' };
|
const contextOrigin = origin || { type: 'global' };
|
||||||
|
|
||||||
|
// Conditional visibility for recipes page
|
||||||
|
const isRecipesPage = state.currentPageType === 'recipes';
|
||||||
|
const modelUpdateItem = this.menu.querySelector('[data-action="check-model-updates"]');
|
||||||
|
const licenseRefreshItem = this.menu.querySelector('[data-action="fetch-missing-licenses"]');
|
||||||
|
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
|
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
|
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||||
|
|
||||||
|
if (isRecipesPage) {
|
||||||
|
modelUpdateItem?.classList.add('hidden');
|
||||||
|
licenseRefreshItem?.classList.add('hidden');
|
||||||
|
downloadExamplesItem?.classList.add('hidden');
|
||||||
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
|
licenseRefreshItem?.classList.remove('hidden');
|
||||||
|
downloadExamplesItem?.classList.remove('hidden');
|
||||||
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
|
repairRecipesItem?.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
super.showMenu(x, y, contextOrigin);
|
super.showMenu(x, y, contextOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +63,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
console.error('Failed to refresh missing license metadata:', error);
|
console.error('Failed to refresh missing license metadata:', error);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'repair-recipes':
|
||||||
|
this.repairRecipes(menuItem).catch((error) => {
|
||||||
|
console.error('Failed to repair recipes:', error);
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Unhandled global context menu action: ${action}`);
|
console.warn(`Unhandled global context menu action: ${action}`);
|
||||||
break;
|
break;
|
||||||
@@ -235,4 +263,78 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
return `${displayName}s`;
|
return `${displayName}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async repairRecipes(menuItem) {
|
||||||
|
if (this._repairInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._repairInProgress = true;
|
||||||
|
menuItem?.classList.add('disabled');
|
||||||
|
|
||||||
|
const loadingMessage = translate(
|
||||||
|
'globalContextMenu.repairRecipes.loading',
|
||||||
|
{},
|
||||||
|
'Repairing recipe data...'
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/recipes/repair', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to start repair');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
|
||||||
|
let isComplete = false;
|
||||||
|
while (!isComplete && this._repairInProgress) {
|
||||||
|
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
|
||||||
|
if (progressResponse.ok) {
|
||||||
|
const progressResult = await progressResponse.json();
|
||||||
|
if (progressResult.success && progressResult.progress) {
|
||||||
|
const p = progressResult.progress;
|
||||||
|
if (p.status === 'processing') {
|
||||||
|
const percent = (p.current / p.total) * 100;
|
||||||
|
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
||||||
|
} else if (p.status === 'completed') {
|
||||||
|
isComplete = true;
|
||||||
|
progressUI?.complete(translate(
|
||||||
|
'globalContextMenu.repairRecipes.success',
|
||||||
|
{ count: p.repaired },
|
||||||
|
`Repaired ${p.repaired} recipes.`
|
||||||
|
));
|
||||||
|
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
|
||||||
|
// Refresh recipes page if active
|
||||||
|
if (window.recipesPage) {
|
||||||
|
window.recipesPage.refresh();
|
||||||
|
}
|
||||||
|
} else if (p.status === 'error') {
|
||||||
|
throw new Error(p.error || 'Repair failed');
|
||||||
|
}
|
||||||
|
} else if (progressResponse.status === 404) {
|
||||||
|
// Progress might have finished quickly and been cleaned up
|
||||||
|
isComplete = true;
|
||||||
|
progressUI?.complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Recipe repair failed:', error);
|
||||||
|
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
|
||||||
|
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
|
||||||
|
} finally {
|
||||||
|
this._repairInProgress = false;
|
||||||
|
menuItem?.classList.remove('disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
super('recipeContextMenu', '.model-card');
|
super('recipeContextMenu', '.model-card');
|
||||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
this.modelType = 'recipe';
|
this.modelType = 'recipe';
|
||||||
|
|
||||||
this.initNSFWSelector();
|
this.initNSFWSelector();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,20 +25,20 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||||
return resetAndReload();
|
return resetAndReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
// Call the parent method first to handle basic positioning
|
// Call the parent method first to handle basic positioning
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
|
|
||||||
// Get recipe data to check for missing LoRAs
|
// Get recipe data to check for missing LoRAs
|
||||||
const recipeId = card.dataset.id;
|
const recipeId = card.dataset.id;
|
||||||
const missingLorasItem = this.menu.querySelector('.download-missing-item');
|
const missingLorasItem = this.menu.querySelector('.download-missing-item');
|
||||||
|
|
||||||
if (recipeId && missingLorasItem) {
|
if (recipeId && missingLorasItem) {
|
||||||
// Check if this card has missing LoRAs
|
// Check if this card has missing LoRAs
|
||||||
const loraCountElement = card.querySelector('.lora-count');
|
const loraCountElement = card.querySelector('.lora-count');
|
||||||
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
||||||
|
|
||||||
// Show/hide the download missing LoRAs option based on missing status
|
// Show/hide the download missing LoRAs option based on missing status
|
||||||
if (hasMissingLoras) {
|
if (hasMissingLoras) {
|
||||||
missingLorasItem.style.display = 'flex';
|
missingLorasItem.style.display = 'flex';
|
||||||
@@ -47,7 +47,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMenuAction(action) {
|
handleMenuAction(action) {
|
||||||
// First try to handle with common actions from ModelContextMenuMixin
|
// First try to handle with common actions from ModelContextMenuMixin
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -56,8 +56,8 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
// Handle recipe-specific actions
|
// Handle recipe-specific actions
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
|
|
||||||
switch(action) {
|
switch (action) {
|
||||||
case 'details':
|
case 'details':
|
||||||
// Show recipe details
|
// Show recipe details
|
||||||
this.currentCard.click();
|
this.currentCard.click();
|
||||||
@@ -93,9 +93,13 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
this.downloadMissingLoRAs(recipeId);
|
this.downloadMissingLoRAs(recipeId);
|
||||||
break;
|
break;
|
||||||
|
case 'repair':
|
||||||
|
// Repair recipe metadata
|
||||||
|
this.repairRecipe(recipeId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to copy recipe syntax to clipboard
|
// New method to copy recipe syntax to clipboard
|
||||||
copyRecipeSyntax() {
|
copyRecipeSyntax() {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
@@ -118,7 +122,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
showToast('recipes.contextMenu.copyRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to send recipe to workflow
|
// New method to send recipe to workflow
|
||||||
sendRecipeToWorkflow(replaceMode) {
|
sendRecipeToWorkflow(replaceMode) {
|
||||||
const recipeId = this.currentCard.dataset.id;
|
const recipeId = this.currentCard.dataset.id;
|
||||||
@@ -141,14 +145,14 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
showToast('recipes.contextMenu.sendRecipe.failed', {}, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// View all LoRAs in the recipe
|
// View all LoRAs in the recipe
|
||||||
viewRecipeLoRAs(recipeId) {
|
viewRecipeLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
showToast('recipes.contextMenu.viewLoras.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// First get the recipe details to access its LoRAs
|
// First get the recipe details to access its LoRAs
|
||||||
fetch(`/api/lm/recipe/${recipeId}`)
|
fetch(`/api/lm/recipe/${recipeId}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
@@ -158,17 +162,17 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
removeSessionItem('recipe_to_lora_filterLoraHashes');
|
||||||
removeSessionItem('filterRecipeName');
|
removeSessionItem('filterRecipeName');
|
||||||
removeSessionItem('viewLoraDetail');
|
removeSessionItem('viewLoraDetail');
|
||||||
|
|
||||||
// Collect all hashes from the recipe's LoRAs
|
// Collect all hashes from the recipe's LoRAs
|
||||||
const loraHashes = recipe.loras
|
const loraHashes = recipe.loras
|
||||||
.filter(lora => lora.hash)
|
.filter(lora => lora.hash)
|
||||||
.map(lora => lora.hash.toLowerCase());
|
.map(lora => lora.hash.toLowerCase());
|
||||||
|
|
||||||
if (loraHashes.length > 0) {
|
if (loraHashes.length > 0) {
|
||||||
// Store the LoRA hashes and recipe name in session storage
|
// Store the LoRA hashes and recipe name in session storage
|
||||||
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
setSessionItem('recipe_to_lora_filterLoraHashes', JSON.stringify(loraHashes));
|
||||||
setSessionItem('filterRecipeName', recipe.title);
|
setSessionItem('filterRecipeName', recipe.title);
|
||||||
|
|
||||||
// Navigate to the LoRAs page
|
// Navigate to the LoRAs page
|
||||||
window.location.href = '/loras';
|
window.location.href = '/loras';
|
||||||
} else {
|
} else {
|
||||||
@@ -180,34 +184,34 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
showToast('recipes.contextMenu.viewLoras.loadError', { message: error.message }, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
async downloadMissingLoRAs(recipeId) {
|
async downloadMissingLoRAs(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
showToast('recipes.contextMenu.downloadMissing.missingId', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First get the recipe details
|
// First get the recipe details
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
const recipe = await response.json();
|
const recipe = await response.json();
|
||||||
|
|
||||||
// Get missing LoRAs
|
// Get missing LoRAs
|
||||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||||
|
|
||||||
if (missingLoras.length === 0) {
|
if (missingLoras.length === 0) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading toast
|
// Show loading toast
|
||||||
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
state.loadingManager.showSimpleLoading('Getting version info for missing LoRAs...');
|
||||||
|
|
||||||
// Get version info for each missing LoRA
|
// Get version info for each missing LoRA
|
||||||
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
const missingLorasWithVersionInfoPromises = missingLoras.map(async lora => {
|
||||||
let endpoint;
|
let endpoint;
|
||||||
|
|
||||||
// Determine which endpoint to use based on available data
|
// Determine which endpoint to use based on available data
|
||||||
if (lora.modelVersionId) {
|
if (lora.modelVersionId) {
|
||||||
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
|
endpoint = `/api/lm/loras/civitai/model/version/${lora.modelVersionId}`;
|
||||||
@@ -217,52 +221,52 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
console.error("Missing both hash and modelVersionId for lora:", lora);
|
console.error("Missing both hash and modelVersionId for lora:", lora);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionResponse = await fetch(endpoint);
|
const versionResponse = await fetch(endpoint);
|
||||||
const versionInfo = await versionResponse.json();
|
const versionInfo = await versionResponse.json();
|
||||||
|
|
||||||
// Return original lora data combined with version info
|
// Return original lora data combined with version info
|
||||||
return {
|
return {
|
||||||
...lora,
|
...lora,
|
||||||
civitaiInfo: versionInfo
|
civitaiInfo: versionInfo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for all API calls to complete
|
// Wait for all API calls to complete
|
||||||
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
const lorasWithVersionInfo = await Promise.all(missingLorasWithVersionInfoPromises);
|
||||||
|
|
||||||
// Filter out null values (failed requests)
|
// Filter out null values (failed requests)
|
||||||
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
const validLoras = lorasWithVersionInfo.filter(lora => lora !== null);
|
||||||
|
|
||||||
if (validLoras.length === 0) {
|
if (validLoras.length === 0) {
|
||||||
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
showToast('recipes.contextMenu.downloadMissing.getInfoFailed', {}, 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare data for import manager using the retrieved information
|
// Prepare data for import manager using the retrieved information
|
||||||
const recipeData = {
|
const recipeData = {
|
||||||
loras: validLoras.map(lora => {
|
loras: validLoras.map(lora => {
|
||||||
const civitaiInfo = lora.civitaiInfo;
|
const civitaiInfo = lora.civitaiInfo;
|
||||||
const modelFile = civitaiInfo.files ?
|
const modelFile = civitaiInfo.files ?
|
||||||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Basic lora info
|
// Basic lora info
|
||||||
name: civitaiInfo.model?.name || lora.name,
|
name: civitaiInfo.model?.name || lora.name,
|
||||||
version: civitaiInfo.name || '',
|
version: civitaiInfo.name || '',
|
||||||
strength: lora.strength || 1.0,
|
strength: lora.strength || 1.0,
|
||||||
|
|
||||||
// Model identifiers
|
// Model identifiers
|
||||||
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
hash: modelFile?.hashes?.SHA256?.toLowerCase() || lora.hash,
|
||||||
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
modelVersionId: civitaiInfo.id || lora.modelVersionId,
|
||||||
|
|
||||||
// Metadata
|
// Metadata
|
||||||
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
thumbnailUrl: civitaiInfo.images?.[0]?.url || '',
|
||||||
baseModel: civitaiInfo.baseModel || '',
|
baseModel: civitaiInfo.baseModel || '',
|
||||||
downloadUrl: civitaiInfo.downloadUrl || '',
|
downloadUrl: civitaiInfo.downloadUrl || '',
|
||||||
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
size: modelFile ? (modelFile.sizeKB * 1024) : 0,
|
||||||
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
file_name: modelFile ? modelFile.name.split('.')[0] : '',
|
||||||
|
|
||||||
// Status flags
|
// Status flags
|
||||||
existsLocally: false,
|
existsLocally: false,
|
||||||
isDeleted: civitaiInfo.error === "Model not found",
|
isDeleted: civitaiInfo.error === "Model not found",
|
||||||
@@ -271,7 +275,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call ImportManager's download missing LoRAs method
|
// Call ImportManager's download missing LoRAs method
|
||||||
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
window.importManager.downloadMissingLoras(recipeData, recipeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -283,6 +287,38 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repair recipe metadata
|
||||||
|
async repairRecipe(recipeId) {
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast('recipes.contextMenu.repair.starting', {}, 'info');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (result.repaired > 0) {
|
||||||
|
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||||
|
// Refresh the current card or reload
|
||||||
|
this.resetAndReload();
|
||||||
|
} else {
|
||||||
|
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Repair failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error repairing recipe:', error);
|
||||||
|
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mix in shared methods from ModelContextMenuMixin
|
// Mix in shared methods from ModelContextMenuMixin
|
||||||
|
|||||||
@@ -102,6 +102,9 @@
|
|||||||
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
<div class="context-menu-item" data-action="cleanup-example-images-folders">
|
||||||
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
<i class="fas fa-trash-restore"></i> <span>{{ t('globalContextMenu.cleanupExampleImages.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="repair-recipes">
|
||||||
|
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
<div id="nsfwLevelSelector" class="nsfw-level-selector">
|
||||||
@@ -110,7 +113,8 @@
|
|||||||
<button class="close-nsfw-selector"><i class="fas fa-times"></i></button>
|
<button class="close-nsfw-selector"><i class="fas fa-times"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="nsfw-level-content">
|
<div class="nsfw-level-content">
|
||||||
<div class="current-level"><span>{{ t('modals.contentRating.current') }}:</span> <span id="currentNSFWLevel">{{ t('common.status.unknown') }}</span></div>
|
<div class="current-level"><span>{{ t('modals.contentRating.current') }}:</span> <span id="currentNSFWLevel">{{
|
||||||
|
t('common.status.unknown') }}</span></div>
|
||||||
<div class="nsfw-level-options">
|
<div class="nsfw-level-options">
|
||||||
<button class="nsfw-level-btn" data-level="1">{{ t('modals.contentRating.levels.pg') }}</button>
|
<button class="nsfw-level-btn" data-level="1">{{ t('modals.contentRating.levels.pg') }}</button>
|
||||||
<button class="nsfw-level-btn" data-level="2">{{ t('modals.contentRating.levels.pg13') }}</button>
|
<button class="nsfw-level-btn" data-level="2">{{ t('modals.contentRating.levels.pg13') }}</button>
|
||||||
@@ -123,4 +127,4 @@
|
|||||||
|
|
||||||
<div id="nodeSelector" class="node-selector">
|
<div id="nodeSelector" class="node-selector">
|
||||||
<!-- Dynamic node list will be populated here -->
|
<!-- Dynamic node list will be populated here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -30,9 +30,12 @@
|
|||||||
<div class="context-menu-item" data-action="set-nsfw">
|
<div class="context-menu-item" data-action="set-nsfw">
|
||||||
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="repair">
|
||||||
|
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }}
|
||||||
|
</div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
|
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
|
||||||
t('loras.contextMenu.moveToFolder') }}</div>
|
t('loras.contextMenu.moveToFolder') }}</div>
|
||||||
<div class="context-menu-separator"></div>
|
|
||||||
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
|
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
|
||||||
t('loras.contextMenu.deleteRecipe') }}</div>
|
t('loras.contextMenu.deleteRecipe') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
278
tests/services/test_recipe_repair.py
Normal file
278
tests/services/test_recipe_repair.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from py.services.recipe_scanner import RecipeScanner
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
# We define these here to help with spec= if needed
|
||||||
|
class MockCivitaiClient:
|
||||||
|
async def get_image_info(self, image_id):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class MockPersistenceService:
|
||||||
|
async def save_recipe(self, recipe):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_civitai_client():
|
||||||
|
client = MagicMock(spec=MockCivitaiClient)
|
||||||
|
client.get_image_info = AsyncMock()
|
||||||
|
return client
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_metadata_provider():
|
||||||
|
provider = MagicMock()
|
||||||
|
provider.get_model_version_info = AsyncMock(return_value=(None, None))
|
||||||
|
provider.get_model_by_hash = AsyncMock(return_value=(None, None))
|
||||||
|
return provider
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def recipe_scanner():
|
||||||
|
lora_scanner = MagicMock()
|
||||||
|
lora_scanner.get_cached_data = AsyncMock(return_value=SimpleNamespace(raw_data=[]))
|
||||||
|
|
||||||
|
scanner = RecipeScanner(lora_scanner=lora_scanner)
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_scanner(recipe_scanner, mock_civitai_client, mock_metadata_provider, monkeypatch):
|
||||||
|
monkeypatch.setattr(recipe_scanner, "_get_civitai_client", AsyncMock(return_value=mock_civitai_client))
|
||||||
|
|
||||||
|
# Wrap the real method with a mock so we can check calls but still execute it
|
||||||
|
real_save = recipe_scanner._save_recipe_persistently
|
||||||
|
mock_save = AsyncMock(side_effect=real_save)
|
||||||
|
monkeypatch.setattr(recipe_scanner, "_save_recipe_persistently", mock_save)
|
||||||
|
|
||||||
|
monkeypatch.setattr("py.services.recipe_scanner.get_default_metadata_provider", AsyncMock(return_value=mock_metadata_provider))
|
||||||
|
|
||||||
|
# Mock get_recipe_json_path to avoid file system issues in tests
|
||||||
|
recipe_scanner.get_recipe_json_path = AsyncMock(return_value="/tmp/test_recipe.json")
|
||||||
|
# Mock open to avoid actual file writing
|
||||||
|
monkeypatch.setattr("builtins.open", MagicMock())
|
||||||
|
monkeypatch.setattr("json.dump", MagicMock())
|
||||||
|
monkeypatch.setattr("os.path.exists", MagicMock(return_value=False)) # avoid EXIF logic
|
||||||
|
|
||||||
|
return recipe_scanner, mock_civitai_client, mock_metadata_provider
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_skip_up_to_date(setup_scanner):
|
||||||
|
recipe_scanner, _, _ = setup_scanner
|
||||||
|
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
||||||
|
{"id": "r1", "repair_version": RecipeScanner.REPAIR_VERSION, "title": "Up to date"}
|
||||||
|
])
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 0
|
||||||
|
assert results["skipped"] == 1
|
||||||
|
recipe_scanner._save_recipe_persistently.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Old Recipe",
|
||||||
|
"source_url": "https://civitai.com/images/12345",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {"prompt": ""}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock image info returning modelVersionId
|
||||||
|
mock_civitai_client.get_image_info.return_value = {
|
||||||
|
"modelVersionId": 5678,
|
||||||
|
"meta": {"prompt": "a beautiful forest", "Checkpoint": "basic_name.safetensors"}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock metadata provider returning full info
|
||||||
|
mock_metadata_provider.get_model_version_info.return_value = ({
|
||||||
|
"id": 5678,
|
||||||
|
"modelId": 1234,
|
||||||
|
"name": "v1.0",
|
||||||
|
"model": {"name": "Full Model Name"},
|
||||||
|
"baseModel": "SDXL 1.0",
|
||||||
|
"images": [{"url": "https://image.url/thumb.jpg"}],
|
||||||
|
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
mock_metadata_provider.get_model_version_info.assert_called_with("5678")
|
||||||
|
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
checkpoint = saved_recipe["checkpoint"]
|
||||||
|
assert checkpoint["name"] == "Full Model Name"
|
||||||
|
assert checkpoint["version"] == "v1.0"
|
||||||
|
assert checkpoint["modelId"] == 1234
|
||||||
|
assert checkpoint["id"] == 5678
|
||||||
|
assert checkpoint["hash"] == "abcdef"
|
||||||
|
assert checkpoint["file_name"] == "full_filename"
|
||||||
|
assert "thumbnailUrl" not in checkpoint # Stripped during sanitation
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Embedded Only",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "",
|
||||||
|
"Model hash": "hash123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock metadata provider lookup by hash
|
||||||
|
mock_metadata_provider.get_model_by_hash.return_value = ({
|
||||||
|
"id": 999,
|
||||||
|
"modelId": 888,
|
||||||
|
"name": "v2.0",
|
||||||
|
"model": {"name": "Hashed Model"},
|
||||||
|
"baseModel": "SD 1.5",
|
||||||
|
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
||||||
|
}, None)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
mock_metadata_provider.get_model_by_hash.assert_called_with("hash123")
|
||||||
|
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
checkpoint = saved_recipe["checkpoint"]
|
||||||
|
assert checkpoint["name"] == "Hashed Model"
|
||||||
|
assert checkpoint["version"] == "v2.0"
|
||||||
|
assert checkpoint["modelId"] == 888
|
||||||
|
assert checkpoint["hash"] == "hash123"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_fallback_to_basic(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "No Meta Lookup",
|
||||||
|
"checkpoint": None,
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "",
|
||||||
|
"Checkpoint": "just_a_name.safetensors"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
|
||||||
|
# Mock metadata provider returning nothing
|
||||||
|
mock_metadata_provider.get_model_by_hash.return_value = (None, "Model not found")
|
||||||
|
|
||||||
|
# Run
|
||||||
|
results = await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert results["repaired"] == 1
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
assert saved_recipe["checkpoint"]["name"] == "just_a_name.safetensors"
|
||||||
|
assert "modelId" not in saved_recipe["checkpoint"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_progress_callback(setup_scanner):
|
||||||
|
recipe_scanner, _, _ = setup_scanner
|
||||||
|
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
||||||
|
{"id": "r1", "title": "R1", "checkpoint": None},
|
||||||
|
{"id": "r2", "title": "R2", "checkpoint": None}
|
||||||
|
])
|
||||||
|
|
||||||
|
progress_calls = []
|
||||||
|
async def progress_callback(data):
|
||||||
|
progress_calls.append(data)
|
||||||
|
|
||||||
|
# Run
|
||||||
|
await recipe_scanner.repair_all_recipes(
|
||||||
|
progress_callback=progress_callback
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
assert len(progress_calls) >= 2
|
||||||
|
assert progress_calls[-1]["status"] == "completed"
|
||||||
|
assert progress_calls[-1]["total"] == 2
|
||||||
|
assert progress_calls[-1]["repaired"] == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repair_all_recipes_strips_runtime_fields(setup_scanner):
|
||||||
|
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
||||||
|
|
||||||
|
# Recipe with runtime fields
|
||||||
|
recipe = {
|
||||||
|
"id": "r1",
|
||||||
|
"title": "Cleanup Test",
|
||||||
|
"checkpoint": {
|
||||||
|
"name": "CP",
|
||||||
|
"inLibrary": True,
|
||||||
|
"localPath": "/path/to/cp",
|
||||||
|
"thumbnailUrl": "thumb.jpg"
|
||||||
|
},
|
||||||
|
"loras": [
|
||||||
|
{
|
||||||
|
"name": "L1",
|
||||||
|
"weight": 0.8,
|
||||||
|
"inLibrary": True,
|
||||||
|
"localPath": "/path/to/l1",
|
||||||
|
"preview_url": "p.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"gen_params": {"prompt": ""}
|
||||||
|
}
|
||||||
|
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
||||||
|
# Set high version to trigger repair if needed (or just ensure it processes)
|
||||||
|
recipe["repair_version"] = 0
|
||||||
|
|
||||||
|
# Run
|
||||||
|
await recipe_scanner.repair_all_recipes()
|
||||||
|
|
||||||
|
# Verify sanitation
|
||||||
|
assert recipe_scanner._save_recipe_persistently.called
|
||||||
|
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
||||||
|
|
||||||
|
# 1. Check LORA
|
||||||
|
lora = saved_recipe["loras"][0]
|
||||||
|
assert "inLibrary" not in lora
|
||||||
|
assert "localPath" not in lora
|
||||||
|
assert "preview_url" not in lora
|
||||||
|
assert "strength" in lora # weight renamed to strength
|
||||||
|
assert lora["strength"] == 0.8
|
||||||
|
|
||||||
|
# 2. Check Checkpoint
|
||||||
|
cp = saved_recipe["checkpoint"]
|
||||||
|
assert "inLibrary" not in cp
|
||||||
|
assert "localPath" not in cp
|
||||||
|
assert "thumbnailUrl" not in cp
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sanitize_recipe_for_storage(recipe_scanner):
|
||||||
|
import sys
|
||||||
|
import py.services.recipe_scanner
|
||||||
|
print(f"\nDEBUG_ENV: sys.path: {sys.path}")
|
||||||
|
print(f"DEBUG_ENV: recipe_scanner file: {py.services.recipe_scanner.__file__}")
|
||||||
|
|
||||||
|
recipe = {
|
||||||
|
"loras": [{"name": "L1", "inLibrary": True, "weight": 0.5}],
|
||||||
|
"checkpoint": {"name": "CP", "localPath": "/tmp/cp"}
|
||||||
|
}
|
||||||
|
|
||||||
|
clean = recipe_scanner._sanitize_recipe_for_storage(recipe)
|
||||||
|
|
||||||
|
assert "inLibrary" not in clean["loras"][0]
|
||||||
|
assert "strength" in clean["loras"][0]
|
||||||
|
assert clean["loras"][0]["strength"] == 0.5
|
||||||
|
assert "localPath" not in clean["checkpoint"]
|
||||||
|
assert clean["checkpoint"]["name"] == "CP"
|
||||||
Reference in New Issue
Block a user