feat(settings): filename templates for download and bulk rename (#1071)

Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).

- apply template automatically after downloads; rename conflicts keep
  the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
  WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
  preview, and per-type 'apply to library' actions
This commit is contained in:
Will Miao
2026-09-19 09:04:24 +08:00
parent 327da0465b
commit 2bc9860b24
38 changed files with 2239 additions and 7 deletions
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "Gültige Vorlage"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "Download-Speicherort",
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
@@ -913,6 +929,14 @@
"complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "Metadaten mit KI anreichern"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "Keine Modelle ausgewählt"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "Valid template"
}
},
"filenameTemplates": {
"title": "Filename Templates",
"help": "Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "Available placeholders:",
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "Apply to Library Now",
"applyHelp": "Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "Valid (keep original filename)",
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
"validTemplate": "Valid template"
}
},
"exampleImages": {
"downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images",
@@ -913,6 +929,14 @@
"complete": "Auto-organize complete",
"error": "Error: {error}"
},
"filenameTemplateProgress": {
"initializing": "Initializing filename template apply...",
"starting": "Applying filename template to {type}...",
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "Filename template apply complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich Metadata with AI"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}",
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "Applying filename template failed: {error}",
"noModelsSelected": "No models selected"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
"filenameTemplatesUpdated": "Filename templates updated",
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
"recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "Plantilla válida"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "Ubicación de descarga",
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
@@ -913,6 +929,14 @@
"complete": "Auto-organización completada",
"error": "Error: {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos con IA"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "No hay modelos seleccionados"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "Modèle valide"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "Emplacement de téléchargement",
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
@@ -913,6 +929,14 @@
"complete": "Auto-organisation terminée",
"error": "Erreur : {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "Aucun modèle sélectionné"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "תבנית תקינה"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "מיקום הורדה",
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
@@ -913,6 +929,14 @@
"complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "לא נבחרו מודלים"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "有効なテンプレート"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
@@ -913,6 +929,14 @@
"complete": "自動整理が完了しました",
"error": "エラー:{error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "メタデータをAIで補完"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "モデルが選択されていません"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "유효한 템플릿"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "다운로드 위치",
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
@@ -913,6 +929,14 @@
"complete": "자동 정리 완료",
"error": "오류: {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "AI로 메타데이터 보강"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "선택된 모델이 없습니다"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "Действительный шаблон"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "Место загрузки",
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
@@ -913,6 +929,14 @@
"complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "Модели не выбраны"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "有效模板"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
@@ -913,6 +929,14 @@
"complete": "自动整理已完成",
"error": "错误:{error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "AI 元数据增强"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "未选中模型"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}",
+29
View File
@@ -681,6 +681,22 @@
"validTemplate": "範本有效"
}
},
"filenameTemplates": {
"title": "[TODO: Translate] Filename Templates",
"help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "[TODO: Translate] Available placeholders:",
"templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "[TODO: Translate] Apply to Library Now",
"applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "[TODO: Translate] Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.",
"validation": {
"keepOriginal": "[TODO: Translate] Valid (keep original filename)",
"invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}",
"validTemplate": "[TODO: Translate] Valid template"
}
},
"exampleImages": {
"downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
@@ -913,6 +929,14 @@
"complete": "自動整理完成",
"error": "錯誤:{error}"
},
"filenameTemplateProgress": {
"initializing": "[TODO: Translate] Initializing filename template apply...",
"starting": "[TODO: Translate] Applying filename template to {type}...",
"processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "[TODO: Translate] Filename template apply complete",
"error": "[TODO: Translate] Error: {error}"
},
"enrichHfAgent": "AI 中繼資料增強"
},
"contextMenu": {
@@ -2311,6 +2335,9 @@
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}",
"filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}",
"noModelsSelected": "未選擇任何模型"
},
"recipes": {
@@ -2477,6 +2504,8 @@
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
"recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}",
+18
View File
@@ -24,9 +24,11 @@ from ..services.use_cases import (
AutoOrganizeUseCase,
BulkMetadataRefreshUseCase,
DownloadModelUseCase,
FilenameTemplateUseCase,
)
from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler,
ModelCivitaiHandler,
ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet,
ModelListingHandler,
ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager,
logger=logger,
)
filename_template_use_case = FilenameTemplateUseCase(
scanner=service.scanner,
lifecycle_service=self._ensure_lifecycle_service(),
lock_provider=self._ws_manager,
model_type=service.model_type,
)
filename_template = ModelFilenameTemplateHandler(
use_case=filename_template_use_case,
progress_callback=self.filename_template_progress_callback,
logger=logger,
)
updates = ModelUpdateHandler(
service=service,
update_service=update_service,
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai,
move=move,
auto_organize=auto_organize,
filename_template=filename_template,
updates=updates,
)
+72 -1
View File
@@ -37,10 +37,14 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError,
DownloadModelUseCase,
DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter,
)
from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback
from ...services.websocket_progress_callback import (
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload
@@ -2692,6 +2696,71 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelFilenameTemplateHandler:
"""Apply the configured filename template to existing library models."""
def __init__(
self,
*,
use_case: FilenameTemplateUseCase,
progress_callback: WebSocketFilenameTemplateProgressCallback,
logger: logging.Logger,
) -> None:
self._use_case = use_case
self._progress_callback = progress_callback
self._logger = logger
async def apply_filename_template(self, request: web.Request) -> web.Response:
try:
file_paths = None
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
except Exception: # pragma: no cover - permissive path
pass
else:
# GET variant (browser extension is GET-only): comma-separated
# file_paths query parameter.
raw_file_paths = request.query.get("file_paths")
if raw_file_paths:
file_paths = [
path.strip()
for path in raw_file_paths.split(",")
if path.strip()
]
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
)
_broadcast_models_changed()
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:
return web.json_response(
{
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
},
status=409,
)
except Exception as exc:
self._logger.error(
"Error in apply_filename_template: %s", exc, exc_info=True
)
try:
await self._progress_callback.on_progress(
{
"type": "filename_template_progress",
"status": "error",
"error": str(exc),
}
)
except Exception: # pragma: no cover - defensive reporting
pass
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler:
"""Handle update tracking requests."""
@@ -3459,6 +3528,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler
move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler
def to_route_mapping(
@@ -3523,6 +3593,7 @@ class ModelHandlerSet:
"rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"apply_filename_template": self.filename_template.apply_filename_template,
"get_model_notes": self.query.get_model_notes,
"get_model_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url,
+6
View File
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
+89 -1
View File
@@ -33,7 +33,7 @@ from ..utils.constants import (
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
@@ -45,6 +45,7 @@ from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first
import tempfile
@@ -2746,6 +2747,7 @@ class DownloadManager:
else None
)
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index]
@@ -2788,6 +2790,15 @@ class DownloadManager:
if scanner is not None:
await scanner.add_model_to_cache(metadata_dict, relative_path)
downloaded_metadata.append(metadata_dict)
await self._apply_download_filename_template(
scanner=scanner,
model_type=model_type,
downloaded_metadata=downloaded_metadata,
download_id=download_id,
)
if transfer_backend == "aria2" and download_id:
await self._aria2_state_store.remove(download_id)
@@ -2827,6 +2838,83 @@ class DownloadManager:
return {"success": False, "error": str(e)}
async def _apply_download_filename_template(
self,
*,
scanner,
model_type: str,
downloaded_metadata: List[Dict[str, Any]],
download_id: Optional[str],
) -> None:
"""Rename freshly downloaded models according to the filename template.
Best-effort post-download step: any failure (including name conflicts)
is logged and skipped so a successful download is never turned into a
failure by a rename problem.
"""
try:
if scanner is None or not downloaded_metadata:
return
template = get_settings_manager().get_download_filename_template(
model_type
)
if not template:
return
lifecycle_service = ModelLifecycleService(
scanner=scanner,
metadata_manager=MetadataManager,
metadata_loader=load_local_metadata,
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
)
for metadata_dict in downloaded_metadata:
file_path = metadata_dict.get("file_path")
if not isinstance(file_path, str) or not file_path:
continue
new_stem = calculate_filename_for_model(metadata_dict, model_type)
if not new_stem:
continue
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
continue
try:
result = await lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
except ValueError as exc:
logger.warning(
"Keeping original filename for %s: %s", file_path, exc
)
continue
new_file_path = result.get("new_file_path")
if download_id and isinstance(new_file_path, str):
info = self._active_downloads.get(download_id)
if info is None:
continue
if info.get("file_path") == file_path:
info["file_path"] = new_file_path
extracted = info.get("extracted_paths")
if isinstance(extracted, list):
info["extracted_paths"] = [
new_file_path if path == file_path else path
for path in extracted
]
except Exception as exc: # Rename phase must never fail the download
logger.warning(
"Filename template rename failed for %s download: %s",
model_type,
exc,
exc_info=True,
)
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type in ("checkpoint", "other"):
return {
+13 -1
View File
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary"""
if self.operation_type == 'filename_template':
message = (
f'Filename template applied: {self.success_count} renamed, '
f'{self.skipped_count} skipped, {self.failure_count} failed '
f'out of {self.total} total'
)
else:
message = (
f'Auto-organize {self.operation_type} completed: '
f'{self.success_count} moved, {self.skipped_count} skipped, '
f'{self.failure_count} failed out of {self.total} total'
)
result: Dict[str, Any] = {
'success': self.status != 'error',
'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
'message': message,
'summary': {
'total': self.total,
'success': self.success_count,
+24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import json
import logging
import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
from ..services.model_update_service import ModelUpdateService
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
"""Load a metadata sidecar JSON, returning an empty dict when missing.
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
(download manager, use cases) that do not hold a sync-service instance.
"""
if not os.path.exists(metadata_path):
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
return {}
return payload if isinstance(payload, dict) else {}
async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]:
@@ -404,6 +425,9 @@ class ModelLifecycleService:
if metadata and new_metadata_path:
metadata["file_name"] = new_file_name
metadata["file_path"] = new_file_path
# Preserve the pre-rename stem so the original download filename
# stays recoverable after template-driven renames.
metadata.setdefault("original_file_name", old_file_name)
if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"])
+45
View File
@@ -98,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"recipes_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
"download_filename_templates": {},
"folder_paths": {},
"extra_folder_paths": {},
"example_images_path": "",
@@ -1276,6 +1277,7 @@ class SettingsManager:
defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults["base_model_path_mappings"] = {}
defaults["download_path_templates"] = {}
defaults["download_filename_templates"] = {}
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault("folder_paths", {})
defaults.setdefault("extra_folder_paths", {})
@@ -2424,6 +2426,49 @@ class SettingsManager:
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
)
def get_download_filename_template(self, model_type: str) -> str:
"""Get the download filename template for a specific model type.
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type. Empty string (the default for
every model type) means downloaded files keep their original
filename.
"""
templates = self.settings.get("download_filename_templates", {})
# Handle edge case where templates might be stored as JSON string
if isinstance(templates, str):
try:
parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict):
self.settings["download_filename_templates"] = parsed_templates
self._save_settings()
templates = parsed_templates
logger.info(
"Successfully parsed download_filename_templates from JSON string"
)
else:
raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
)
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
if not isinstance(templates, dict):
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
template = templates.get(model_type, "")
return template if isinstance(template, str) else ""
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
_SETTINGS_MANAGER_LOCK = Lock()
+2
View File
@@ -20,6 +20,7 @@ from .example_images import (
ImportExampleImagesUseCase,
ImportExampleImagesValidationError,
)
from .filename_template_use_case import FilenameTemplateUseCase
__all__ = [
"AutoOrganizeInProgressError",
@@ -34,4 +35,5 @@ __all__ = [
"DownloadExampleImagesUseCase",
"ImportExampleImagesUseCase",
"ImportExampleImagesValidationError",
"FilenameTemplateUseCase",
]
@@ -0,0 +1,222 @@
"""Filename template use case: bulk-rename library models per the configured template."""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Dict, List, Optional, Sequence
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ...utils.utils import calculate_filename_for_model
from ..model_file_service import AutoOrganizeResult, ProgressCallback
from ..model_lifecycle_service import ModelLifecycleService
from ..settings_manager import get_settings_manager
from .auto_organize_use_case import (
AutoOrganizeInProgressError,
AutoOrganizeLockProvider,
)
logger = logging.getLogger(__name__)
_PROGRESS_TYPE = "filename_template_progress"
class FilenameTemplateUseCase:
"""Apply the download filename template to existing library models.
Shares the auto-organize lock (and its in-progress error) so a bulk
rename never runs concurrently with an auto-organize operation.
"""
def __init__(
self,
*,
scanner,
lifecycle_service: ModelLifecycleService,
lock_provider: AutoOrganizeLockProvider,
model_type: str,
) -> None:
self._scanner = scanner
self._lifecycle_service = lifecycle_service
self._lock_provider = lock_provider
self._model_type = model_type
async def execute(
self,
*,
file_paths: Optional[Sequence[str]] = None,
progress_callback: Optional[ProgressCallback] = None,
) -> AutoOrganizeResult:
"""Run the bulk rename guarded by the shared library-operation lock."""
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
if callable(is_running) and is_running():
raise AutoOrganizeInProgressError(
"A filename template operation is already running"
)
if self._lock_provider.is_auto_organize_running():
raise AutoOrganizeInProgressError("Auto-organize is already running")
lock = await self._lock_provider.get_auto_organize_lock()
if lock.locked():
raise AutoOrganizeInProgressError(
"Another library operation is already running"
)
async with lock:
return await self._run(
file_paths=file_paths, progress_callback=progress_callback
)
async def _run(
self,
*,
file_paths: Optional[Sequence[str]],
progress_callback: Optional[ProgressCallback],
) -> AutoOrganizeResult:
result = AutoOrganizeResult()
result.operation_type = "filename_template"
self._scanner.reset_cancellation()
try:
template = get_settings_manager().get_download_filename_template(
self._model_type
)
cache = await self._scanner.get_cached_data()
models = list(cache.raw_data)
if file_paths:
wanted = set(file_paths)
models = [
model for model in models if model.get("file_path") in wanted
]
result.total = len(models)
await self._emit_progress(progress_callback, result, "started")
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self._scanner.is_cancelled():
logger.info(
"Filename template apply cancelled for %s", self._model_type
)
break
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
if self._scanner.is_cancelled():
break
await self._process_model(model, template, result)
result.processed += 1
await self._emit_progress(progress_callback, result, "processing")
# Yield between batches so the server stays responsive.
await asyncio.sleep(0.1)
if self._scanner.is_cancelled():
result.status = "cancelled"
await self._emit_progress(progress_callback, result, "cancelled")
return result
await self._emit_progress(progress_callback, result, "completed")
return result
except Exception as exc:
logger.error("Error in filename template apply: %s", exc, exc_info=True)
if progress_callback:
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": "error",
"error": str(exc),
"operation_type": result.operation_type,
}
)
raise
async def _process_model(
self,
model: Dict[str, Any],
template: str,
result: AutoOrganizeResult,
) -> None:
model_name = model.get("model_name", "Unknown")
try:
file_path = model.get("file_path")
if not file_path:
self._add_result(result, model_name, False, "No file path found")
result.failure_count += 1
return
if not template:
result.skipped_count += 1
return
new_stem = calculate_filename_for_model(model, self._model_type)
if not new_stem:
result.skipped_count += 1
return
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
result.skipped_count += 1
return
await self._lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
result.success_count += 1
except ValueError as exc:
# Conflicts (e.g. target name already exists) count as failures
# without aborting the batch.
self._add_result(result, model_name, False, str(exc))
result.failure_count += 1
except Exception as exc:
logger.error(
"Error applying filename template to %s: %s", model_name, exc,
exc_info=True,
)
self._add_result(result, model_name, False, f"Error: {exc}")
result.failure_count += 1
async def _emit_progress(
self,
progress_callback: Optional[ProgressCallback],
result: AutoOrganizeResult,
status: str,
) -> None:
if not progress_callback:
return
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": status,
"total": result.total,
"processed": result.processed,
"success": result.success_count,
"failures": result.failure_count,
"skipped": result.skipped_count,
"operation_type": result.operation_type,
}
)
@staticmethod
def _add_result(
result: AutoOrganizeResult,
model_name: str,
success: bool,
message: str,
) -> None:
"""Add a result entry if under the limit (mirrors ModelFileService)."""
if len(result.results) < 100:
result.results.append(
{"model": model_name, "success": success, "message": message}
)
elif len(result.results) == 100:
result.results_truncated = True
result.sample_results = result.results[:50]
+22
View File
@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add filename template progress tracking
self._filename_template_progress: Optional[Dict[str, Any]] = None
# Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock()
@@ -205,6 +207,26 @@ class WebSocketManager:
def cleanup_auto_organize_progress(self):
"""Clear auto-organize progress data"""
self._auto_organize_progress = None
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
"""Broadcast filename template progress to connected clients"""
self._filename_template_progress = data
await self.broadcast(data)
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
"""Get current filename template progress"""
return self._filename_template_progress
def cleanup_filename_template_progress(self):
"""Clear filename template progress data"""
self._filename_template_progress = None
def is_filename_template_running(self) -> bool:
"""Check if a filename template operation is currently running"""
if not self._filename_template_progress:
return False
status = self._filename_template_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
"""Broadcast recipe rematch progress to connected clients"""
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
await ws_manager.broadcast_auto_organize_progress(progress_data)
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
"""WebSocket progress callback for filename template operations."""
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
"""Send filename template progress via WebSocket."""
await ws_manager.broadcast_filename_template_progress(progress_data)
class WebSocketBroadcastCallback:
"""Generic WebSocket progress callback broadcasting to all clients."""
+104
View File
@@ -1,4 +1,5 @@
from difflib import SequenceMatcher
import logging
import os
import re
from typing import Any, Dict, List, Optional
@@ -7,6 +8,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager
import asyncio
logger = logging.getLogger(__name__)
def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache"""
@@ -598,6 +601,107 @@ def calculate_relative_path_for_model(
return formatted_path
def calculate_filename_for_model(
model_data: Dict[str, Any], model_type: str = "lora"
) -> str:
"""Calculate the filename stem for a model using the filename template.
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
but renders a single filename (no path segments). Missing values resolve
to empty segments instead of the path-oriented defaults ("Anonymous" /
"no tags") so templates degrade gracefully.
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Sanitized filename stem without extension, or an empty string when no
template is configured, the template is invalid, or the rendered name
is empty.
"""
settings_manager = get_settings_manager()
template = settings_manager.get_download_filename_template(model_type)
if not template:
return ""
# A filename template must render a single name, never folder segments.
if "/" in template or "\\" in template:
logger.warning(
"Filename template for %s contains a path separator and is ignored: %r",
model_type,
template,
)
return ""
civitai_data = model_data.get("civitai", {})
author = ""
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or ""
base_model = model_data.get("base_model", "")
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
lowercase_tags = [
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
]
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
model_name = model_data.get("model_name", "")
version_name = ""
if isinstance(civitai_data, dict):
version_name = civitai_data.get("name") or ""
sha256 = model_data.get("sha256") or ""
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
file_path = model_data.get("file_path") or ""
if isinstance(file_path, str) and file_path:
original_name = os.path.splitext(os.path.basename(file_path))[0]
else:
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
def _sanitize_value(value: Any) -> str:
# sanitize_folder_name falls back to "unnamed" for empty input; for
# templates an empty value must stay empty so segments collapse.
text = str(value) if value else ""
return sanitize_folder_name(text) if text else ""
replacements = {
"{model_name}": _sanitize_value(model_name),
"{version_name}": _sanitize_value(version_name),
"{base_model}": _sanitize_value(mapped_base_model),
"{author}": _sanitize_value(author),
"{first_tag}": _sanitize_value(first_tag),
"{hash_short}": hash_short,
"{original_name}": _sanitize_value(original_name),
}
result = template
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
if model_type == "embedding":
result = result.replace(" ", "_")
# Strip characters that are illegal in filenames on common filesystems.
result = re.sub(r'[:*?"<>|]', "", result)
# Collapse runs of identical separators introduced by empty substitutions.
result = re.sub(r"([-_. ])\1+", r"\1", result)
# Drop separators left dangling next to each other ("- -" -> "-").
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
# A stem must not start or end with separators, spaces or dots.
result = result.strip("-_. ")
return result
def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path.
+3
View File
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
autoOrganize: `/api/lm/${modelType}/auto-organize`,
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
// Filename template operations
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
// Model-specific endpoints (will be merged with specific configs)
specific: {}
};
+129
View File
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
});
}
/**
* Apply the configured download filename template to models, renaming their files
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
* @returns {Promise} - Promise that resolves when the operation is complete
*/
async applyFilenameTemplate(filePaths = null) {
let ws = null;
await state.loadingManager.showWithProgress(async (loading) => {
loading.showCancelButton(() => this.cancelTask());
try {
// Connect to WebSocket for progress updates
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
const operationComplete = new Promise((resolve, reject) => {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type !== 'filename_template_progress') return;
switch (data.status) {
case 'started':
loading.setProgress(0);
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
break;
case 'processing':
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
loading.setProgress(percent);
loading.setStatus(
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
processed: data.processed,
total: data.total,
success: data.success,
failures: data.failures,
skipped: data.skipped
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
);
break;
case 'completed':
loading.setProgress(100);
loading.setStatus(
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
success: data.success,
skipped: data.skipped,
failures: data.failures,
total: data.total
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
);
setTimeout(() => {
resolve(data);
}, 1500);
break;
case 'cancelled':
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
resolve(data);
break;
case 'error':
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
reject(new Error(data.error));
break;
}
};
ws.onerror = (error) => {
console.error('WebSocket error during filename template apply:', error);
reject(new Error('Connection error'));
};
});
// Start the filename template operation
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
const requestBody = {};
if (filePaths) {
requestBody.file_paths = filePaths;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || 'Failed to start filename template operation');
}
// Wait for the operation to complete via WebSocket
const result = await operationComplete;
// Show appropriate success message based on results
if (result.status === 'cancelled') {
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
} else if (result.failures === 0) {
showToast('toast.loras.filenameTemplateSuccess', {
count: result.success,
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
}, 'success');
} else {
showToast('toast.loras.filenameTemplatePartialSuccess', {
success: result.success,
failures: result.failures,
total: result.total
}, 'warning');
}
} catch (error) {
console.error('Error applying filename template:', error);
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
throw error;
} finally {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
}, {
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
});
}
async stopExampleImages() {
try {
const response = await fetch('/api/lm/stop-example-images', {
+197 -1
View File
@@ -1,12 +1,14 @@
import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js';
import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
import {
DOWNLOAD_PATH_TEMPLATES,
MAPPABLE_BASE_MODELS,
PATH_TEMPLATE_PLACEHOLDERS,
DEFAULT_PATH_TEMPLATES,
FILENAME_TEMPLATE_PLACEHOLDERS,
DEFAULT_FILENAME_TEMPLATES,
DEFAULT_PRIORITY_TAG_CONFIG,
getMappableBaseModelsDynamic
} from '../utils/constants.js';
@@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = {
controlnet: 'settings.folderSettings.subTypeControlnet',
};
// Singular filename-template model type -> plural API model type (MODEL_TYPES)
const FILENAME_TEMPLATE_MODEL_TYPES = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings',
};
export class SettingsManager {
constructor() {
this.initialized = false;
@@ -147,6 +156,25 @@ export class SettingsManager {
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
let filenameTemplates = backendSettings?.download_filename_templates;
if (typeof filenameTemplates === 'string') {
try {
const parsed = JSON.parse(filenameTemplates);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
filenameTemplates = parsed;
}
} catch (parseError) {
console.warn('Failed to parse download_filename_templates string from backend, using defaults');
filenameTemplates = null;
}
}
if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) {
filenameTemplates = {};
}
merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates };
const priorityTags = backendSettings?.priority_tags;
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
@@ -428,6 +456,31 @@ export class SettingsManager {
}
});
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const filenameInput = document.getElementById(`${modelType}FilenameTemplate`);
if (filenameInput) {
filenameInput.addEventListener('input', (e) => {
const template = e.target.value;
settingsManager.validateFilenameTemplate(modelType, template);
settingsManager.updateFilenamePreview(modelType, template);
settingsManager.updateFilenameTemplateApplyButton(modelType, template);
});
filenameInput.addEventListener('blur', (e) => {
const template = e.target.value;
if (settingsManager.validateFilenameTemplate(modelType, template)) {
settingsManager.updateFilenameTemplate(modelType, template);
}
});
filenameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.target.blur();
}
});
}
});
const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeInput) {
autoOrganizeInput.addEventListener('keydown', (event) => {
@@ -1142,6 +1195,9 @@ export class SettingsManager {
// Load download path templates
this.loadDownloadPathTemplates();
// Load download filename templates
this.loadFilenameTemplates();
// Load priority tag settings
this.loadPriorityTagSettings();
@@ -2797,6 +2853,146 @@ export class SettingsManager {
}
}
loadFilenameTemplates() {
const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES;
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
const input = document.getElementById(`${modelType}FilenameTemplate`);
if (!input) return;
const template = templates[modelType] || '';
input.value = template;
this.validateFilenameTemplate(modelType, template);
this.updateFilenamePreview(modelType, template);
this.updateFilenameTemplateApplyButton(modelType, template);
});
}
validateFilenameTemplate(modelType, template) {
const validationElement = document.getElementById(`${modelType}FilenameValidation`);
if (!validationElement) return true;
// Reset validation state
validationElement.innerHTML = '';
validationElement.className = 'template-validation';
if (!template) {
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.keepOriginal', {}, 'Valid (keep original filename)')}`;
validationElement.classList.add('valid');
return true;
}
// A filename stem cannot contain path separators or OS-illegal characters
const invalidChars = /[/\\<>:"|?*]/;
if (invalidChars.test(template)) {
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidChars', {}, 'Invalid characters detected (a filename cannot contain / \\ < > : " | ? *)')}`;
validationElement.classList.add('invalid');
return false;
}
// Extract placeholders
const placeholderRegex = /\{([^}]+)\}/g;
const matches = template.match(placeholderRegex) || [];
// Check for invalid placeholders
const invalidPlaceholders = matches.filter(match =>
!FILENAME_TEMPLATE_PLACEHOLDERS.includes(match)
);
if (invalidPlaceholders.length > 0) {
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
validationElement.classList.add('invalid');
return false;
}
// Template is valid
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`;
validationElement.classList.add('valid');
return true;
}
updateFilenameTemplate(modelType, template) {
if (!this.validateFilenameTemplate(modelType, template)) {
return; // Don't save invalid templates
}
// Update state
if (!state.global.settings.download_filename_templates) {
state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES };
}
state.global.settings.download_filename_templates[modelType] = template;
// Update preview and apply-button state
this.updateFilenamePreview(modelType, template);
this.updateFilenameTemplateApplyButton(modelType, template);
// Save settings
this.saveFilenameTemplates();
}
updateFilenameTemplateApplyButton(modelType, template) {
const button = document.getElementById(`${modelType}ApplyFilenameTemplate`);
if (!button) return;
// An empty template keeps original filenames, so there is nothing to apply
button.disabled = !template;
}
updateFilenamePreview(modelType, template) {
const previewElement = document.getElementById(`${modelType}FilenamePreview`);
if (!previewElement) return;
if (!template) {
// Empty template keeps the original filename untouched
previewElement.textContent = 'V1.safetensors';
} else {
const exampleStem = template
.replaceAll('{model_name}', 'model-name')
.replaceAll('{version_name}', 'v3')
.replaceAll('{base_model}', 'Flux.1 D')
.replaceAll('{author}', 'authorname')
.replaceAll('{first_tag}', 'style')
.replaceAll('{hash_short}', 'a1b2c3d4e5')
.replaceAll('{original_name}', 'V1');
previewElement.textContent = `${exampleStem}.safetensors`;
}
previewElement.style.display = 'block';
}
async saveFilenameTemplates() {
try {
// Save to backend using universal save method
await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates);
showToast('toast.settings.filenameTemplatesUpdated', {}, 'success');
} catch (error) {
console.error('Error saving download filename templates:', error);
showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error');
}
}
async applyFilenameTemplate(modelType) {
const template = state.global.settings.download_filename_templates?.[modelType] || '';
if (!template) {
showToast('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
return;
}
if (!confirm(translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? The original filename is preserved in each model\'s metadata.'))) {
return;
}
try {
const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]);
await apiClient.applyFilenameTemplate();
resetAndReload(true);
} catch (error) {
// The API client already surfaced a toast with the failure reason
console.error('Error applying filename template:', error);
}
}
toggleSettings() {
if (this.isOpen) {
modalManager.closeModal('settingsModal');
+3 -1
View File
@@ -1,7 +1,7 @@
// Create the new hierarchical state structure
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '',
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
recipes_path: '',
base_model_path_mappings: {},
download_path_templates: {},
download_filename_templates: {},
example_images_path: '',
example_images_open_mode: 'system',
example_images_local_root: '',
@@ -74,6 +75,7 @@ export function createDefaultSettings() {
...DEFAULT_SETTINGS_BASE,
base_model_path_mappings: {},
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
+19
View File
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
other: ''
};
// Valid placeholders for download filename templates (opt-in rename of
// downloaded safetensors; the result is a filename stem, no path separators)
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
'{model_name}',
'{version_name}',
'{base_model}',
'{author}',
'{first_tag}',
'{hash_short}',
'{original_name}'
];
// Default filename templates per model type; empty string keeps the original filename
export const DEFAULT_FILENAME_TEMPLATES = {
lora: '',
checkpoint: '',
embedding: ''
};
// Model type labels for UI
export const MODEL_TYPE_LABELS = {
lora: 'LoRA Models',
@@ -198,6 +198,82 @@
</div>
</div>
<!-- Filename Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.filenameTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{hash_short}</span>
<span class="placeholder-tag">{original_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control">
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="loraFilenameValidation"></div>
<div class="template-preview" id="loraFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control">
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="checkpointFilenameValidation"></div>
<div class="template-preview" id="checkpointFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control">
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="embeddingFilenameValidation"></div>
<div class="template-preview" id="embeddingFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
</div>
</div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
@@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
@@ -0,0 +1,276 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
},
createDefaultSettings: () => ({
language: 'en',
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
getModelApiClient: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: [],
FILENAME_TEMPLATE_PLACEHOLDERS: [
'{model_name}',
'{version_name}',
'{base_model}',
'{author}',
'{first_tag}',
'{hash_short}',
'{original_name}',
],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
getMappableBaseModelsDynamic: () => [],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (key, params, fallback) => {
if (params && fallback) {
return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match);
}
return fallback ?? '';
},
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock('../../../static/js/managers/BannerService.js', () => ({
bannerService: {
registerBanner: vi.fn(),
},
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { state } from '../../../static/js/state/index.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
const createManager = () => {
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendFilenameTemplateUi = (modelType = 'lora') => {
document.body.innerHTML = `
<input id="${modelType}FilenameTemplate" />
<div id="${modelType}FilenameValidation"></div>
<div id="${modelType}FilenamePreview"></div>
<button id="${modelType}ApplyFilenameTemplate" type="button"></button>
`;
};
describe('SettingsManager filename templates', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
state.global.settings = {
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
};
});
it('treats an empty template as valid (keep original filename)', () => {
appendFilenameTemplateUi();
const manager = createManager();
expect(manager.validateFilenameTemplate('lora', '')).toBe(true);
const validation = document.getElementById('loraFilenameValidation');
expect(validation.classList.contains('valid')).toBe(true);
expect(validation.textContent).toContain('keep original filename');
});
it('rejects templates with path separators or OS-illegal characters', () => {
appendFilenameTemplateUi();
const manager = createManager();
expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false);
expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false);
const validation = document.getElementById('loraFilenameValidation');
expect(validation.classList.contains('invalid')).toBe(true);
});
it('rejects unknown placeholders', () => {
appendFilenameTemplateUi();
const manager = createManager();
expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false);
const validation = document.getElementById('loraFilenameValidation');
expect(validation.textContent).toContain('{bogus}');
});
it('accepts a template using only known placeholders', () => {
appendFilenameTemplateUi();
const manager = createManager();
const template = '{base_model}-{model_name}-{version_name}-{hash_short}';
expect(manager.validateFilenameTemplate('lora', template)).toBe(true);
expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true);
});
it('saves a valid template via saveSetting with the merged dict', () => {
appendFilenameTemplateUi();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.updateFilenameTemplate('lora', '{model_name}');
expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}');
expect(manager.saveSetting).toHaveBeenCalledWith(
'download_filename_templates',
{ lora: '{model_name}', checkpoint: '', embedding: '' },
);
});
it('does not save an invalid template', () => {
appendFilenameTemplateUi();
const manager = createManager();
manager.saveSetting = vi.fn().mockResolvedValue();
manager.updateFilenameTemplate('lora', '{unknown_placeholder}');
expect(state.global.settings.download_filename_templates.lora).toBe('');
expect(manager.saveSetting).not.toHaveBeenCalled();
});
it('previews the original filename when the template is empty', () => {
appendFilenameTemplateUi();
const manager = createManager();
manager.updateFilenamePreview('lora', '');
expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors');
});
it('renders a preview with example placeholder values', () => {
appendFilenameTemplateUi();
const manager = createManager();
manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}');
expect(document.getElementById('loraFilenamePreview').textContent)
.toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors');
});
it('disables the apply button when the template is empty', () => {
appendFilenameTemplateUi();
const manager = createManager();
manager.updateFilenameTemplateApplyButton('lora', '');
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(true);
manager.updateFilenameTemplateApplyButton('lora', '{model_name}');
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(false);
});
it('merges backend download_filename_templates over defaults', () => {
const manager = createManager();
const merged = manager.mergeSettingsWithDefaults({
download_filename_templates: { lora: '{model_name}' },
});
expect(merged.download_filename_templates).toEqual({
lora: '{model_name}',
checkpoint: '',
embedding: '',
});
const fromString = manager.mergeSettingsWithDefaults({
download_filename_templates: '{"checkpoint":"{hash_short}"}',
});
expect(fromString.download_filename_templates).toEqual({
lora: '',
checkpoint: '{hash_short}',
embedding: '',
});
});
it('shows an info toast and does nothing when applying an empty template', async () => {
appendFilenameTemplateUi();
const manager = createManager();
vi.stubGlobal('confirm', vi.fn(() => true));
await manager.applyFilenameTemplate('lora');
expect(showToast).toHaveBeenCalledWith('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
expect(getModelApiClient).not.toHaveBeenCalled();
});
it('applies the template through the model API client and reloads', async () => {
appendFilenameTemplateUi();
state.global.settings.download_filename_templates.lora = '{model_name}';
const manager = createManager();
vi.stubGlobal('confirm', vi.fn(() => true));
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
getModelApiClient.mockReturnValue(apiClient);
await manager.applyFilenameTemplate('lora');
expect(getModelApiClient).toHaveBeenCalledWith('loras');
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
expect(resetAndReload).toHaveBeenCalledWith(true);
});
it('does not apply when the confirm dialog is declined', async () => {
appendFilenameTemplateUi();
state.global.settings.download_filename_templates.lora = '{model_name}';
const manager = createManager();
vi.stubGlobal('confirm', vi.fn(() => false));
await manager.applyFilenameTemplate('lora');
expect(getModelApiClient).not.toHaveBeenCalled();
expect(resetAndReload).not.toHaveBeenCalled();
});
});
@@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
@@ -36,6 +36,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {},
getMappableBaseModelsDynamic: () => [],
}));
@@ -34,6 +34,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {},
getMappableBaseModelsDynamic: () => [],
}));
@@ -23,6 +23,7 @@ from py.services.metadata_sync_service import MetadataSyncService
from py.services.model_file_service import AutoOrganizeResult
from py.services.model_update_service import ModelVersionRecord
from py.services.service_registry import ServiceRegistry
from py.services.use_cases import FilenameTemplateUseCase
from py.services.websocket_manager import ws_manager
from py.utils.exif_utils import ExifUtils
from py.utils.metadata_manager import MetadataManager
@@ -126,9 +127,11 @@ async def create_test_client(service) -> TestClient[Any, Any]:
@pytest.fixture(autouse=True)
def reset_ws_manager_state():
ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear()
yield
ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear()
@@ -762,6 +765,105 @@ def test_auto_organize_conflict_when_running(mock_service):
asyncio.run(scenario())
def test_apply_filename_template_route_emits_progress(
mock_service, monkeypatch: pytest.MonkeyPatch
):
async def fake_execute(self, file_paths=None, progress_callback=None):
result = AutoOrganizeResult()
result.total = 1
result.processed = 1
result.success_count = 1
result.operation_type = "filename_template"
if progress_callback is not None:
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "started"}
)
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "completed"}
)
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/apply-filename-template",
json={"file_paths": ["/tmp/a.safetensors"]},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["summary"]["operation_type"] == "filename_template"
progress = ws_manager.get_filename_template_progress()
assert progress is not None
assert progress["status"] == "completed"
# Auto-organize progress state must stay untouched.
assert ws_manager.get_auto_organize_progress() is None
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_get_parses_query_file_paths(
mock_service, monkeypatch: pytest.MonkeyPatch
):
captured = {}
async def fake_execute(self, file_paths=None, progress_callback=None):
captured["file_paths"] = file_paths
result = AutoOrganizeResult()
result.operation_type = "filename_template"
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get(
"/api/lm/test-models/apply-filename-template",
params={"file_paths": "/tmp/a.safetensors, /tmp/b.safetensors"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert captured["file_paths"] == [
"/tmp/a.safetensors",
"/tmp/b.safetensors",
]
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_conflict_when_running(mock_service):
async def scenario():
client = await create_test_client(mock_service)
try:
await ws_manager.broadcast_filename_template_progress(
{"type": "filename_template_progress", "status": "started"}
)
response = await client.post("/api/lm/test-models/apply-filename-template")
payload = await response.json()
assert response.status == 409
assert payload == {
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
}
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
async def scenario():
@@ -0,0 +1,147 @@
"""Tests for the post-download filename template rename phase."""
import json
from pathlib import Path
import pytest
from py.services.download_manager import DownloadManager
from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import get_settings_manager
class DummyScanner:
def __init__(self, root: Path):
self._root = root
self.model_type = "lora"
self.updates = []
def get_model_roots(self):
return [str(self._root)]
async def update_single_model_cache(self, original_path, new_path, metadata):
self.updates.append((original_path, new_path, metadata))
return True
@pytest.fixture
def download_manager() -> DownloadManager:
return DownloadManager()
@pytest.fixture(autouse=True)
def no_recipe_scanner(monkeypatch: pytest.MonkeyPatch):
async def _no_scanner():
return None
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _no_scanner)
def _set_template(template: str, model_type: str = "lora") -> None:
manager = get_settings_manager()
templates = dict(manager.settings.get("download_filename_templates") or {})
templates[model_type] = template
manager.settings["download_filename_templates"] = templates
def _write_model(root: Path, stem: str, model_name: str, sha256: str) -> Path:
model_path = root / f"{stem}.safetensors"
model_path.write_bytes(b"model")
metadata_path = root / f"{stem}.metadata.json"
metadata_path.write_text(
json.dumps(
{
"file_name": stem,
"file_path": model_path.as_posix(),
"model_name": model_name,
"sha256": sha256,
"civitai": {"id": 1},
}
)
)
return model_path
async def test_download_rename_applies_filename_template(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("{model_name}-{hash_short}")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
download_manager._active_downloads["dl1"] = {"file_path": model_path.as_posix()}
downloaded_metadata = [
{
"file_path": model_path.as_posix(),
"file_name": "V1",
"model_name": "My Model",
"sha256": "abcdef0123456789",
"civitai": {"id": 1},
}
]
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=downloaded_metadata,
download_id="dl1",
)
new_path = tmp_path / "My Model-abcdef0123.safetensors"
assert new_path.exists()
assert not model_path.exists()
new_metadata = json.loads(
(tmp_path / "My Model-abcdef0123.metadata.json").read_text()
)
assert new_metadata["original_file_name"] == "V1"
assert (
download_manager._active_downloads["dl1"]["file_path"]
== new_path.as_posix()
)
async def test_download_rename_keeps_original_on_conflict(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("{model_name}-{hash_short}")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
# Conflicting target already exists.
(tmp_path / "My Model-abcdef0123.safetensors").write_bytes(b"other")
downloaded_metadata = [
{
"file_path": model_path.as_posix(),
"file_name": "V1",
"model_name": "My Model",
"sha256": "abcdef0123456789",
"civitai": {"id": 1},
}
]
# Must not raise: a rename conflict never fails the download.
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=downloaded_metadata,
download_id=None,
)
assert model_path.exists()
async def test_download_rename_noop_without_template(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=[{"file_path": model_path.as_posix()}],
download_id=None,
)
assert model_path.exists()
assert (tmp_path / "V1.metadata.json").exists()
@@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path):
assert payload["file_name"] == new_name
@pytest.mark.asyncio
async def test_rename_model_records_original_file_name(tmp_path: Path):
old_name = "V1"
new_name = "flux-my-model-v3"
model_path = tmp_path / f"{old_name}.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / f"{old_name}.metadata.json"
metadata_payload = {
"file_name": old_name,
"file_path": model_path.as_posix(),
}
metadata_path.write_text(json.dumps(metadata_payload))
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
service = ModelLifecycleService(
scanner=DummyScanner(),
metadata_manager=PassthroughMetadataManager(),
metadata_loader=metadata_loader,
)
await service.rename_model(
file_path=model_path.as_posix(),
new_file_name=new_name,
)
saved_metadata = json.loads((tmp_path / f"{new_name}.metadata.json").read_text())
assert saved_metadata["original_file_name"] == old_name
# A second rename keeps the very first recorded name.
second_name = "flux-my-model-v4"
await service.rename_model(
file_path=(tmp_path / f"{new_name}.safetensors").as_posix(),
new_file_name=second_name,
)
saved_metadata = json.loads(
(tmp_path / f"{second_name}.metadata.json").read_text()
)
assert saved_metadata["original_file_name"] == old_name
@pytest.mark.asyncio
async def test_rename_model_with_dotted_basename(tmp_path: Path):
old_name = "model.v1"
+178 -2
View File
@@ -19,6 +19,7 @@ from py.services.use_cases import (
DownloadModelEarlyAccessError,
DownloadModelUseCase,
DownloadModelValidationError,
FilenameTemplateUseCase,
ImportExampleImagesUseCase,
ImportExampleImagesValidationError,
)
@@ -33,7 +34,7 @@ from py.utils.example_images_processor import (
ExampleImagesValidationError,
)
from py.utils.metadata_manager import MetadataManager
from tests.conftest import MockModelService, MockScanner
from tests.conftest import MockCache, MockModelService, MockScanner
class StubLockProvider:
@@ -502,4 +503,179 @@ async def test_import_example_images_use_case_propagates_generic_error() -> None
request = DummyJsonRequest({"model_hash": "abc", "file_paths": ["/tmp/file"]})
with pytest.raises(ExampleImagesImportError):
await use_case.execute(request) # pyright: ignore[reportArgumentType]
await use_case.execute(request) # pyright: ignore[reportArgumentType]
class StubLifecycleService:
def __init__(self, scanner: Optional[MockScanner] = None) -> None:
self.renames: List[Dict[str, str]] = []
self.error: Optional[Exception] = None
self.cancel_on_rename = False
self._scanner = scanner
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
if self.error is not None:
raise self.error
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})
if self.cancel_on_rename and self._scanner is not None:
self._scanner.cancel_task()
return {"success": True, "new_file_path": file_path}
def _filename_template_model(
file_path: str,
model_name: str,
sha256: str = "abcdef0123456789",
) -> Dict[str, Any]:
return {
"file_path": file_path,
"file_name": file_path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
"model_name": model_name,
"sha256": sha256,
"civitai": {"id": 1},
}
def _set_filename_template(template: str, model_type: str = "lora") -> None:
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
templates = dict(manager.settings.get("download_filename_templates") or {})
templates[model_type] = template
manager.settings["download_filename_templates"] = templates
def _make_filename_template_use_case(
scanner: MockScanner,
lifecycle: StubLifecycleService,
lock_provider: Optional[StubLockProvider] = None,
) -> FilenameTemplateUseCase:
return FilenameTemplateUseCase(
scanner=scanner,
lifecycle_service=lifecycle, # pyright: ignore[reportArgumentType]
lock_provider=lock_provider or StubLockProvider(),
model_type="lora",
)
async def test_filename_template_use_case_renames_models() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
progress = ProgressCollector()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=progress)
assert result.status == "success"
assert result.operation_type == "filename_template"
assert result.total == 2
assert result.success_count == 2
assert result.failure_count == 0
assert lifecycle.renames == [
{"file_path": "/library/alpha.safetensors", "new_file_name": "Alpha-abcdef0123"},
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"},
]
statuses = [event["status"] for event in progress.events]
assert statuses[0] == "started"
assert statuses[-1] == "completed"
assert all(event["type"] == "filename_template_progress" for event in progress.events)
async def test_filename_template_use_case_skips_unchanged_names() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/Alpha-abcdef0123.safetensors", "Alpha"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.success_count == 0
assert result.skipped_count == 1
assert lifecycle.renames == []
async def test_filename_template_use_case_skips_all_when_template_empty() -> None:
_set_filename_template("")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.skipped_count == 1
assert lifecycle.renames == []
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
_set_filename_template("{model_name}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
lifecycle.error = ValueError("A file with this name already exists")
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.status == "success"
assert result.failure_count == 2
assert result.success_count == 0
assert len(result.results) == 2
async def test_filename_template_use_case_honours_cancellation() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService(scanner=scanner)
lifecycle.cancel_on_rename = True
progress = ProgressCollector()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=progress)
assert result.status == "cancelled"
assert len(lifecycle.renames) == 1
assert progress.events[-1]["status"] == "cancelled"
async def test_filename_template_use_case_filters_file_paths() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(
file_paths=["/library/beta.safetensors"], progress_callback=None
)
assert result.total == 1
assert lifecycle.renames == [
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"}
]
async def test_filename_template_use_case_rejects_when_lock_held() -> None:
_set_filename_template("{model_name}")
scanner = MockScanner(cache=MockCache())
lifecycle = StubLifecycleService()
lock_provider = StubLockProvider()
lock_provider.running = True
use_case = _make_filename_template_use_case(scanner, lifecycle, lock_provider)
with pytest.raises(AutoOrganizeInProgressError):
await use_case.execute(progress_callback=None)
+140
View File
@@ -3,6 +3,7 @@ import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.service_registry import ServiceRegistry
from py.utils.utils import (
calculate_filename_for_model,
calculate_recipe_fingerprint,
calculate_relative_path_for_model,
get_lora_info,
@@ -164,6 +165,145 @@ def test_calculate_recipe_fingerprint_empty_input():
assert calculate_recipe_fingerprint([]) == ""
def _set_filename_templates(isolated_settings, template, model_types=("lora", "checkpoint", "embedding")):
isolated_settings["download_filename_templates"] = {
model_type: template for model_type in model_types
}
def test_calculate_filename_returns_empty_without_template(isolated_settings):
model_data = {"model_name": "Model", "file_path": "/models/V1.safetensors"}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_substitutes_all_placeholders(isolated_settings):
_set_filename_templates(
isolated_settings,
"{base_model}-{model_name}-{version_name}-{author}-{first_tag}-{hash_short}-{original_name}",
)
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"tags": ["Style"],
"sha256": "ABCDEF0123456789",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1, "name": "v3", "creator": {"username": "Author"}},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "SDXL-My Model-v3-Author-style-abcdef0123-V1"
def test_calculate_filename_hash_short_empty_when_unknown(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}-{hash_short}")
model_data = {
"model_name": "My Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
# Missing hash leaves an empty segment; the dangling separator collapses.
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_missing_metadata_produces_empty_segments(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_rejects_path_separators(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}/{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == ""
_set_filename_templates(isolated_settings, "{base_model}\\{model_name}")
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_strips_illegal_characters(isolated_settings):
_set_filename_templates(isolated_settings, '{model_name}:"custom"')
model_data = {
"model_name": "My:Model*",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "My_Modelcustom"
def test_calculate_filename_empty_result_returns_empty(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_uses_base_model_mapping(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
isolated_settings["base_model_path_mappings"] = {"SDXL": "sdxl-mapped"}
model_data = {
"model_name": "Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "sdxl-mapped-Model"
def test_calculate_filename_embedding_replaces_spaces(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model} {model_name}")
model_data = {
"model_name": "My Model",
"base_model": "Base Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "embedding") == "Base_Model_My_Model"
def test_calculate_filename_original_name_falls_back_to_file_name(isolated_settings):
_set_filename_templates(isolated_settings, "{original_name}-{hash_short}")
model_data = {
"file_name": "legacy-name",
"sha256": "0123456789abcdef",
}
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
@pytest.mark.parametrize(
"original, expected",
[