mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat(settings): add multi-library backend support
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from ..utils.settings_paths import ensure_settings_file
|
||||
|
||||
@@ -42,6 +44,7 @@ class SettingsManager:
|
||||
self.settings = self._load_settings()
|
||||
self._migrate_setting_keys()
|
||||
self._ensure_default_settings()
|
||||
self._migrate_to_library_registry()
|
||||
self._migrate_download_path_template()
|
||||
self._auto_set_default_roots()
|
||||
self._check_environment_variables()
|
||||
@@ -69,6 +72,223 @@ class SettingsManager:
|
||||
if updated:
|
||||
self._save_settings()
|
||||
|
||||
def _migrate_to_library_registry(self) -> None:
|
||||
"""Ensure settings include the multi-library registry structure."""
|
||||
libraries = self.settings.get("libraries")
|
||||
active_name = self.settings.get("active_library")
|
||||
|
||||
if not isinstance(libraries, dict) or not libraries:
|
||||
library_name = active_name or "default"
|
||||
library_payload = self._build_library_payload(
|
||||
folder_paths=self.settings.get("folder_paths", {}),
|
||||
default_lora_root=self.settings.get("default_lora_root", ""),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root", ""),
|
||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||
)
|
||||
libraries = {library_name: library_payload}
|
||||
self.settings["libraries"] = libraries
|
||||
self.settings["active_library"] = library_name
|
||||
self._sync_active_library_to_root(save=False)
|
||||
self._save_settings()
|
||||
return
|
||||
|
||||
sanitized_libraries: Dict[str, Dict[str, Any]] = {}
|
||||
changed = False
|
||||
for name, data in libraries.items():
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
changed = True
|
||||
payload = self._build_library_payload(
|
||||
folder_paths=data.get("folder_paths"),
|
||||
default_lora_root=data.get("default_lora_root"),
|
||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||
default_embedding_root=data.get("default_embedding_root"),
|
||||
metadata=data.get("metadata"),
|
||||
base=data,
|
||||
)
|
||||
sanitized_libraries[name] = payload
|
||||
if payload is not data:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self.settings["libraries"] = sanitized_libraries
|
||||
|
||||
if not active_name or active_name not in sanitized_libraries:
|
||||
if sanitized_libraries:
|
||||
self.settings["active_library"] = next(iter(sanitized_libraries.keys()))
|
||||
else:
|
||||
self.settings["active_library"] = "default"
|
||||
|
||||
self._sync_active_library_to_root(save=changed)
|
||||
|
||||
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", {})
|
||||
active_name = self.settings.get("active_library")
|
||||
if not libraries:
|
||||
return
|
||||
|
||||
if active_name not in libraries:
|
||||
active_name = next(iter(libraries.keys()))
|
||||
self.settings["active_library"] = active_name
|
||||
|
||||
active_library = libraries.get(active_name, {})
|
||||
folder_paths = copy.deepcopy(active_library.get("folder_paths", {}))
|
||||
self.settings["folder_paths"] = folder_paths
|
||||
self.settings["default_lora_root"] = active_library.get("default_lora_root", "")
|
||||
self.settings["default_checkpoint_root"] = active_library.get("default_checkpoint_root", "")
|
||||
self.settings["default_embedding_root"] = active_library.get("default_embedding_root", "")
|
||||
|
||||
if save:
|
||||
self._save_settings()
|
||||
|
||||
def _current_timestamp(self) -> str:
|
||||
return datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
def _build_library_payload(
|
||||
self,
|
||||
*,
|
||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
default_lora_root: Optional[str] = None,
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
base: Optional[Mapping[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = dict(base or {})
|
||||
timestamp = self._current_timestamp()
|
||||
|
||||
if folder_paths is not None:
|
||||
payload["folder_paths"] = self._normalize_folder_paths(folder_paths)
|
||||
else:
|
||||
payload.setdefault("folder_paths", {})
|
||||
|
||||
if default_lora_root is not None:
|
||||
payload["default_lora_root"] = default_lora_root
|
||||
else:
|
||||
payload.setdefault("default_lora_root", "")
|
||||
|
||||
if default_checkpoint_root is not None:
|
||||
payload["default_checkpoint_root"] = default_checkpoint_root
|
||||
else:
|
||||
payload.setdefault("default_checkpoint_root", "")
|
||||
|
||||
if default_embedding_root is not None:
|
||||
payload["default_embedding_root"] = default_embedding_root
|
||||
else:
|
||||
payload.setdefault("default_embedding_root", "")
|
||||
|
||||
if metadata:
|
||||
merged_meta = dict(payload.get("metadata", {}))
|
||||
merged_meta.update(metadata)
|
||||
payload["metadata"] = merged_meta
|
||||
|
||||
payload.setdefault("created_at", timestamp)
|
||||
payload["updated_at"] = timestamp
|
||||
return payload
|
||||
|
||||
def _normalize_folder_paths(
|
||||
self, folder_paths: Mapping[str, Iterable[str]]
|
||||
) -> Dict[str, List[str]]:
|
||||
normalized: Dict[str, List[str]] = {}
|
||||
for key, values in folder_paths.items():
|
||||
if not isinstance(values, Iterable):
|
||||
continue
|
||||
cleaned: List[str] = []
|
||||
seen = set()
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped not in seen:
|
||||
cleaned.append(stripped)
|
||||
seen.add(stripped)
|
||||
normalized[key] = cleaned
|
||||
return normalized
|
||||
|
||||
def _validate_folder_paths(
|
||||
self,
|
||||
library_name: str,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
) -> None:
|
||||
"""Ensure folder paths do not overlap with other libraries."""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
normalized_new: Dict[str, Dict[str, str]] = {}
|
||||
for key, values in folder_paths.items():
|
||||
path_map: Dict[str, str] = {}
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
normalized_value = os.path.normcase(os.path.normpath(stripped))
|
||||
path_map[normalized_value] = stripped
|
||||
if path_map:
|
||||
normalized_new[key] = path_map
|
||||
|
||||
if not normalized_new:
|
||||
return
|
||||
|
||||
for other_name, other in libraries.items():
|
||||
if other_name == library_name:
|
||||
continue
|
||||
other_paths = other.get("folder_paths", {})
|
||||
for key, new_paths in normalized_new.items():
|
||||
existing = {
|
||||
os.path.normcase(os.path.normpath(path))
|
||||
for path in other_paths.get(key, [])
|
||||
if isinstance(path, str) and path
|
||||
}
|
||||
overlap = existing.intersection(new_paths.keys())
|
||||
if overlap:
|
||||
collisions = ", ".join(sorted(new_paths[value] for value in overlap))
|
||||
raise ValueError(
|
||||
f"Folder path(s) {collisions} already assigned to library '{other_name}'"
|
||||
)
|
||||
|
||||
def _update_active_library_entry(
|
||||
self,
|
||||
*,
|
||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
default_lora_root: Optional[str] = None,
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
) -> bool:
|
||||
libraries = self.settings.get("libraries", {})
|
||||
active_name = self.settings.get("active_library")
|
||||
if not active_name or active_name not in libraries:
|
||||
return False
|
||||
|
||||
library = libraries[active_name]
|
||||
changed = False
|
||||
|
||||
if folder_paths is not None:
|
||||
normalized_paths = self._normalize_folder_paths(folder_paths)
|
||||
if library.get("folder_paths") != normalized_paths:
|
||||
library["folder_paths"] = normalized_paths
|
||||
changed = True
|
||||
|
||||
if default_lora_root is not None and library.get("default_lora_root") != default_lora_root:
|
||||
library["default_lora_root"] = default_lora_root
|
||||
changed = True
|
||||
|
||||
if default_checkpoint_root is not None and library.get("default_checkpoint_root") != default_checkpoint_root:
|
||||
library["default_checkpoint_root"] = default_checkpoint_root
|
||||
changed = True
|
||||
|
||||
if default_embedding_root is not None and library.get("default_embedding_root") != default_embedding_root:
|
||||
library["default_embedding_root"] = default_embedding_root
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
library.setdefault("created_at", self._current_timestamp())
|
||||
library["updated_at"] = self._current_timestamp()
|
||||
|
||||
return changed
|
||||
|
||||
def _migrate_setting_keys(self) -> None:
|
||||
"""Migrate legacy camelCase setting keys to snake_case"""
|
||||
key_migrations = {
|
||||
@@ -138,6 +358,11 @@ class SettingsManager:
|
||||
self.settings['default_embedding_root'] = embeddings[0]
|
||||
updated = True
|
||||
if updated:
|
||||
self._update_active_library_entry(
|
||||
default_lora_root=self.settings.get('default_lora_root'),
|
||||
default_checkpoint_root=self.settings.get('default_checkpoint_root'),
|
||||
default_embedding_root=self.settings.get('default_embedding_root'),
|
||||
)
|
||||
self._save_settings()
|
||||
|
||||
def _check_environment_variables(self) -> None:
|
||||
@@ -168,6 +393,14 @@ class SettingsManager:
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set setting value and save"""
|
||||
self.settings[key] = value
|
||||
if key == 'folder_paths' and isinstance(value, Mapping):
|
||||
self._update_active_library_entry(folder_paths=value) # type: ignore[arg-type]
|
||||
elif key == 'default_lora_root':
|
||||
self._update_active_library_entry(default_lora_root=str(value))
|
||||
elif key == 'default_checkpoint_root':
|
||||
self._update_active_library_entry(default_checkpoint_root=str(value))
|
||||
elif key == 'default_embedding_root':
|
||||
self._update_active_library_entry(default_embedding_root=str(value))
|
||||
self._save_settings()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
@@ -185,6 +418,227 @@ class SettingsManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving settings: {e}")
|
||||
|
||||
def get_libraries(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return a copy of the registered libraries."""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
return copy.deepcopy(libraries)
|
||||
|
||||
def get_active_library_name(self) -> str:
|
||||
"""Return the currently active library name."""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
active_name = self.settings.get("active_library")
|
||||
if active_name and active_name in libraries:
|
||||
return active_name
|
||||
if libraries:
|
||||
return next(iter(libraries.keys()))
|
||||
return "default"
|
||||
|
||||
def get_active_library(self) -> Dict[str, Any]:
|
||||
"""Return a copy of the active library configuration."""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
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,
|
||||
*,
|
||||
folder_paths: Optional[Mapping[str, Iterable[str]]] = None,
|
||||
default_lora_root: Optional[str] = None,
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
activate: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create or update a library definition."""
|
||||
|
||||
name = library_name.strip()
|
||||
if not name:
|
||||
raise ValueError("Library name cannot be empty")
|
||||
|
||||
if folder_paths is not None:
|
||||
self._validate_folder_paths(name, folder_paths)
|
||||
|
||||
libraries = self.settings.setdefault("libraries", {})
|
||||
existing = libraries.get(name, {})
|
||||
|
||||
payload = self._build_library_payload(
|
||||
folder_paths=folder_paths if folder_paths is not None else existing.get("folder_paths"),
|
||||
default_lora_root=default_lora_root if default_lora_root is not None else existing.get("default_lora_root"),
|
||||
default_checkpoint_root=(
|
||||
default_checkpoint_root
|
||||
if default_checkpoint_root is not None
|
||||
else existing.get("default_checkpoint_root")
|
||||
),
|
||||
default_embedding_root=(
|
||||
default_embedding_root
|
||||
if default_embedding_root is not None
|
||||
else existing.get("default_embedding_root")
|
||||
),
|
||||
metadata=metadata if metadata is not None else existing.get("metadata"),
|
||||
base=existing,
|
||||
)
|
||||
|
||||
libraries[name] = payload
|
||||
|
||||
if activate or not self.settings.get("active_library"):
|
||||
self.settings["active_library"] = name
|
||||
|
||||
self._sync_active_library_to_root(save=False)
|
||||
self._save_settings()
|
||||
|
||||
if self.settings.get("active_library") == name:
|
||||
self._notify_library_change(name)
|
||||
|
||||
return payload
|
||||
|
||||
def create_library(
|
||||
self,
|
||||
library_name: str,
|
||||
*,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
default_lora_root: str = "",
|
||||
default_checkpoint_root: str = "",
|
||||
default_embedding_root: 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,
|
||||
default_lora_root=default_lora_root,
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
metadata=metadata,
|
||||
activate=activate,
|
||||
)
|
||||
|
||||
def rename_library(self, old_name: str, new_name: str) -> None:
|
||||
"""Rename an existing library."""
|
||||
|
||||
libraries = self.settings.get("libraries", {})
|
||||
if old_name not in libraries:
|
||||
raise KeyError(f"Library '{old_name}' does not exist")
|
||||
new_name_stripped = new_name.strip()
|
||||
if not new_name_stripped:
|
||||
raise ValueError("New library name cannot be empty")
|
||||
if new_name_stripped in libraries:
|
||||
raise ValueError(f"Library '{new_name_stripped}' already exists")
|
||||
|
||||
libraries[new_name_stripped] = libraries.pop(old_name)
|
||||
if self.settings.get("active_library") == old_name:
|
||||
self.settings["active_library"] = new_name_stripped
|
||||
active_name = new_name_stripped
|
||||
else:
|
||||
active_name = self.settings.get("active_library")
|
||||
|
||||
self._sync_active_library_to_root(save=False)
|
||||
self._save_settings()
|
||||
|
||||
if active_name == new_name_stripped:
|
||||
self._notify_library_change(new_name_stripped)
|
||||
|
||||
def delete_library(self, library_name: str) -> None:
|
||||
"""Remove a library definition."""
|
||||
|
||||
libraries = self.settings.get("libraries", {})
|
||||
if library_name not in libraries:
|
||||
raise KeyError(f"Library '{library_name}' does not exist")
|
||||
if len(libraries) == 1:
|
||||
raise ValueError("At least one library must remain")
|
||||
|
||||
was_active = self.settings.get("active_library") == library_name
|
||||
libraries.pop(library_name)
|
||||
|
||||
if was_active:
|
||||
new_active = next(iter(libraries.keys()))
|
||||
self.settings["active_library"] = new_active
|
||||
self._sync_active_library_to_root(save=False)
|
||||
self._save_settings()
|
||||
|
||||
if was_active:
|
||||
self._notify_library_change(self.settings["active_library"])
|
||||
|
||||
def update_active_library_paths(
|
||||
self,
|
||||
folder_paths: Mapping[str, Iterable[str]],
|
||||
*,
|
||||
default_lora_root: Optional[str] = None,
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update folder paths for the active library."""
|
||||
|
||||
active_name = self.get_active_library_name()
|
||||
self.upsert_library(
|
||||
active_name,
|
||||
folder_paths=folder_paths,
|
||||
default_lora_root=default_lora_root,
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
activate=True,
|
||||
)
|
||||
|
||||
def _notify_library_change(self, library_name: str) -> None:
|
||||
"""Notify dependent services that the active library changed."""
|
||||
libraries = self.settings.get("libraries", {})
|
||||
library_config = libraries.get(library_name, {})
|
||||
library_snapshot = copy.deepcopy(library_config)
|
||||
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.apply_library_settings(library_snapshot)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to apply library settings to config: %s", exc)
|
||||
|
||||
try:
|
||||
from .service_registry import ServiceRegistry # type: ignore
|
||||
|
||||
for service_name in (
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"recipe_scanner",
|
||||
):
|
||||
service = ServiceRegistry.get_service_sync(service_name)
|
||||
if service and hasattr(service, "on_library_changed"):
|
||||
try:
|
||||
service.on_library_changed()
|
||||
except Exception as service_exc: # pragma: no cover - defensive logging
|
||||
logger.debug(
|
||||
"Service %s failed to handle library change: %s",
|
||||
service_name,
|
||||
service_exc,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to notify services about library change: %s", exc)
|
||||
|
||||
def get_download_path_template(self, model_type: str) -> str:
|
||||
"""Get download path template for specific model type
|
||||
|
||||
|
||||
Reference in New Issue
Block a user