mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
feat(sidecars): surface storage location and cover excluded models in migration
After migrating to centralized sidecar storage users had no indication where their files went, and portable-mode installs silently placed the sidecar root inside the plugin folder where a reinstall or git clean would delete it. - Migration now also covers models excluded from the library view and returns the resolved sidecar root in its result payload - get_settings exposes the resolved sidecar root, whether it is the default, and whether it lives inside the installation folder - New POST /api/lm/sidecars/open-location endpoint opens (or copies) the sidecar storage folder - Settings UI always shows the effective storage path with an open-folder button, and warns when the root is inside the installation folder (portable-mode hazard) - Migration confirmation shows the destination; on completion a result dialog summarizes moved/skipped/conflict counts with the storage location and an open-folder action - Ignore /sidecars/ at the repository root so portable-mode sidecars are never committed Refs #1045
This commit is contained in:
@@ -70,7 +70,12 @@ from ...utils.example_images_paths import (
|
||||
)
|
||||
from ...utils.lora_metadata import extract_trained_words
|
||||
from ...utils.session_logging import get_standalone_session_log_snapshot
|
||||
from ...utils.sidecar_paths import get_metadata_path, get_preview_dir
|
||||
from ...utils.sidecar_paths import (
|
||||
describe_sidecar_root,
|
||||
get_configured_sidecar_root,
|
||||
get_metadata_path,
|
||||
get_preview_dir,
|
||||
)
|
||||
from ...utils.usage_stats import UsageStats
|
||||
from .base_model_handlers import BaseModelHandlerSet
|
||||
|
||||
@@ -1627,6 +1632,19 @@ class SettingsHandler:
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
# Resolved centralized sidecar root (mode-independent): lets the
|
||||
# settings UI show where sidecars actually live, including when the
|
||||
# path setting is empty and the default kicks in. inside_repo flags
|
||||
# the portable-mode hazard (root inside the plugin folder).
|
||||
try:
|
||||
sidecar_info = describe_sidecar_root()
|
||||
response_data["sidecar_storage_root"] = sidecar_info["root"]
|
||||
response_data["sidecar_storage_root_is_default"] = sidecar_info["is_default"]
|
||||
response_data["sidecar_storage_root_in_repo"] = sidecar_info["inside_repo"]
|
||||
except Exception as sidecar_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve sidecar storage info: %s", sidecar_error
|
||||
)
|
||||
messages_getter: Any = getattr(self._settings, "get_startup_messages", None)
|
||||
messages = list(messages_getter()) if messages_getter else []
|
||||
return web.json_response(
|
||||
@@ -3516,6 +3534,24 @@ class FileSystemHandler:
|
||||
logger.error("Failed to open wildcards location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def open_sidecar_location(self, request: web.Request) -> web.Response:
|
||||
"""Open the centralized sidecar storage root in the file manager."""
|
||||
|
||||
try:
|
||||
root = get_configured_sidecar_root()
|
||||
if not root:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Sidecar storage root is not resolvable"},
|
||||
status=404,
|
||||
)
|
||||
# Create on demand so the button also works before the first
|
||||
# migration/download has materialized the directory.
|
||||
os.makedirs(root, exist_ok=True)
|
||||
return await self._open_path(root)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Failed to open sidecar location: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def browse_directory(self, request: web.Request) -> web.Response:
|
||||
"""Browse a directory for the settings-UI directory picker."""
|
||||
try:
|
||||
@@ -4290,6 +4326,7 @@ class MiscHandlerSet:
|
||||
"open_settings_location": self.filesystem.open_settings_location,
|
||||
"open_backup_location": self.filesystem.open_backup_location,
|
||||
"open_wildcards_location": self.filesystem.open_wildcards_location,
|
||||
"open_sidecar_location": self.filesystem.open_sidecar_location,
|
||||
"browse_directory": self.filesystem.browse_directory,
|
||||
"validate_path": self.filesystem.validate_path,
|
||||
"search_custom_words": self.custom_words.search_custom_words,
|
||||
|
||||
@@ -120,6 +120,9 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/sidecars/migrate", "migrate_sidecars"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/sidecars/open-location", "open_sidecar_location"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-model-source", "download_model_source"
|
||||
),
|
||||
|
||||
@@ -276,6 +276,7 @@ class SidecarMigrationUseCase:
|
||||
"conflicts": counters["conflicts"],
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
"sidecar_root": new_root,
|
||||
}
|
||||
|
||||
def _rewrite_root_prefix(
|
||||
@@ -344,6 +345,7 @@ class SidecarMigrationUseCase:
|
||||
"conflicts": 0,
|
||||
"errors": [],
|
||||
"error_count": 0,
|
||||
"sidecar_root": get_configured_sidecar_root() or "",
|
||||
}
|
||||
|
||||
def _active_scanner_factories(self) -> Tuple[Tuple[str, ScannerFactory], ...]:
|
||||
@@ -356,7 +358,13 @@ class SidecarMigrationUseCase:
|
||||
async def _collect_model_paths(
|
||||
self, errors: List[Dict[str, str]]
|
||||
) -> List[Tuple[Any, List[str]]]:
|
||||
"""Enumerate model file paths grouped by the scanner that owns them."""
|
||||
"""Enumerate model file paths grouped by the scanner that owns them.
|
||||
|
||||
Excluded models are included: they are absent from the cache but still
|
||||
on disk, and leaving their sidecars behind would strand the metadata
|
||||
if the user later un-excludes them (the scanner would then look the
|
||||
sidecar up in the NEW layout and find nothing).
|
||||
"""
|
||||
|
||||
groups: List[Tuple[Any, List[str]]] = []
|
||||
for model_type, factory in self._active_scanner_factories():
|
||||
@@ -376,6 +384,19 @@ class SidecarMigrationUseCase:
|
||||
for entry in cache.raw_data
|
||||
if entry.get("file_path")
|
||||
]
|
||||
get_excluded = getattr(scanner, "get_excluded_models", None)
|
||||
if callable(get_excluded):
|
||||
try:
|
||||
known = set(paths)
|
||||
paths.extend(
|
||||
path for path in get_excluded() if path and path not in known
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Sidecar migration: failed to enumerate excluded %s models: %s",
|
||||
model_type,
|
||||
exc,
|
||||
)
|
||||
groups.append((scanner, paths))
|
||||
return groups
|
||||
|
||||
@@ -480,6 +501,9 @@ class SidecarMigrationUseCase:
|
||||
"conflicts": conflicts,
|
||||
"errors": errors,
|
||||
"error_count": len(errors),
|
||||
# Effective centralized root, so the UI can show/offer to open the
|
||||
# destination (or, for to_alongside, the source) after the run.
|
||||
"sidecar_root": root,
|
||||
}
|
||||
|
||||
async def _migrate_model(
|
||||
|
||||
@@ -109,6 +109,47 @@ def get_configured_sidecar_root() -> str:
|
||||
return _resolve_root_from_settings()
|
||||
|
||||
|
||||
def _installation_root() -> str:
|
||||
"""Return the plugin installation directory (repository root)."""
|
||||
|
||||
return os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
|
||||
|
||||
def _path_contains(base: str, path: str) -> bool:
|
||||
"""Containment check tolerant of symlinked installs (custom_nodes links)."""
|
||||
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
normalized = os.path.normcase(os.path.normpath(candidate))
|
||||
for root_variant in (os.path.abspath(base), os.path.realpath(base)):
|
||||
root_normalized = os.path.normcase(os.path.normpath(root_variant))
|
||||
if (
|
||||
normalized == root_normalized
|
||||
or normalized.startswith(root_normalized + os.sep)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def describe_sidecar_root() -> dict:
|
||||
"""Describe the effective centralized sidecar root for UI display.
|
||||
|
||||
``inside_repo`` flags the portable-mode hazard: when settings live in the
|
||||
repository, the default root lands inside the plugin folder, where a
|
||||
reinstall or ``git clean`` would silently delete every sidecar.
|
||||
"""
|
||||
|
||||
configured = _get_settings_value("sidecar_storage_path", "")
|
||||
is_default = not (isinstance(configured, str) and configured.strip())
|
||||
root = _resolve_root_from_settings()
|
||||
return {
|
||||
"root": root,
|
||||
"is_default": is_default,
|
||||
"inside_repo": bool(root) and _path_contains(_installation_root(), root),
|
||||
}
|
||||
|
||||
|
||||
def sanitize_path_component(name: str) -> str:
|
||||
"""Return a filesystem-safe single path component."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user