feat(settings)!: remove multi-library support

This commit is contained in:
Will Miao
2026-11-09 20:53:09 +08:00
parent 1c652e76b7
commit 802b82a806
37 changed files with 389 additions and 1116 deletions
@@ -49,9 +49,10 @@ python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_conte
- Settings file: `<settings_dir>/settings.json`.
- Cache root: `<settings_dir>/cache`.
- Canonical cache files:
- Model cache: `cache/model/<active_library>.sqlite`.
- Recipe cache: `cache/recipe/<active_library>.sqlite`.
- Model update cache: `cache/model_update/<active_library>.sqlite`.
- Model cache: `cache/model/default.sqlite`.
- Recipe cache: `cache/recipe/default.sqlite`.
- Model update cache: `cache/model_update/default.sqlite`.
- Upgrades copy legacy library-named caches to these paths.
- Recipe FTS: `cache/fts/recipe_fts.sqlite`.
- Tag FTS: `cache/fts/tag_fts.sqlite`.
- Symlink map: `cache/symlink/symlink_map.json`.
@@ -61,12 +62,12 @@ python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_conte
## Data Location Rules
- Model roots come from `settings.folder_paths` and the active library payload under `settings.libraries[active_library]`.
- Model roots come from top-level `settings.folder_paths`. Legacy `settings.libraries[active_library]` payloads are migrated and removed at startup.
- Model metadata JSON sidecars live next to the model file as `<model basename>.metadata.json`.
- Recipes root is `settings.recipes_path` when it is a non-empty string. If empty, use the first configured LoRA root plus `/recipes`.
- Recipe JSON files are named `*.recipe.json` under the recipes root and may be nested in folders.
- Example image root is `settings.example_images_path`.
- If multiple libraries are configured, example images are stored under `<example_images_path>/<sanitized_library>/<sha256>/`; otherwise they are under `<example_images_path>/<sha256>/`.
- Example images are stored under `<example_images_path>/<sha256>/`. Legacy library-named subfolders are consolidated into this root at startup.
## Useful Cache Tables
@@ -314,15 +314,10 @@ def resolve_recipes_root(settings: dict[str, Any], active_library: str) -> Path
def example_images_payload(settings: dict[str, Any], active_library: str) -> dict[str, Any]:
root = settings.get("example_images_path") or ""
libraries = settings.get("libraries")
library_count = len(libraries) if isinstance(libraries, dict) else 0
scoped = library_count > 1
root_path = Path(expand_path(root)) if isinstance(root, str) and root else None
library_root = root_path / sanitize_library_name(active_library) if root_path and scoped else root_path
return {
"root": str(root_path or ""),
"uses_library_scoped_folders": scoped,
"library_root": str(library_root or ""),
"library_root": str(root_path or ""),
}
+5 -2
View File
@@ -1,7 +1,10 @@
# Multi-Library Management for Standalone Mode
> **Deprecated:** Multi-library support is deprecated and will be removed in an
> upcoming release. Multiple folder roots within a single library
> **Removed:** Multi-library support and library switching have been removed.
> On upgrade, folder paths from all configured libraries are merged into the
> previously active library; its default download, workflow, and recipe folders
> are preserved. The `libraries` and `active_library` keys are then removed from
> `settings.json`. Multiple folder roots within a single library
> (`folder_paths` / `extra_folder_paths`) remain fully supported. This document
> is kept for historical reference only.
+5 -8
View File
@@ -233,11 +233,10 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> state, the bulk-fetch rate-limit copy, the "fetch metadata" post-scan banner, two download
> link errors and two duplicate-delete follow-ups — 36 keys left behind as
> `[TODO: Translate]`. All 36 are now translated in all 9 locales (renderings in §2, "First-run
> welcome page and fetch-status copy"). The same pass re-translated the three keys whose English
> text had changed without being re-queued: `settings.folderSettings.activeLibrary` /
> `.activeLibraryHelp` (multi-library deprecation, commit `1a6b0f78`) and
> `toast.contextMenu.linkHfSuccess` (the toast now says "model source" instead of
> "HuggingFace", commit `5ab0e88ab`). No `[TODO: Translate]` placeholder remains.
> welcome page and fetch-status copy"). The same pass also re-translated
> `toast.contextMenu.linkHfSuccess`, whose English text had changed without being re-queued
> (the toast now says "model source" instead of "HuggingFace", commit `5ab0e88ab`). No
> `[TODO: Translate]` placeholder remains.
> **Status (2026-10, workflow management):** the Workflows page (`workflows.*`) manages the
> ComfyUI workflow JSON files in their own library folders and shipped with the settings toggle
@@ -1016,7 +1015,6 @@ Most of these strings are assembled from existing renderings rather than new ter
| directory (of the centralized sidecar path) | zh-CN 目录 · zh-TW 目錄 · ja ディレクトリ · ko 디렉터리 · fr dossier · de Verzeichnis · es directorio · ru каталог · he תיקייה |
| metadata storage | zh-CN 元数据存储 · zh-TW 中繼資料儲存 · ja メタデータの保存 · ko 메타데이터 저장 · fr Stockage des métadonnées · de Metadaten-Speicherung · es Almacenamiento de metadatos · ru Хранение метаданных · he אחסון מטא-נתונים |
| rate limit (of the metadata provider) | zh-CN 速率限制 · zh-TW 速率限制 · ja レート制限 · ko 속도 제한 · fr limite de débit · de Ratenlimit · es límite de solicitudes · ru лимит запросов · he מגבלת קצב |
| "(Deprecated)" (`settings.folderSettings.activeLibrary`) | zh-CN 已弃用 · zh-TW 已棄用 · ja 非推奨 · ko 사용 중단 예정 · fr obsolète · de veraltet · es obsoleto · ru устарело · he מיושן |
| bulk-fetch banner title | zh-CN 让你的模型库焕发生机 · zh-TW 讓您的模型庫煥發生機 · ja ライブラリに命を吹き込もう · ko 라이브러리에 생기를 불어넣으세요 · fr Donnez vie à votre bibliothèque · de Erwecken Sie Ihre Bibliothek zum Leben · es Dale vida a tu biblioteca · ru Вдохните жизнь в свою библиотеку · he החייה את הספרייה שלך |
| defer ("Later") / banner ("Maybe Later") | zh-CN 稍后 / 以后再说 · zh-TW 稍後 / 稍後再說 · ja 後で / また今度 · ko 나중에 / 나중에 하기 · fr Plus tard / Peut-être plus tard · de Später / Vielleicht später · es Más tarde / Quizás más tarde · ru Позже / Может быть, позже · he מאוחר יותר / אולי מאוחר יותר |
@@ -1182,8 +1180,7 @@ URL placeholders are the exception). As of the 2026-08 sweep, **all previously u
blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipes.batchImport*`
(fr/de/es/ru/he/ja/ko), `banners.communitySupport.*`, `modals.model.license.*`,
`globalContextMenu.fetchMissingLicenses.*`, the `doctor.*` issue/action/label subset,
`toast.settings.libraryLoadFailed` / `libraryActivateFailed`, `toast.api.moveFailed`,
`settings.extraFolderPaths.restartRequired`, `toast.recipes.recipeSaved`,
`toast.api.moveFailed`, `settings.extraFolderPaths.restartRequired`, `toast.recipes.recipeSaved`,
`sidebar.dragDrop.moveUnsupported`, `checkpoints.modelTypes.diffusion_model`
(ja/ko keep the English loanword), `initialization.recipes.title`.
+13 -14
View File
@@ -1,15 +1,15 @@
# Library Switching and Preview Routes
# Preview Routes
> **Deprecated:** Multi-library support (and therefore library switching) is
> deprecated and will be removed in an upcoming release. The dynamic preview
> route described here will remain — it also serves single-library
> installations.
Multi-library support and library switching have been removed. On upgrade, all
configured library folder paths are merged into the active library, and the
`libraries` / `active_library` keys are removed from `settings.json`. Legacy
library-named caches are migrated to the default cache namespace. Multiple
folder roots within that single library remain fully supported.
Library switching no longer requires restarting the backend. The preview
thumbnails shown in the UI are now served through a dynamic endpoint that
resolves files against the folders registered for the active library at request
time. This allows the multi-library flow to update model roots without touching
the aiohttp router, so previews remain available immediately after a switch.
The preview thumbnails shown in the UI are served through a dynamic endpoint
that resolves files against the currently configured folders at request time.
This keeps previews available when model roots change without touching the
aiohttp router.
## How the dynamic preview endpoint works
@@ -34,7 +34,6 @@ the aiohttp router, so previews remain available immediately after a switch.
checks preview requests against this cache.【F:py/config.py†L51-L68】【F:py/config.py†L180-L248】【F:py/config.py†L332-L346】
Both the ComfyUI runtime (`LoraManager.add_routes`) and the standalone launcher
(`StandaloneLoraManager.add_routes`) register the new preview routes instead of
mounting a static directory per root. Switching libraries therefore works
without restarting the application, and preview URLs generated before or after a
switch continue to resolve correctly.【F:py/lora_manager.py†L21-L82】【F:standalone.py†L302-L315】
(`StandaloneLoraManager.add_routes`) register the preview routes instead of
mounting a static directory per root. Preview URLs therefore continue to resolve
correctly when the configured roots change.【F:py/lora_manager.py†L21-L82】【F:standalone.py†L302-L315】
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "Passen Sie die Unschärfeintensität der Kopf- und Fußzeilen-Overlays auf Modell- und Rezeptkarten an (0 = keine Unschärfe, 20 = maximale Unschärfe)."
},
"folderSettings": {
"activeLibrary": "Aktive Bibliothek (veraltet)",
"activeLibraryHelp": "Zwischen den konfigurierten Bibliotheken wechseln, um die Standardordner zu aktualisieren. Eine Änderung der Auswahl lädt die Seite neu. Veraltet: Die Unterstützung mehrerer Bibliotheken wird in einem kommenden Release entfernt. Mehrere Stammordner innerhalb einer Bibliothek werden weiterhin vollständig unterstützt.",
"loadingLibraries": "Bibliotheken werden geladen...",
"noLibraries": "Keine Bibliotheken konfiguriert",
"defaultLoraRoot": "LoRA-Stammordner",
"defaultLoraRootHelp": "Legen Sie den Standard-LoRA-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultCheckpointRoot": "Checkpoint-Stammordner",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "Kompakt-Modus {state}",
"settingSaveFailed": "Fehler beim Speichern der Einstellung: {message}",
"displayDensitySet": "Anzeige-Dichte auf {density} gesetzt",
"libraryLoadFailed": "Bibliotheken konnten nicht geladen werden: {message}",
"libraryActivateFailed": "Bibliothek konnte nicht aktiviert werden: {message}",
"languageChangeFailed": "Fehler beim Ändern der Sprache: {message}",
"cacheCleared": "Cache-Dateien wurden erfolgreich gelöscht. Cache wird bei der nächsten Aktion neu aufgebaut.",
"cacheClearFailed": "Fehler beim Löschen des Caches: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "Adjust the blur intensity of the header and footer overlays on model and recipe cards (0 = no blur, 20 = maximum blur)."
},
"folderSettings": {
"activeLibrary": "Active Library (Deprecated)",
"activeLibraryHelp": "Switch between configured libraries to update default folders. Changing the selection reloads the page. Deprecated: multi-library support will be removed in an upcoming release. Multiple folder roots within a single library remain fully supported.",
"loadingLibraries": "Loading libraries...",
"noLibraries": "No libraries configured",
"defaultLoraRoot": "LoRA Root",
"defaultLoraRootHelp": "Set default LoRA root directory for downloads, imports and moves",
"defaultCheckpointRoot": "Checkpoint Root",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "Compact Mode {state}",
"settingSaveFailed": "Failed to save setting: {message}",
"displayDensitySet": "Display Density set to {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"languageChangeFailed": "Failed to change language: {message}",
"cacheCleared": "Cache files have been cleared successfully. Cache will rebuild on next action.",
"cacheClearFailed": "Failed to clear cache: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "Ajuste la intensidad de desenfoque de las superposiciones del encabezado y pie de página en las tarjetas de modelos y recetas (0 = sin desenfoque, 20 = desenfoque máximo)."
},
"folderSettings": {
"activeLibrary": "Biblioteca activa (obsoleto)",
"activeLibraryHelp": "Alterna entre las bibliotecas configuradas para actualizar las carpetas predeterminadas. Cambiar la selección recarga la página. Obsoleto: la compatibilidad con varias bibliotecas se eliminará en una próxima versión. Varias raíces de carpeta dentro de una misma biblioteca siguen siendo totalmente compatibles.",
"loadingLibraries": "Cargando bibliotecas...",
"noLibraries": "No hay bibliotecas configuradas",
"defaultLoraRoot": "Raíz de LoRA",
"defaultLoraRootHelp": "Establecer el directorio raíz predeterminado de LoRA para descargas, importaciones y movimientos",
"defaultCheckpointRoot": "Raíz de checkpoint",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "Modo compacto {state}",
"settingSaveFailed": "Error al guardar configuración: {message}",
"displayDensitySet": "Densidad de visualización establecida a {density}",
"libraryLoadFailed": "No se pudieron cargar las bibliotecas: {message}",
"libraryActivateFailed": "No se pudo activar la biblioteca: {message}",
"languageChangeFailed": "Error al cambiar idioma: {message}",
"cacheCleared": "Archivos de caché limpiados exitosamente. La caché se reconstruirá en la próxima acción.",
"cacheClearFailed": "Error al limpiar caché: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "Ajustez l'intensité du flou des superpositions d'en-tête et de pied de page sur les cartes de modèles et de Recipes (0 = aucun flou, 20 = flou maximal)."
},
"folderSettings": {
"activeLibrary": "Bibliothèque active (obsolète)",
"activeLibraryHelp": "Basculez entre les bibliothèques configurées pour mettre à jour les dossiers par défaut. Changer la sélection recharge la page. Obsolète : la prise en charge de plusieurs bibliothèques sera supprimée dans une prochaine version. Plusieurs racines de dossier au sein d’une même bibliothèque restent entièrement prises en charge.",
"loadingLibraries": "Chargement des bibliothèques...",
"noLibraries": "Aucune bibliothèque configurée",
"defaultLoraRoot": "Racine LoRA",
"defaultLoraRootHelp": "Définir le répertoire racine LoRA par défaut pour les téléchargements, imports et déplacements",
"defaultCheckpointRoot": "Racine Checkpoint",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "Mode compact {state}",
"settingSaveFailed": "Échec de la sauvegarde du paramètre : {message}",
"displayDensitySet": "Densité d'affichage définie sur {density}",
"libraryLoadFailed": "Échec du chargement des bibliothèques : {message}",
"libraryActivateFailed": "Échec de l'activation de la bibliothèque : {message}",
"languageChangeFailed": "Échec du changement de langue : {message}",
"cacheCleared": "Les fichiers de cache ont été vidés avec succès. Le cache sera reconstruit à la prochaine action.",
"cacheClearFailed": "Échec du vidage du cache : {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "כוונן את עוצמת הטשטוש של שכבת-העל בכותרת ובכותרות תחתונה בכרטיסי מודל ומתכונים (0 = ללא טשטוש, 20 = טשטוש מקסימלי)."
},
"folderSettings": {
"activeLibrary": "ספרייה פעילה (מיושן)",
"activeLibraryHelp": "החלפה בין הספריות המוגדרות לעדכן את תיקיות ברירת המחדל. שינוי הבחירה ירענן את הדף. מיושן: התמיכה בריבוי ספריות תוסר בגרסה קרובה. מספר תיקיות שורש בתוך ספרייה אחת עדיין נתמך במלואו.",
"loadingLibraries": "טוען ספריות...",
"noLibraries": "לא הוגדרו ספריות",
"defaultLoraRoot": "תיקיית שורש LoRA",
"defaultLoraRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של LoRA להורדות, ייבוא והעברות",
"defaultCheckpointRoot": "תיקיית שורש Checkpoint",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "מצב קומפקטי {state}",
"settingSaveFailed": "שמירת ההגדרה נכשלה: {message}",
"displayDensitySet": "צפיפות התצוגה הוגדרה ל-{density}",
"libraryLoadFailed": "לא ניתן היה לטעון את הספריות: {message}",
"libraryActivateFailed": "לא ניתן היה להפעיל את הספרייה: {message}",
"languageChangeFailed": "שינוי השפה נכשל: {message}",
"cacheCleared": "קבצי המטמון נוקו בהצלחה. המטמון ייבנה מחדש בפעולה הבאה.",
"cacheClearFailed": "ניקוי המטמון נכשל: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "モデルカードとレシピカードのヘッダー・フッターオーバーレイのぼかし強度を調整します(0 = ぼかしなし、20 = 最大ぼかし)。"
},
"folderSettings": {
"activeLibrary": "アクティブライブラリ(非推奨)",
"activeLibraryHelp": "設定済みのライブラリを切り替えてデフォルトのフォルダを更新します。選択を変更するとページが再読み込みされます。非推奨:複数ライブラリのサポートは今後のリリースで削除されます。単一ライブラリ内の複数のフォルダルートは引き続き完全にサポートされます。",
"loadingLibraries": "ライブラリを読み込み中...",
"noLibraries": "ライブラリが設定されていません",
"defaultLoraRoot": "LoRAルート",
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "Checkpointルート",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "コンパクトモード {state}",
"settingSaveFailed": "設定の保存に失敗しました:{message}",
"displayDensitySet": "表示密度が {density} に設定されました",
"libraryLoadFailed": "ライブラリを読み込めませんでした: {message}",
"libraryActivateFailed": "ライブラリをアクティブ化できませんでした: {message}",
"languageChangeFailed": "言語の変更に失敗しました:{message}",
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "모델 및 레시피 카드의 헤더와 푸터 오버레이 흐림 강도를 조정합니다 (0 = 흐림 없음, 20 = 최대 흐림)."
},
"folderSettings": {
"activeLibrary": "활성 라이브러리 (사용 중단 예정)",
"activeLibraryHelp": "구성된 라이브러리를 전환하여 기본 폴더를 업데이트합니다. 선택을 변경하면 페이지가 다시 로드됩니다. 사용 중단 예정: 다중 라이브러리 지원은 향후 릴리스에서 제거됩니다. 단일 라이브러리 내의 여러 폴더 루트는 계속 완전히 지원됩니다.",
"loadingLibraries": "라이브러리를 불러오는 중...",
"noLibraries": "구성된 라이브러리가 없습니다",
"defaultLoraRoot": "LoRA 루트",
"defaultLoraRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 LoRA 루트 디렉토리를 설정합니다",
"defaultCheckpointRoot": "Checkpoint 루트",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "컴팩트 모드 {state}",
"settingSaveFailed": "설정 저장 실패: {message}",
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
"libraryLoadFailed": "라이브러리를 불러오지 못했습니다: {message}",
"libraryActivateFailed": "라이브러리를 활성화하지 못했습니다: {message}",
"languageChangeFailed": "언어 변경 실패: {message}",
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
"cacheClearFailed": "캐시 지우기 실패: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "Настройте интенсивность размытия наложений верхнего и нижнего колонтитулов на карточках моделей и рецептов (0 = без размытия, 20 = максимальное размытие)."
},
"folderSettings": {
"activeLibrary": "Активная библиотека (устарело)",
"activeLibraryHelp": "Переключайтесь между настроенными библиотеками, чтобы обновить папки по умолчанию. Изменение выбора перезагружает страницу. Устарело: поддержка нескольких библиотек будет удалена в одном из следующих выпусков. Несколько корневых папок в пределах одной библиотеки по-прежнему полностью поддерживаются.",
"loadingLibraries": "Загрузка библиотек...",
"noLibraries": "Библиотеки не настроены",
"defaultLoraRoot": "Корневая папка LoRA",
"defaultLoraRootHelp": "Установить корневую папку LoRA по умолчанию для загрузок, импорта и перемещений",
"defaultCheckpointRoot": "Корневая папка Checkpoint",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "Компактный режим {state}",
"settingSaveFailed": "Не удалось сохранить настройку: {message}",
"displayDensitySet": "Плотность отображения установлена на {density}",
"libraryLoadFailed": "Не удалось загрузить библиотеки: {message}",
"libraryActivateFailed": "Не удалось активировать библиотеку: {message}",
"languageChangeFailed": "Не удалось изменить язык: {message}",
"cacheCleared": "Файлы кэша успешно очищены. Кэш будет пересобран при следующем действии.",
"cacheClearFailed": "Не удалось очистить кэш: {error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "调整模型和配方卡片上页眉和页脚叠加层的模糊强度(0 = 无模糊,20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "活动库(已弃用)",
"activeLibraryHelp": "在已配置的库之间切换以更新默认文件夹。更改选择将重新加载页面。已弃用:多库支持将在即将发布的版本中移除。单个库内的多个文件夹根目录仍完全受支持。",
"loadingLibraries": "正在加载库...",
"noLibraries": "尚未配置库",
"defaultLoraRoot": "LoRA 根目录",
"defaultLoraRootHelp": "设置下载、导入和移动时的默认 LoRA 根目录",
"defaultCheckpointRoot": "Checkpoint 根目录",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "紧凑模式 {state}",
"settingSaveFailed": "保存设置失败:{message}",
"displayDensitySet": "显示密度已设置为 {density}",
"libraryLoadFailed": "加载模型库失败:{message}",
"libraryActivateFailed": "激活模型库失败:{message}",
"languageChangeFailed": "切换语言失败:{message}",
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
"cacheClearFailed": "清除缓存失败:{error}",
-6
View File
@@ -648,10 +648,6 @@
"cardBlurAmountHelp": "調整模型和配方卡片上頁首和頁尾疊加層的模糊強度(0 = 無模糊,20 = 最大模糊)。"
},
"folderSettings": {
"activeLibrary": "使用中的資料庫(已棄用)",
"activeLibraryHelp": "在已設定的資料庫之間切換以更新預設資料夾。變更選項將重新載入頁面。已棄用:多資料庫支援將在即將推出的版本中移除。單一資料庫內的多個資料夾根目錄仍完全受支援。",
"loadingLibraries": "正在載入資料庫...",
"noLibraries": "尚未設定任何資料庫",
"defaultLoraRoot": "LoRA 根目錄",
"defaultLoraRootHelp": "設定下載、匯入和移動時的預設 LoRA 根目錄",
"defaultCheckpointRoot": "Checkpoint 根目錄",
@@ -2947,8 +2943,6 @@
"compactModeToggled": "緊湊模式已{state}",
"settingSaveFailed": "儲存設定失敗:{message}",
"displayDensitySet": "顯示密度已設為 {density}",
"libraryLoadFailed": "載入模型庫失敗:{message}",
"libraryActivateFailed": "啟動模型庫失敗:{message}",
"languageChangeFailed": "切換語言失敗:{message}",
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
"cacheClearFailed": "清除快取失敗:{error}",
+2 -19
View File
@@ -398,7 +398,7 @@ class Config:
return filtered
def save_folder_paths_to_settings(self):
"""Persist ComfyUI-derived folder paths to the multi-library settings."""
"""Persist ComfyUI-derived folder paths to the library settings."""
try:
ensure_settings_file(logger)
from .services.settings_manager import get_settings_manager
@@ -470,7 +470,7 @@ class Config:
# SettingsManager and never written into the library's
# folder_paths map here.
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
# keys so settings reloads round-trip them.
for key, roots in (self.other_folder_roots or {}).items():
target_folder_paths[key] = list(roots)
# ...and keep an other-model root that is configured but unavailable.
@@ -2287,23 +2287,6 @@ class Config:
len(self.extra_embeddings_roots or []),
)
def get_library_registry_snapshot(self) -> Dict[str, object]:
"""Return the current library registry and active library name."""
try:
from .services.settings_manager import get_settings_manager
settings_service = get_settings_manager()
libraries = settings_service.get_libraries()
active_library = settings_service.get_active_library_name()
return {
"active_library": active_library,
"libraries": libraries,
}
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to collect library registry snapshot: %s", exc)
return {"active_library": "", "libraries": {}}
# Global config instance
# NOTE: Guard against re-import. When ServiceRegistry.get_lora_scanner() triggers
-61
View File
@@ -1854,24 +1854,6 @@ class SettingsHandler:
self._metadata_provider_updater = metadata_provider_updater
self._downloader_factory = downloader_factory
async def get_libraries(self, request: web.Request) -> web.Response:
"""Return the registered libraries and the active selection."""
try:
snapshot = config.get_library_registry_snapshot()
libraries = snapshot.get("libraries", {})
active_library = snapshot.get("active_library", "")
return web.json_response(
{
"success": True,
"libraries": libraries,
"active_library": active_library,
}
)
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Error getting library registry: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_settings(self, request: web.Request) -> web.Response:
try:
response_data = {}
@@ -1983,47 +1965,6 @@ class SettingsHandler:
logger.error("Error getting priority tags: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def activate_library(self, request: web.Request) -> web.Response:
"""Activate the selected library."""
try:
data = await request.json()
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error parsing activate library request: %s", exc, exc_info=True
)
return web.json_response(
{"success": False, "error": "Invalid JSON payload"}, status=400
)
library_name = data.get("library") or data.get("library_name")
if not isinstance(library_name, str) or not library_name.strip():
return web.json_response(
{"success": False, "error": "Library name is required"}, status=400
)
try:
normalized_name = library_name.strip()
self._settings.activate_library(normalized_name)
snapshot = config.get_library_registry_snapshot()
libraries = snapshot.get("libraries", {})
active_library = snapshot.get("active_library", "")
return web.json_response(
{
"success": True,
"active_library": active_library,
"libraries": libraries,
}
)
except KeyError as exc:
logger.debug("Attempted to activate unknown library '%s'", library_name)
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(
"Error activating library '%s': %s", library_name, exc, exc_info=True
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_settings(self, request: web.Request) -> web.Response:
try:
data = await request.json()
@@ -4774,8 +4715,6 @@ class MiscHandlerSet:
"resolve_doctor_filename_conflicts": self.doctor.resolve_filename_conflicts,
"export_doctor_bundle": self.doctor.export_doctor_bundle,
"get_priority_tags": self.settings.get_priority_tags,
"get_settings_libraries": self.settings.get_libraries,
"activate_library": self.settings.activate_library,
"get_llm_models": self.settings.get_llm_models,
"get_provider_models": self.settings.get_provider_models,
"update_usage_stats": self.usage_stats.update_usage_stats,
-2
View File
@@ -32,8 +32,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/doctor/resolve-filename-conflicts", "resolve_doctor_filename_conflicts"),
RouteDefinition("POST", "/api/lm/doctor/export-bundle", "export_doctor_bundle"),
RouteDefinition("GET", "/api/lm/priority-tags", "get_priority_tags"),
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/init-status", "get_init_status"),
# First-run welcome page: info + gated initial scan kickoff. The GET alias
+1 -4
View File
@@ -203,10 +203,7 @@ class FirstRunManager:
self._settings.set("initial_scan_completed", True)
def _persistent_snapshot_exists(self) -> bool:
try:
library_name = self._settings.get_active_library_name()
except Exception: # pragma: no cover - defensive guard
library_name = "default"
library_name = "default"
candidates = (
(
resolve_cache_path_with_migration(
+2 -6
View File
@@ -401,11 +401,7 @@ class ModelUpdateService:
self._initialize_schema()
def _get_active_library_name(self) -> str:
try:
value = self._settings.get_active_library_name()
except Exception:
value = None
return value or "default"
return "default"
def _resolve_default_path(self, library_name: str) -> str:
env_override = os.environ.get("LORA_MANAGER_MODEL_UPDATE_DB")
@@ -416,7 +412,7 @@ class ModelUpdateService:
)
def on_library_changed(self) -> None:
"""Switch to the database for the active library."""
"""Re-resolve the default database after settings changes."""
if self._custom_db_path:
return
+1 -4
View File
@@ -1077,7 +1077,4 @@ class PersistentModelCache:
def get_persistent_cache() -> PersistentModelCache:
from .settings_manager import get_settings_manager # Local import to avoid cycles
library_name = get_settings_manager().get_active_library_name()
return PersistentModelCache.get_default(library_name)
return PersistentModelCache.get_default("default")
+3 -6
View File
@@ -89,7 +89,7 @@ class PersistentRecipeCache:
@classmethod
def clear_instances(cls) -> None:
"""Clear all cached instances (useful for library switching)."""
"""Clear all cached instances."""
with cls._instance_lock:
cls._instances.clear()
@@ -648,8 +648,5 @@ class PersistentRecipeCache:
def get_persistent_recipe_cache() -> PersistentRecipeCache:
"""Get the default persistent recipe cache instance for the active library."""
from .settings_manager import get_settings_manager
library_name = get_settings_manager().get_active_library_name()
return PersistentRecipeCache.get_default(library_name)
"""Get the persistent recipe cache instance."""
return PersistentRecipeCache.get_default("default")
+65 -78
View File
@@ -83,6 +83,8 @@ CORE_USER_SETTING_KEYS: Tuple[str, ...] = (
"folder_paths",
)
RUNTIME_ONLY_SETTING_KEYS = frozenset({"libraries", "active_library"})
# Threshold for aggressive cleanup: if file contains this many default keys, clean it up
DEFAULT_KEYS_CLEANUP_THRESHOLD = 10
@@ -535,7 +537,7 @@ class SettingsManager:
# Note: inserted_defaults no longer triggers save - defaults stay in memory only
def _migrate_to_library_registry(self) -> None:
"""Ensure settings include the multi-library registry structure."""
"""Ensure settings include one library, consolidating legacy entries."""
libraries = self.settings.get("libraries")
active_name = self.settings.get("active_library")
initial_bootstrap = self._bootstrap_reason == "missing"
@@ -650,39 +652,62 @@ class SettingsManager:
else:
self.settings["active_library"] = "default"
if len(sanitized_libraries) > 1:
active_name = self.settings["active_library"]
self._consolidate_libraries(active_name, sanitized_libraries)
changed = True
self._sync_active_library_to_root(save=changed and not initial_bootstrap)
if changed and initial_bootstrap:
self._needs_initial_save = True
self._warn_if_multi_library()
def _consolidate_libraries(
self, active_name: str, libraries: Mapping[str, Dict[str, Any]]
) -> None:
"""Merge legacy library paths into the active single-library settings."""
ordered_names = [active_name]
ordered_names.extend(name for name in libraries if name != active_name)
merged = copy.deepcopy(libraries[active_name])
def _warn_if_multi_library(self) -> None:
"""Surface a deprecation notice when more than one library is configured."""
libraries = self.settings.get("libraries", {})
if not isinstance(libraries, Mapping) or len(libraries) <= 1:
return
for mapping_key in ("folder_paths", "extra_folder_paths"):
combined: Dict[str, List[str]] = {}
seen: Dict[str, Set[str]] = {}
for name in ordered_names:
paths = self._normalize_folder_paths(
libraries[name].get(mapping_key, {})
)
for key, values in paths.items():
target = combined.setdefault(key, [])
key_seen = seen.setdefault(key, set())
for value in values:
identity = os.path.normcase(os.path.normpath(value))
if identity in key_seen:
continue
key_seen.add(identity)
target.append(value)
merged[mapping_key] = combined
merged["updated_at"] = self._current_timestamp()
self.settings["libraries"] = {active_name: merged}
self.settings["active_library"] = active_name
names = ", ".join(sorted(str(name) for name in libraries))
logger.warning(
"Multi-library support is deprecated and will be removed in an "
"upcoming release. Detected %d libraries in settings.json (%s). "
"Multiple folder roots within a single library remain fully "
"supported; please consolidate your libraries into one.",
logger.info(
"Consolidated %d legacy libraries into '%s': %s",
len(libraries),
active_name,
names,
)
self._add_startup_message(
code="multi-library-deprecated",
title="Multi-library support is deprecated",
code="multi-library-consolidated",
title="Libraries consolidated",
message=(
f"This installation has {len(libraries)} libraries configured in "
f"settings.json ({names}). Multi-library support is deprecated and "
"will be removed in an upcoming release; at that point all "
"libraries' folder paths will be merged into a single library. "
"Multiple folder roots within a single library remain fully "
"supported, so consider consolidating into one library now."
f"Multi-library support has been removed. Folder paths from "
f"{len(libraries)} libraries ({names}) were merged into "
f"'{active_name}'. Its default download, workflow, and recipe "
"folders were kept."
),
severity="warning",
severity="info",
actions=self._default_settings_actions(),
dismissible=True,
)
@@ -779,6 +804,20 @@ class SettingsManager:
)
self._save_settings()
def _migrate_legacy_cache_namespace(self, library_name: str) -> None:
original = self._original_disk_payload or {}
if not any(key in original for key in RUNTIME_ONLY_SETTING_KEYS):
return
from ..utils.cache_paths import CacheType, migrate_library_cache_to_default
for cache_type in (
CacheType.MODEL,
CacheType.MODEL_UPDATE,
CacheType.RECIPE,
):
migrate_library_cache_to_default(cache_type, library_name)
def _sync_active_library_to_root(self, *, save: bool = False) -> None:
"""Update top-level folder path settings to mirror the active library."""
libraries = self.settings.get("libraries", {})
@@ -790,6 +829,7 @@ class SettingsManager:
active_name = next(iter(libraries.keys()))
self.settings["active_library"] = active_name
self._migrate_legacy_cache_namespace(active_name)
active_library = libraries.get(active_name, {})
folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
self.settings["folder_paths"] = folder_paths
@@ -2534,7 +2574,8 @@ class SettingsManager:
if self._seed_template:
for key, value in self._seed_template.items():
minimal.setdefault(key, copy.deepcopy(value))
if key not in RUNTIME_ONLY_SETTING_KEYS:
minimal.setdefault(key, copy.deepcopy(value))
return minimal
@@ -2543,6 +2584,8 @@ class SettingsManager:
minimal = {}
for key, value in self.settings.items():
if key in RUNTIME_ONLY_SETTING_KEYS:
continue
default_value = defaults.get(key)
# Core settings are always saved (even if equal to default)
@@ -2581,25 +2624,6 @@ class SettingsManager:
active_name = self.get_active_library_name()
return copy.deepcopy(libraries.get(active_name, {}))
def activate_library(self, library_name: str) -> None:
"""Activate a library by name and refresh dependent services."""
libraries = self.settings.get("libraries", {})
if library_name not in libraries:
raise KeyError(f"Library '{library_name}' does not exist")
current_active = self.get_active_library_name()
if current_active == library_name:
# Ensure root settings stay in sync even if already active
self._sync_active_library_to_root(save=False)
self._save_settings()
self._notify_library_change(library_name)
return
self.settings["active_library"] = library_name
self._sync_active_library_to_root(save=False)
self._save_settings()
self._notify_library_change(library_name)
def upsert_library(
self,
library_name: str,
@@ -2688,43 +2712,6 @@ class SettingsManager:
return payload
def create_library(
self,
library_name: str,
*,
folder_paths: Mapping[str, Iterable[str]],
extra_folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
default_lora_root: str = "",
default_checkpoint_root: str = "",
default_unet_root: str = "",
default_embedding_root: str = "",
default_other_roots: Optional[Mapping[str, str]] = None,
workflow_path: str = "",
recipes_path: str = "",
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
) -> Dict[str, Any]:
"""Create a new library entry."""
libraries = self.settings.get("libraries", {})
if library_name in libraries:
raise ValueError(f"Library '{library_name}' already exists")
return self.upsert_library(
library_name,
folder_paths=folder_paths,
extra_folder_paths=extra_folder_paths,
default_lora_root=default_lora_root,
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
workflow_path=workflow_path,
recipes_path=recipes_path,
metadata=metadata,
activate=activate,
)
def rename_library(self, old_name: str, new_name: str) -> None:
"""Rename an existing library."""
+38 -3
View File
@@ -10,11 +10,11 @@ Target structure:
├── symlink/
│ └── symlink_map.json
├── model/
│ └── {library_name}.sqlite
│ └── default.sqlite
├── model_update/
│ └── {library_name}.sqlite
│ └── default.sqlite
├── recipe/
│ └── {library_name}.sqlite
│ └── default.sqlite
└── fts/
├── recipe_fts.sqlite
└── tag_fts.sqlite
@@ -266,6 +266,41 @@ def _cleanup_empty_legacy_directories(legacy_path: str) -> None:
logger.debug("Failed to cleanup empty legacy directories: %s", exc)
def migrate_library_cache_to_default(
cache_type: CacheType, library_name: Optional[str]
) -> None:
"""Copy a legacy named cache to the single-library default namespace."""
safe_name = _sanitize_library_name(library_name)
if safe_name == "default" or cache_type not in (
CacheType.MODEL,
CacheType.MODEL_UPDATE,
CacheType.RECIPE,
):
return
source = get_cache_file_path(cache_type, safe_name, create_dir=False)
target = get_cache_file_path(cache_type, "default", create_dir=True)
if not os.path.exists(source) or os.path.exists(target):
return
try:
shutil.copy2(source, target)
logger.info(
"Migrated %s cache from library '%s' to the default namespace",
cache_type.value,
safe_name,
)
_cleanup_legacy_file_after_migration(source, target)
except Exception as exc:
logger.warning(
"Failed to migrate %s cache from '%s' to default: %s",
cache_type.value,
safe_name,
exc,
)
def resolve_cache_path_with_migration(
cache_type: CacheType,
library_name: Optional[str] = None,
+4 -69
View File
@@ -16,12 +16,9 @@ from ..utils.example_images_paths import (
ExampleImagePathResolver,
clear_example_completion_marker,
ensure_library_root_exists,
get_example_images_root,
get_library_root,
has_completion_marker,
is_hash_folder,
mark_example_folder_complete,
uses_library_scoped_folders,
)
from ..utils.metadata_manager import MetadataManager
from .example_images_processor import ExampleImagesProcessor, MediaDownloadLimiter
@@ -486,36 +483,6 @@ class DownloadManager:
progress_file = os.path.join(output_dir, ".download_progress.json")
progress_source = progress_file
# Handle legacy migration if needed
if uses_library_scoped_folders():
legacy_root = get_settings_manager().get("example_images_path") or ""
legacy_progress = (
os.path.join(legacy_root, ".download_progress.json")
if legacy_root
else ""
)
if (
legacy_progress
and os.path.exists(legacy_progress)
and not os.path.exists(progress_file)
):
try:
os.makedirs(output_dir, exist_ok=True)
shutil.move(legacy_progress, progress_file)
logger.info(
"Migrated legacy download progress file '%s' to '%s'",
legacy_progress,
progress_file,
)
except OSError as exc:
logger.warning(
"Failed to migrate download progress file from '%s' to '%s': %s",
legacy_progress,
progress_file,
exc,
)
progress_source = legacy_progress
processed_models = set()
failed_models = set()
rate_limited_models = set()
@@ -672,24 +639,6 @@ class DownloadManager:
folder_index = await asyncio.get_event_loop().run_in_executor(
None, _build_example_folder_index, output_dir
)
# In multi-library mode, folders that have not been consolidated
# into the library root yet (startup migration skipped, failed
# move, or created at the legacy path afterwards) still live at
# the legacy root/<hash> location. Only scan that root when at
# least one candidate is missing from the library-root index, so
# the fully-consolidated case does not pay an extra directory
# pass on every call.
if uses_library_scoped_folders() and any(
not folder_index.get(model_hash, False)
for model_hash in candidate_hashes
):
legacy_root = get_example_images_root()
if legacy_root and legacy_root != output_dir:
legacy_index = await asyncio.get_event_loop().run_in_executor(
None, _build_example_folder_index, legacy_root
)
for hash_key, has_files in legacy_index.items():
folder_index.setdefault(hash_key, has_files)
for model_hash in candidate_hashes:
if not folder_index.get(model_hash, False):
pending_hashes.add(model_hash)
@@ -2045,27 +1994,13 @@ _default_download_manager: DownloadManager | None = None
def _progress_file_candidates(library_name: str | None = None) -> List[str]:
"""Return existing-progress-file locations for a library (plus the legacy root)."""
"""Return the existing progress file location."""
settings_manager = get_settings_manager()
if not settings_manager.get("example_images_path"):
root = settings_manager.get("example_images_path")
if not root:
return []
candidates: List[str] = []
library_root = get_library_root(
library_name or settings_manager.get_active_library_name()
)
if library_root:
candidates.append(os.path.join(library_root, ".download_progress.json"))
if uses_library_scoped_folders():
legacy_root = get_example_images_root()
if legacy_root:
legacy_file = os.path.join(legacy_root, ".download_progress.json")
if legacy_file not in candidates:
candidates.append(legacy_file)
return candidates
return [os.path.join(root, ".download_progress.json")]
def _remove_hashes_from_progress_file(progress_file: str, hashes: Set[str]) -> int:
+1 -6
View File
@@ -10,7 +10,6 @@ from ..utils.example_images_paths import (
get_example_images_root,
is_hash_folder,
iter_library_roots,
uses_library_scoped_folders,
_library_folder_has_only_hash_dirs,
)
from ..utils.metadata_manager import MetadataManager
@@ -56,16 +55,12 @@ class ExampleImagesMigration:
def _consolidate_library_folders():
"""Move hash folders from library-named subdirectories back to root.
When a user switches from multi-library mode back to single-library
mode, example images previously stored under e.g.
Example images previously stored under e.g.
``<root>/default/<hash>/`` need to be moved back to
``<root>/<hash>/``. Running this once at startup removes the need
for ``get_model_folder()`` to perform directory scans on every
request.
"""
if uses_library_scoped_folders():
return
root = get_example_images_root()
if not root or not os.path.isdir(root):
return
+16 -94
View File
@@ -6,9 +6,8 @@ import json
import logging
import os
import re
import shutil
import time
from typing import Iterable, List, Optional, Tuple
from typing import Iterable, Optional, Tuple
from ..services.settings_manager import get_settings_manager
@@ -35,16 +34,6 @@ _SAFE_FILENAMES: frozenset[str] = frozenset({
logger = logging.getLogger(__name__)
def _get_configured_libraries() -> List[str]:
"""Return configured library names if multi-library support is enabled."""
settings_manager = get_settings_manager()
libraries = settings_manager.get("libraries")
if isinstance(libraries, dict) and libraries:
return list(libraries.keys())
return []
def get_example_images_root() -> str:
"""Return the root directory configured for example images."""
@@ -53,81 +42,32 @@ def get_example_images_root() -> str:
return os.path.abspath(root) if root else ""
def uses_library_scoped_folders() -> bool:
"""Return True when example images should be separated per library."""
libraries = _get_configured_libraries()
return len(libraries) > 1
def sanitize_library_name(library_name: Optional[str]) -> str:
"""Return a filesystem safe library name."""
settings_manager = get_settings_manager()
name = library_name or settings_manager.get_active_library_name() or "default"
safe_name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
return safe_name or "default"
def get_library_root(library_name: Optional[str] = None) -> str:
"""Return the directory where a library's example images should live."""
"""Return the example images root; the legacy name argument is ignored."""
root = get_example_images_root()
if not root:
return ""
if uses_library_scoped_folders():
return os.path.join(root, sanitize_library_name(library_name))
return root
return get_example_images_root()
def ensure_library_root_exists(library_name: Optional[str] = None) -> str:
"""Ensure the example image directory for a library exists and return it."""
"""Ensure the example images root exists and return it."""
library_root = get_library_root(library_name)
if library_root:
os.makedirs(library_root, exist_ok=True)
return library_root
root = get_library_root()
if root:
os.makedirs(root, exist_ok=True)
return root
def get_model_folder(model_hash: str, library_name: Optional[str] = None) -> str:
"""Return the folder path for a model's example images.
Multi-library ↔ single-library consolidation is handled once at startup by
``ExampleImagesMigration._consolidate_library_folders`` — this function is a
pure path computation on the hot path (no directory scans).
"""
"""Return the folder path for a model's example images."""
if not model_hash:
return ""
library_root = ensure_library_root_exists(library_name)
if not library_root:
root = ensure_library_root_exists()
if not root:
return ""
normalized_hash = (model_hash or "").lower()
resolved_folder = os.path.join(library_root, normalized_hash)
if uses_library_scoped_folders():
legacy_root = get_example_images_root()
legacy_folder = os.path.join(legacy_root, normalized_hash)
if os.path.exists(legacy_folder) and not os.path.exists(resolved_folder):
try:
os.makedirs(library_root, exist_ok=True)
shutil.move(legacy_folder, resolved_folder)
logger.info(
"Migrated legacy example images folder '%s' to '%s'", legacy_folder, resolved_folder
)
except OSError as exc:
logger.error(
"Failed to migrate example images from '%s' to '%s': %s",
legacy_folder,
resolved_folder,
exc,
)
return legacy_folder
return resolved_folder
return os.path.join(root, model_hash.lower())
def get_completion_marker_path(model_dir: str) -> str:
@@ -254,28 +194,13 @@ def get_model_relative_path(model_hash: str, library_name: Optional[str] = None)
def iter_library_roots() -> Iterable[Tuple[str, str]]:
"""Yield configured library names and their resolved filesystem roots."""
"""Return the single example images root using the legacy tuple shape."""
root = get_example_images_root()
if not root:
return []
libraries = _get_configured_libraries()
if uses_library_scoped_folders():
results: List[Tuple[str, str]] = []
if libraries:
for library in libraries:
results.append((library, get_library_root(library)))
else:
# Fall back to the active library to avoid skipping migrations/cleanup
settings_manager = get_settings_manager()
active = settings_manager.get_active_library_name() or "default"
results.append((active, get_library_root(active)))
return results
settings_manager = get_settings_manager()
active = settings_manager.get_active_library_name() or "default"
return [(active, root)]
return [("default", root)]
def is_hash_folder(name: str) -> bool:
@@ -326,11 +251,8 @@ def is_valid_example_images_root(folder_path: str) -> bool:
if item == "_deleted":
# Allow cleanup staging folders
continue
# Accept legacy library folders even when current settings do not
# explicitly enable multi-library mode. This allows users to reuse a
# previously configured example images directory after settings are
# reset, as long as the nested structure still looks like dedicated
# hash folders.
# Accept legacy library folders so the startup consolidation can
# recover their hash directories.
if _library_folder_has_only_hash_dirs(item_path):
continue
return False
-150
View File
@@ -79,8 +79,6 @@ export class SettingsManager {
this.initialized = false;
this.isOpen = false;
this.initializationPromise = null;
this.availableLibraries = {};
this.activeLibrary = '';
this.registeredStartupBannerIds = new Set();
this.modelPathsSectionInitialized = false;
this.modelPathsDirty = false;
@@ -1413,9 +1411,6 @@ export class SettingsManager {
downloadRootRulesError.textContent = '';
}
// Load library options
await this.loadLibraries();
// Load default lora root
await this.loadLoraRoots();
@@ -1699,151 +1694,6 @@ export class SettingsManager {
}
}
async loadLibraries() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const setPlaceholderOption = (textKey, fallback) => {
librarySelect.innerHTML = '';
const option = document.createElement('option');
option.value = '';
option.textContent = translate(textKey, {}, fallback);
librarySelect.appendChild(option);
};
setPlaceholderOption('settings.folderSettings.loadingLibraries', 'Loading libraries...');
librarySelect.disabled = true;
try {
const response = await fetch('/api/lm/settings/libraries');
if (!response.ok) {
throw new Error('Failed to fetch library registry');
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to fetch library registry');
}
const libraries = data.libraries && typeof data.libraries === 'object'
? data.libraries
: {};
this.availableLibraries = libraries;
const entries = Object.entries(libraries);
if (entries.length === 0) {
this.activeLibrary = '';
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
return;
}
const activeName = data.active_library && libraries[data.active_library]
? data.active_library
: entries[0][0];
this.activeLibrary = activeName;
librarySelect.innerHTML = '';
const fragment = document.createDocumentFragment();
entries
.sort((a, b) => {
const nameA = this.getLibraryDisplayName(a[0], a[1]).toLowerCase();
const nameB = this.getLibraryDisplayName(b[0], b[1]).toLowerCase();
return nameA.localeCompare(nameB);
})
.forEach(([name, info]) => {
const option = document.createElement('option');
option.value = name;
option.textContent = this.getLibraryDisplayName(name, info);
fragment.appendChild(option);
});
librarySelect.appendChild(fragment);
librarySelect.value = activeName;
librarySelect.disabled = entries.length <= 1;
} catch (error) {
console.error('Error loading libraries:', error);
setPlaceholderOption('settings.folderSettings.noLibraries', 'No libraries configured');
this.availableLibraries = {};
this.activeLibrary = '';
librarySelect.disabled = true;
showToast('toast.settings.libraryLoadFailed', { message: error.message }, 'error');
}
}
getLibraryDisplayName(libraryName, libraryData = {}) {
if (libraryData && typeof libraryData === 'object') {
const metadata = libraryData.metadata;
if (metadata && typeof metadata === 'object' && metadata.display_name) {
return metadata.display_name;
}
if (libraryData.display_name) {
return libraryData.display_name;
}
}
return libraryName;
}
async handleLibraryChange() {
const librarySelect = document.getElementById('librarySelect');
if (!librarySelect) {
return;
}
const selectedLibrary = librarySelect.value;
if (!selectedLibrary || selectedLibrary === this.activeLibrary) {
librarySelect.value = this.activeLibrary;
return;
}
librarySelect.disabled = true;
try {
state.loadingManager.showSimpleLoading('Activating library...');
await this.activateLibrary(selectedLibrary);
// Add a short delay before reloading the page
await new Promise(resolve => setTimeout(resolve, 300));
window.location.reload();
} catch (error) {
console.error('Failed to activate library:', error);
showToast('toast.settings.libraryActivateFailed', { message: error.message }, 'error');
await this.loadLibraries();
} finally {
state.loadingManager.hide();
if (!document.hidden) {
librarySelect.disabled = librarySelect.options.length <= 1;
}
}
}
async activateLibrary(libraryName) {
const response = await fetch('/api/lm/settings/libraries/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ library: libraryName }),
});
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to activate library');
}
const activeName = data.active_library || libraryName;
this.activeLibrary = activeName;
return data;
}
showNoRootsPlaceholder(select) {
select.innerHTML = '';
const option = document.createElement('option');
@@ -4,22 +4,6 @@
<!-- Folder Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.folderSettings') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="librarySelect">
{{ t('settings.folderSettings.activeLibrary') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.activeLibraryHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="librarySelect" onchange="settingsManager.handleLibraryChange()">
<option value="">{{ t('settings.folderSettings.loadingLibraries') }}</option>
</select>
</div>
</div>
</div>
{{ sm.setting_select('defaultLoraRoot', 'default_lora_root', 'settings.folderSettings.defaultLoraRoot', [], 'settings.folderSettings.defaultLoraRootHelp') }}
{{ sm.setting_select('defaultCheckpointRoot', 'default_checkpoint_root', 'settings.folderSettings.defaultCheckpointRoot', [], 'settings.folderSettings.defaultCheckpointRootHelp') }}
@@ -68,8 +68,6 @@ import { bannerService } from '../../../static/js/managers/BannerService.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js';
const originalLocation = window.location;
const createManager = () => {
state.global.settings = {};
const initSettingsSpy = vi
@@ -87,13 +85,6 @@ const createManager = () => {
return manager;
};
const appendLibrarySelect = () => {
const select = document.createElement('select');
select.id = 'librarySelect';
document.body.appendChild(select);
return select;
};
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
@@ -102,12 +93,6 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete document.hidden;
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
writable: true,
});
});
describe('SettingsManager root selects', () => {
@@ -222,107 +207,7 @@ describe('SettingsManager root selects', () => {
);
});
describe('SettingsManager library controls', () => {
it('loads libraries and populates the select', async () => {
const manager = createManager();
const select = appendLibrarySelect();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
libraries: {
beta: { display_name: 'Beta' },
alpha: { metadata: { display_name: 'Alpha' } },
},
active_library: 'beta',
}),
});
await manager.loadLibraries();
expect(manager.availableLibraries).toEqual({
beta: { display_name: 'Beta' },
alpha: { metadata: { display_name: 'Alpha' } },
});
expect(manager.activeLibrary).toBe('beta');
expect(select.options).toHaveLength(2);
expect(Array.from(select.options).map(option => option.value)).toEqual([
'alpha',
'beta',
]);
expect(select.value).toBe('beta');
expect(select.disabled).toBe(false);
});
it('handles load errors by disabling the select and showing a toast', async () => {
const manager = createManager();
const select = appendLibrarySelect();
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await manager.loadLibraries();
expect(select.options).toHaveLength(1);
expect(select.options[0].value).toBe('');
expect(select.disabled).toBe(true);
expect(manager.availableLibraries).toEqual({});
expect(manager.activeLibrary).toBe('');
expect(showToast).toHaveBeenCalledWith(
'toast.settings.libraryLoadFailed',
expect.objectContaining({ message: 'Failed to fetch library registry' }),
'error',
);
});
it('activates a newly selected library and reloads the page', async () => {
const manager = createManager();
const select = appendLibrarySelect();
select.appendChild(new Option('Alpha', 'alpha'));
select.appendChild(new Option('Beta', 'beta'));
select.value = 'beta';
manager.activeLibrary = 'alpha';
Object.defineProperty(document, 'hidden', {
value: false,
configurable: true,
});
const reloadMock = vi.fn();
Object.defineProperty(window, 'location', {
value: { reload: reloadMock },
configurable: true,
});
const activateSpy = vi
.spyOn(manager, 'activateLibrary')
.mockResolvedValue({ success: true, active_library: 'beta' });
await manager.handleLibraryChange();
expect(activateSpy).toHaveBeenCalledWith('beta');
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(select.disabled).toBe(false);
});
it('ignores changes when selecting the active library', async () => {
const manager = createManager();
const select = appendLibrarySelect();
select.appendChild(new Option('Alpha', 'alpha'));
select.value = 'alpha';
manager.activeLibrary = 'alpha';
const activateSpy = vi.spyOn(manager, 'activateLibrary');
await manager.handleLibraryChange();
expect(select.value).toBe('alpha');
expect(activateSpy).not.toHaveBeenCalled();
});
describe('SettingsManager library settings', () => {
it('loads recipes_path into the settings input', async () => {
const manager = createManager();
const input = document.createElement('input');
@@ -361,7 +246,6 @@ describe('SettingsManager library controls', () => {
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
@@ -473,7 +357,6 @@ describe('SettingsManager library controls', () => {
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
+2 -116
View File
@@ -1,5 +1,5 @@
import json
from typing import Any, Optional
from typing import Any
import pytest
@@ -16,14 +16,7 @@ class FakeRequest:
class DummySettings:
def __init__(self):
self.activated = None
self.should_raise: Optional[Exception] = None
def activate_library(self, name):
if self.should_raise:
raise self.should_raise
self.activated = name
pass
def json_payload(response) -> Any:
@@ -55,113 +48,6 @@ def handler():
)
@pytest.mark.asyncio
async def test_get_libraries_returns_registry(monkeypatch, handler):
registry = {"libraries": {"default": {"name": "Default"}}, "active_library": "default"}
monkeypatch.setattr(config, "get_library_registry_snapshot", lambda: registry)
response = await handler.get_libraries(FakeRequest())
payload = json_payload(response)
assert response.status == 200
assert payload == {
"success": True,
"libraries": registry["libraries"],
"active_library": "default",
}
@pytest.mark.asyncio
async def test_get_libraries_handles_errors(monkeypatch, handler):
def boom():
raise RuntimeError("exploded")
monkeypatch.setattr(config, "get_library_registry_snapshot", boom)
response = await handler.get_libraries(FakeRequest())
payload = json_payload(response)
assert response.status == 500
assert payload["success"] is False
assert payload["error"] == "exploded"
@pytest.mark.asyncio
async def test_activate_library_success(monkeypatch):
dummy_settings = DummySettings()
handler = SettingsHandler(
settings_service=dummy_settings,
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
registry = {"libraries": {"alpha": {"name": "Alpha"}}, "active_library": "alpha"}
monkeypatch.setattr(config, "get_library_registry_snapshot", lambda: registry)
response = await handler.activate_library(
FakeRequest(json_data={"library": "alpha"}) # pyright: ignore[reportArgumentType]
)
payload = json_payload(response)
assert response.status == 200
assert payload == {
"success": True,
"active_library": "alpha",
"libraries": registry["libraries"],
}
assert dummy_settings.activated == "alpha"
@pytest.mark.asyncio
async def test_activate_library_requires_name(handler):
response = await handler.activate_library(FakeRequest(json_data={}))
payload = json_payload(response)
assert response.status == 400
assert payload["success"] is False
assert payload["error"] == "Library name is required"
@pytest.mark.asyncio
async def test_activate_library_unknown_returns_404(monkeypatch):
dummy_settings = DummySettings()
dummy_settings.should_raise = KeyError("Unknown library")
handler = SettingsHandler(
settings_service=dummy_settings,
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
response = await handler.activate_library(
FakeRequest(json_data={"library": "ghost"}) # pyright: ignore[reportArgumentType]
)
payload = json_payload(response)
assert response.status == 404
assert payload["success"] is False
assert payload["error"] == "'Unknown library'"
@pytest.mark.asyncio
async def test_activate_library_unexpected_error_returns_500(monkeypatch):
dummy_settings = DummySettings()
dummy_settings.should_raise = ValueError("bad things")
handler = SettingsHandler(
settings_service=dummy_settings,
metadata_provider_updater=noop_async,
downloader_factory=dummy_downloader_factory,
)
response = await handler.activate_library(
FakeRequest(json_data={"library": "broken"}) # pyright: ignore[reportArgumentType]
)
payload = json_payload(response)
assert response.status == 500
assert payload["success"] is False
assert payload["error"] == "bad things"
class DummySettingsForGet:
def __init__(self, values=None):
self._values = dict(values or {})
+4 -5
View File
@@ -481,20 +481,19 @@ async def test_check_pending_models_uses_per_folder_check_for_small_candidate_se
@pytest.mark.asyncio
@pytest.mark.usefixtures("tmp_path")
async def test_check_pending_models_bulk_index_includes_legacy_folders(
async def test_check_pending_models_bulk_index_includes_existing_folders(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
"""In multi-library mode the bulk index also scans the legacy root so models
whose folders have not been consolidated yet are not reported pending."""
"""The bulk index recognizes populated folders in the example images root."""
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}, "extra": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "extra")
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "default")
# 1500 unprocessed models triggers the bulk lookup path
models = [
@@ -8,7 +8,7 @@ from typing import Any
import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.settings_manager import get_settings_manager
from py.utils import example_images_download_manager as download_module
from py.utils.example_images_processor import ImageDownloadOutcome
@@ -475,7 +475,7 @@ async def test_model_pipeline_overlaps_across_models(
@pytest.mark.usefixtures("tmp_path")
async def test_legacy_folder_migrated_and_skipped(
async def test_existing_folder_is_skipped(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
@@ -484,8 +484,8 @@ async def test_legacy_folder_migrated_and_skipped(
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}, "extra": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "extra")
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "default")
model_hash = "d" * 64
model_path = tmp_path / "model.safetensors"
@@ -550,18 +550,14 @@ async def test_legacy_folder_migrated_and_skipped(
if manager._download_task is not None and not manager._download_task.done():
await asyncio.wait_for(manager._download_task, timeout=1)
library_root = tmp_path / "extra"
migrated_folder = library_root / model_hash
assert migrated_folder.exists()
assert not legacy_folder.exists()
assert legacy_folder.exists()
assert not process_called
assert not download_called
assert model_hash in manager._progress["processed_models"]
@pytest.mark.usefixtures("tmp_path")
async def test_legacy_progress_file_migrates(
async def test_existing_progress_file_is_used(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
@@ -570,8 +566,8 @@ async def test_legacy_progress_file_migrates(
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}, "extra": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "extra")
monkeypatch.setitem(settings_manager.settings, "libraries", {"default": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "default")
model_hash = "e" * 64
model_path = tmp_path / "model-two.safetensors"
@@ -601,7 +597,7 @@ async def test_legacy_progress_file_migrates(
return False
async def fake_download_model_images(*_args, **_kwargs):
raise AssertionError("Remote download should not be attempted when progress is migrated")
raise AssertionError("Remote download should not be attempted for processed models")
async def fake_get_downloader():
class _Downloader:
@@ -628,95 +624,12 @@ async def test_legacy_progress_file_migrates(
if manager._download_task is not None:
await asyncio.wait_for(manager._download_task, timeout=1)
new_progress = (tmp_path / "extra") / ".download_progress.json"
assert model_hash in manager._progress["processed_models"]
assert not legacy_progress.exists()
assert new_progress.exists()
contents = json.loads(new_progress.read_text(encoding="utf-8"))
assert legacy_progress.exists()
contents = json.loads(legacy_progress.read_text(encoding="utf-8"))
assert model_hash in contents.get("processed_models", [])
@pytest.mark.usefixtures("tmp_path")
async def test_download_remains_in_initial_library(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
monkeypatch.setitem(settings_manager.settings, "libraries", {"LibraryA": {}, "LibraryB": {}})
monkeypatch.setitem(settings_manager.settings, "active_library", "LibraryA")
state = {"active": "LibraryA"}
def fake_get_active_library_name(self):
return state["active"]
monkeypatch.setattr(SettingsManager, "get_active_library_name", fake_get_active_library_name)
model_hash = "f" * 64
model_path = tmp_path / "example-model.safetensors"
model_path.write_text("data", encoding="utf-8")
model = {
"sha256": model_hash,
"model_name": "Library Switch Model",
"file_path": str(model_path),
"file_name": "example-model.safetensors",
}
_patch_scanner(monkeypatch, StubScanner([model]))
async def fake_process_local_examples(
_file_path,
_file_name,
_model_name,
model_dir,
_optimize,
):
Path(model_dir).mkdir(parents=True, exist_ok=True)
(Path(model_dir) / "local.txt").write_text("data", encoding="utf-8")
state["active"] = "LibraryB"
return True
async def fake_update_metadata(*_args, **_kwargs):
return True
async def fake_get_downloader():
return object()
monkeypatch.setattr(
download_module.ExampleImagesProcessor,
"process_local_examples",
staticmethod(fake_process_local_examples),
)
monkeypatch.setattr(
download_module.MetadataUpdater,
"update_metadata_from_local_examples",
staticmethod(fake_update_metadata),
)
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
result = await manager.start_download({"model_types": ["lora"], "delay": 0})
assert result["success"] is True
if manager._download_task is not None:
await asyncio.wait_for(manager._download_task, timeout=1)
library_a_root = tmp_path / "LibraryA"
library_b_root = tmp_path / "LibraryB"
progress_file = library_a_root / ".download_progress.json"
model_dir = library_a_root / model_hash
assert progress_file.exists()
assert (model_dir / "local.txt").exists()
assert not (library_b_root / ".download_progress.json").exists()
assert not (library_b_root / model_hash).exists()
@pytest.mark.usefixtures("tmp_path")
async def test_not_found_example_images_are_cleaned(
monkeypatch: pytest.MonkeyPatch,
+126 -117
View File
@@ -134,37 +134,56 @@ def test_existing_folder_paths_seed_default_library(tmp_path, monkeypatch):
assert manager.get_startup_messages() == []
def test_multi_library_configuration_triggers_deprecation_warning(
tmp_path, monkeypatch, caplog
):
def test_multi_library_configuration_is_consolidated(tmp_path, monkeypatch):
initial = {
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
"archive": {"folder_paths": {"loras": ["/loras-b"]}},
"default": {
"folder_paths": {"loras": ["/loras-a"]},
"extra_folder_paths": {"loras": ["/extra-a"]},
"default_lora_root": "/loras-a",
"workflow_path": "/workflows-a",
"recipes_path": "/recipes-a",
},
"archive": {
"folder_paths": {
"loras": ["/loras-b", "/loras-a"],
"checkpoints": ["/checkpoints-b"],
},
"extra_folder_paths": {"loras": ["/extra-b"]},
"default_lora_root": "/loras-b",
"workflow_path": "/workflows-b",
"recipes_path": "/recipes-b",
},
},
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
deprecation = [
assert manager.get_active_library_name() == "default"
assert list(manager.get_libraries()) == ["default"]
assert manager.get("folder_paths") == {
"loras": ["/loras-a", "/loras-b"],
"checkpoints": ["/checkpoints-b"],
}
assert manager.get("extra_folder_paths") == {
"loras": ["/extra-a", "/extra-b"]
}
assert manager.get("default_lora_root") == "/loras-a"
assert manager.get("workflow_path") == "/workflows-a"
assert manager.get("recipes_path") == "/recipes-a"
migration = [
message
for message in manager.get_startup_messages()
if message["code"] == "multi-library-deprecated"
if message["code"] == "multi-library-consolidated"
]
assert len(deprecation) == 1
assert deprecation[0]["severity"] == "warning"
assert deprecation[0]["dismissible"] is True
assert "archive" in deprecation[0]["message"]
assert len(migration) == 1
assert migration[0]["severity"] == "info"
assert "archive" in migration[0]["message"]
def test_single_library_configuration_has_no_deprecation_warning(
tmp_path, monkeypatch, caplog
def test_single_library_configuration_has_no_consolidation_message(
tmp_path, monkeypatch
):
initial = {
"libraries": {
@@ -173,19 +192,94 @@ def test_single_library_configuration_has_no_deprecation_warning(
"active_library": "default",
}
with caplog.at_level(logging.WARNING, logger="py.services.settings_manager"):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert not any(
"Multi-library support is deprecated" in record.getMessage()
for record in caplog.records
)
assert not any(
message["code"] == "multi-library-deprecated"
message["code"] == "multi-library-consolidated"
for message in manager.get_startup_messages()
)
def test_multi_library_consolidation_is_persisted(tmp_path, monkeypatch):
settings_path = tmp_path / "settings.json"
settings_path.write_text(
json.dumps(
{
"libraries": {
"default": {"folder_paths": {"loras": ["/loras-a"]}},
"archive": {"folder_paths": {"loras": ["/loras-b"]}},
},
"active_library": "default",
}
),
encoding="utf-8",
)
monkeypatch.setattr(
"py.services.settings_manager.ensure_settings_file",
lambda logger=None: str(settings_path),
)
first = SettingsManager()
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "libraries" not in stored
assert "active_library" not in stored
assert stored["folder_paths"]["loras"] == ["/loras-a", "/loras-b"]
second = SettingsManager()
assert second.get("folder_paths")["loras"] == ["/loras-a", "/loras-b"]
assert not any(
message["code"] == "multi-library-consolidated"
for message in second.get_startup_messages()
)
assert first.get_active_library_name() == "default"
def test_single_library_registry_is_removed_and_caches_migrated(
tmp_path, monkeypatch
):
settings_path = tmp_path / "settings.json"
settings_path.write_text(
json.dumps(
{
"libraries": {
"comfyui": {
"folder_paths": {"loras": ["/loras"]},
"default_lora_root": "/loras",
}
},
"active_library": "comfyui",
}
),
encoding="utf-8",
)
monkeypatch.setattr(
"py.services.settings_manager.ensure_settings_file",
lambda logger=None: str(settings_path),
)
monkeypatch.setattr(
"py.utils.cache_paths.get_settings_dir",
lambda create=True: str(tmp_path),
)
for subdir in ("model", "model_update", "recipe"):
cache = tmp_path / "cache" / subdir / "comfyui.sqlite"
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(subdir, encoding="utf-8")
manager = SettingsManager()
stored = json.loads(settings_path.read_text(encoding="utf-8"))
assert "libraries" not in stored
assert "active_library" not in stored
assert stored["folder_paths"] == {"loras": ["/loras"]}
assert stored["default_lora_root"] == "/loras"
for subdir in ("model", "model_update", "recipe"):
assert (tmp_path / "cache" / subdir / "default.sqlite").read_text(
encoding="utf-8"
) == subdir
assert not (tmp_path / "cache" / subdir / "comfyui.sqlite").exists()
assert manager.get("folder_paths") == {"loras": ["/loras"]}
def test_environment_variable_overrides_settings(tmp_path, monkeypatch):
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
monkeypatch.setenv("CIVITAI_API_KEY", "secret")
@@ -833,22 +927,12 @@ def test_active_library_syncs_top_level_settings(tmp_path, monkeypatch):
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
assert manager.get_active_library_name() == "studio"
assert manager.get("folder_paths")["loras"] == ["/studio"]
assert manager.get("folder_paths")["loras"] == ["/studio", "/loras"]
assert manager.get("default_lora_root") == "/studio"
assert manager.get("default_checkpoint_root") == "/studio_ckpt"
assert manager.get("default_embedding_root") == "/studio_embed"
assert manager.get("recipes_path") == "/studio/custom-recipes"
# Drift the top-level values again and ensure activate_library repairs them
manager.settings["folder_paths"] = {"loras": ["/loras"]}
manager.settings["default_lora_root"] = "/loras"
manager.settings["recipes_path"] = "/loras/recipes"
manager.activate_library("studio")
assert manager.get("folder_paths")["loras"] == ["/studio"]
assert manager.get("default_lora_root") == "/studio"
assert manager.get("recipes_path") == "/studio/custom-recipes"
def test_refresh_environment_variables_updates_stored_value(tmp_path, monkeypatch):
calls = []
@@ -1172,66 +1256,6 @@ def test_get_extra_folder_paths(manager, tmp_path):
assert str(extra_dir) in extra_paths.get("loras", [])
def test_library_switch_preserves_extra_paths(manager, tmp_path):
"""Test that switching libraries preserves each library's extra paths."""
lora_dir1 = tmp_path / "lib1_loras"
extra_dir1 = tmp_path / "lib1_extra"
lora_dir2 = tmp_path / "lib2_loras"
extra_dir2 = tmp_path / "lib2_extra"
for directory in (lora_dir1, extra_dir1, lora_dir2, extra_dir2):
directory.mkdir()
manager.create_library(
"library1",
folder_paths={"loras": [str(lora_dir1)]},
extra_folder_paths={"loras": [str(extra_dir1)]},
activate=True,
)
manager.create_library(
"library2",
folder_paths={"loras": [str(lora_dir2)]},
extra_folder_paths={"loras": [str(extra_dir2)]},
)
assert manager.get_active_library_name() == "library1"
lib1 = manager.get_active_library()
assert str(lora_dir1) in lib1["folder_paths"]["loras"]
assert str(extra_dir1) in lib1["extra_folder_paths"]["loras"]
manager.activate_library("library2")
assert manager.get_active_library_name() == "library2"
lib2 = manager.get_active_library()
assert str(lora_dir2) in lib2["folder_paths"]["loras"]
assert str(extra_dir2) in lib2["extra_folder_paths"]["loras"]
def test_extra_paths_validation_no_overlap_with_other_libraries(manager, tmp_path):
"""Test that extra paths cannot overlap with other libraries' paths."""
lora_dir1 = tmp_path / "lib1_loras"
lora_dir1.mkdir()
manager.create_library(
"library1",
folder_paths={"loras": [str(lora_dir1)]},
activate=True,
)
extra_dir = tmp_path / "extra_loras"
extra_dir.mkdir()
manager.create_library(
"library2",
folder_paths={"loras": [str(extra_dir)]},
activate=True,
)
with pytest.raises(ValueError, match="already assigned to library"):
manager.update_extra_folder_paths({"loras": [str(lora_dir1)]})
def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(
manager, tmp_path
):
@@ -1241,7 +1265,7 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root(
lora_link = tmp_path / "loras_link"
lora_link.symlink_to(real_lora_dir, target_is_directory=True)
manager.create_library(
manager.upsert_library(
"library1",
folder_paths={"loras": [str(lora_link)]},
activate=True,
@@ -1262,7 +1286,7 @@ def test_extra_paths_validation_no_overlap_with_active_primary_lora_root_case_in
lora_link = tmp_path / "loras_link"
lora_link.symlink_to(real_lora_dir, target_is_directory=True)
manager.create_library(
manager.upsert_library(
"library1",
folder_paths={"loras": [str(lora_link)]},
activate=True,
@@ -1307,7 +1331,7 @@ def test_extra_paths_validation_allows_missing_non_overlapping_lora_root(
lora_dir.mkdir()
missing_extra = tmp_path / "missing_loras"
manager.create_library(
manager.upsert_library(
"library1",
folder_paths={"loras": [str(lora_dir)]},
activate=True,
@@ -1330,7 +1354,7 @@ def test_extra_paths_validation_rejects_primary_root_first_level_symlink_target(
link_dir = lora_dir / "link"
link_dir.symlink_to(external_dir, target_is_directory=True)
manager.create_library(
manager.upsert_library(
"library1",
folder_paths={"loras": [str(lora_dir)]},
activate=True,
@@ -1346,7 +1370,7 @@ def test_delete_library_switches_active(manager, tmp_path):
other_dir = tmp_path / "other"
other_dir.mkdir()
manager.create_library(
manager.upsert_library(
"other",
folder_paths={"loras": [str(other_dir)]},
activate=True,
@@ -1567,21 +1591,6 @@ def test_upsert_library_passthrough_default_other_roots(manager, tmp_path):
assert libraries["studio"]["default_other_roots"] == {"vae": "/studio/vae"}
def test_library_switch_restores_default_other_roots(manager):
manager.set("default_other_roots", {"vae": "/default-vae"})
manager.create_library(
"studio",
folder_paths={"loras": ["/studio/loras"]},
default_other_roots={"vae": "/studio-vae"},
)
manager.activate_library("studio")
assert manager.get("default_other_roots") == {"vae": "/studio-vae"}
manager.activate_library("default")
assert manager.get("default_other_roots") == {"vae": "/default-vae"}
def test_migrate_sanitizes_legacy_libraries_includes_other_roots(tmp_path, monkeypatch):
initial = {
"libraries": {"legacy": "not-a-dict"},
+47
View File
@@ -6,6 +6,7 @@ import pytest
from py.utils.cache_paths import (
CacheType,
migrate_library_cache_to_default,
resolve_cache_path_with_migration,
)
@@ -152,6 +153,52 @@ class TestResolveCachePathWithMigration:
assert not canonical.exists()
@pytest.mark.parametrize(
("cache_type", "subdir"),
[
(CacheType.MODEL, "model"),
(CacheType.MODEL_UPDATE, "model_update"),
(CacheType.RECIPE, "recipe"),
],
)
def test_named_cache_migrates_to_default(
tmp_path, monkeypatch, cache_type, subdir
):
settings_dir = tmp_path / "settings"
source = settings_dir / "cache" / subdir / "comfyui.sqlite"
source.parent.mkdir(parents=True)
source.write_text("cached data")
monkeypatch.setattr(
"py.utils.cache_paths.get_settings_dir",
lambda create=True: str(settings_dir),
)
migrate_library_cache_to_default(cache_type, "comfyui")
target = settings_dir / "cache" / subdir / "default.sqlite"
assert target.read_text() == "cached data"
assert not source.exists()
def test_named_cache_migration_does_not_overwrite_default(tmp_path, monkeypatch):
settings_dir = tmp_path / "settings"
model_dir = settings_dir / "cache" / "model"
model_dir.mkdir(parents=True)
source = model_dir / "comfyui.sqlite"
target = model_dir / "default.sqlite"
source.write_text("named")
target.write_text("default")
monkeypatch.setattr(
"py.utils.cache_paths.get_settings_dir",
lambda create=True: str(settings_dir),
)
migrate_library_cache_to_default(CacheType.MODEL, "comfyui")
assert target.read_text() == "default"
assert source.read_text() == "named"
class TestAutomaticCleanup:
"""Tests for automatic cleanup during migration."""
@@ -8,6 +8,23 @@ import pytest
from py.utils import example_images_migration as migration_module
def test_consolidates_legacy_library_folders(tmp_path, monkeypatch):
example_root = tmp_path / "example_images"
model_hash = "a" * 64
legacy_folder = example_root / "archive" / model_hash
legacy_folder.mkdir(parents=True)
(legacy_folder / "image.png").write_bytes(b"image")
monkeypatch.setattr(
migration_module, "get_example_images_root", lambda: str(example_root)
)
migration_module.ExampleImagesMigration._consolidate_library_folders()
assert (example_root / model_hash / "image.png").read_bytes() == b"image"
assert not (example_root / "archive").exists()
class FakeScanner:
def __init__(self, data_map, init_cycles=0):
self._data_map = data_map
+18 -49
View File
@@ -46,72 +46,41 @@ def test_get_model_folder_single_library(tmp_path, settings_manager):
assert relative == model_hash
def test_get_model_folder_multi_library(tmp_path, settings_manager):
def test_get_model_folder_ignores_legacy_library_name(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path)
settings_manager.settings['libraries'] = {
'default': {},
'Alt Library': {},
}
settings_manager.settings['active_library'] = 'Alt Library'
settings_manager.settings['libraries'] = {'default': {}}
settings_manager.settings['active_library'] = 'default'
model_hash = 'b' * 64
expected_folder = tmp_path / 'Alt_Library' / model_hash
folder = get_model_folder(model_hash)
relative = get_model_relative_path(model_hash)
folder = get_model_folder(model_hash, 'Alt Library')
relative = get_model_relative_path(model_hash, 'Alt Library')
assert Path(folder) == expected_folder
assert relative == os.path.join('Alt_Library', model_hash).replace('\\', '/')
assert Path(folder) == tmp_path / model_hash
assert relative == model_hash
def test_get_model_folder_migrates_legacy_structure(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path)
settings_manager.settings['libraries'] = {
'default': {},
'extra': {},
}
settings_manager.settings['active_library'] = 'extra'
model_hash = 'c' * 64
legacy_folder = tmp_path / model_hash
legacy_folder.mkdir()
legacy_file = legacy_folder / 'image.png'
legacy_file.write_text('data', encoding='utf-8')
resolved_folder = get_model_folder(model_hash)
relative = get_model_relative_path(model_hash)
expected_folder = tmp_path / 'extra' / model_hash
assert Path(resolved_folder) == expected_folder
assert relative == os.path.join('extra', model_hash).replace('\\', '/')
assert not legacy_folder.exists()
assert (expected_folder / 'image.png').exists()
def test_ensure_library_root_exists_creates_directories(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path)
settings_manager.settings['libraries'] = {'default': {}, 'secondary': {}}
settings_manager.settings['active_library'] = 'secondary'
def test_ensure_library_root_exists_creates_root(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path / 'examples')
settings_manager.settings['libraries'] = {'default': {}}
settings_manager.settings['active_library'] = 'default'
resolved = ensure_library_root_exists('secondary')
assert Path(resolved) == tmp_path / 'secondary'
assert (tmp_path / 'secondary').is_dir()
assert Path(resolved) == tmp_path / 'examples'
assert (tmp_path / 'examples').is_dir()
def test_iter_library_roots_returns_all_configured(tmp_path, settings_manager):
def test_iter_library_roots_returns_single_root(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path)
settings_manager.settings['libraries'] = {'default': {}, 'alt': {}}
settings_manager.settings['active_library'] = 'alt'
settings_manager.settings['libraries'] = {'default': {}}
settings_manager.settings['active_library'] = 'default'
roots = dict(iter_library_roots())
assert roots['default'] == str(tmp_path / 'default')
assert roots['alt'] == str(tmp_path / 'alt')
assert list(iter_library_roots()) == [('default', str(tmp_path))]
def test_is_valid_example_images_root_accepts_hash_directories(tmp_path, settings_manager):
settings_manager.settings['example_images_path'] = str(tmp_path)
# Ensure single-library mode (not multi-library mode)
# Use the single-library storage layout.
settings_manager.settings['libraries'] = {'default': {}}
settings_manager.settings['active_library'] = 'default'