mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 13:34:08 -03:00
refactor: route sidecar/preview path derivation through sidecar_paths helpers
Phase 1 of #1045 (optional centralized sidecar storage): introduce py/utils/sidecar_paths.py as the single place that resolves .metadata.json and preview locations, and replace all inline splitext-based derivations across scanners, services, download manager, and route handlers. No behavior change: the default 'alongside' storage mode resolves every path exactly as before. .civitai.info (third-party sidecar) derivation is intentionally left co-located.
This commit is contained in:
@@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
|
||||
from .models import BaseModelMetadata, CheckpointMetadata, EmbeddingMetadata, LoraMetadata
|
||||
from .file_utils import normalize_path, find_preview_file, calculate_sha256, calculate_autov3
|
||||
from .lora_metadata import extract_lora_metadata, extract_checkpoint_metadata
|
||||
from .sidecar_paths import get_metadata_path, resolve_metadata_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -32,7 +33,7 @@ class MetadataManager:
|
||||
- metadata: BaseModelMetadata instance or None
|
||||
- should_skip: True if corrupted metadata file exists and model should be skipped
|
||||
"""
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
|
||||
# Check if metadata file exists
|
||||
if not os.path.exists(metadata_path):
|
||||
@@ -98,11 +99,7 @@ class MetadataManager:
|
||||
payload.update(unknown_fields)
|
||||
else:
|
||||
if not should_skip:
|
||||
metadata_path = (
|
||||
file_path
|
||||
if file_path.endswith(".metadata.json")
|
||||
else f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
)
|
||||
metadata_path = resolve_metadata_path(file_path)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||
@@ -150,7 +147,7 @@ class MetadataManager:
|
||||
return model_data
|
||||
|
||||
folder = model_data.get("folder")
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = get_metadata_path(file_path)
|
||||
sidecar_exists = os.path.exists(metadata_path)
|
||||
cached = model_data.copy()
|
||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||
@@ -188,12 +185,7 @@ class MetadataManager:
|
||||
bool: Success or failure
|
||||
"""
|
||||
# Determine if the input is a metadata path or a model file path
|
||||
if path.endswith('.metadata.json'):
|
||||
metadata_path = path
|
||||
else:
|
||||
# Use existing logic for model file paths
|
||||
file_path = path
|
||||
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||
metadata_path = resolve_metadata_path(path)
|
||||
temp_path = f"{metadata_path}.tmp"
|
||||
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Resolution of sidecar metadata and preview storage paths.
|
||||
|
||||
All code that needs the on-disk location of a model's ``.metadata.json``
|
||||
sidecar or preview assets MUST go through these helpers instead of deriving
|
||||
paths inline (``splitext(model_path)[0] + ".metadata.json"`` and friends).
|
||||
|
||||
Two storage modes are supported, selected by the ``sidecar_storage_mode``
|
||||
setting:
|
||||
|
||||
- ``alongside`` (default): sidecars and previews live next to the model
|
||||
file, the historical layout other tools may rely on.
|
||||
- ``centralized``: sidecars and previews live under a configurable root
|
||||
(``sidecar_storage_path`` setting, default ``<settings_dir>/sidecars``),
|
||||
mirroring the library-relative directory structure:
|
||||
``<root>/<library>/<root_basename>/<rel_dir>/<name>.metadata.json``.
|
||||
|
||||
All helpers are pure path computations: no directory scans and no file I/O
|
||||
on the hot path. Settings lookups go through ``SettingsManager.get`` (a dict
|
||||
read); config roots come from the already-initialized ``config`` singleton.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
METADATA_SUFFIX = ".metadata.json"
|
||||
|
||||
STORAGE_MODE_ALONGSIDE = "alongside"
|
||||
STORAGE_MODE_CENTRALIZED = "centralized"
|
||||
|
||||
_VALID_MODES = frozenset({STORAGE_MODE_ALONGSIDE, STORAGE_MODE_CENTRALIZED})
|
||||
|
||||
|
||||
def _get_settings_value(key: str, default=None):
|
||||
"""Read a setting defensively; never fail path resolution on settings errors."""
|
||||
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
value = get_settings_manager().get(key)
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("sidecar_paths: settings lookup for %r failed: %s", key, exc)
|
||||
return default
|
||||
return default if value is None else value
|
||||
|
||||
|
||||
def get_storage_mode() -> str:
|
||||
"""Return the active sidecar storage mode (``alongside`` unless configured)."""
|
||||
|
||||
mode = _get_settings_value("sidecar_storage_mode", STORAGE_MODE_ALONGSIDE)
|
||||
if mode not in _VALID_MODES:
|
||||
return STORAGE_MODE_ALONGSIDE
|
||||
return mode
|
||||
|
||||
|
||||
def is_centralized() -> bool:
|
||||
"""Return True when centralized sidecar storage is active and resolvable."""
|
||||
|
||||
return get_storage_mode() == STORAGE_MODE_CENTRALIZED and bool(get_sidecar_root())
|
||||
|
||||
|
||||
def get_sidecar_root() -> str:
|
||||
"""Return the absolute root directory for centralized sidecar storage.
|
||||
|
||||
Empty string when centralized storage is not usable (mode alongside or an
|
||||
unresolvable configured path).
|
||||
"""
|
||||
|
||||
if get_storage_mode() != STORAGE_MODE_CENTRALIZED:
|
||||
return ""
|
||||
|
||||
configured = _get_settings_value("sidecar_storage_path", "")
|
||||
if configured and isinstance(configured, str):
|
||||
root = os.path.abspath(os.path.expanduser(configured.strip()))
|
||||
if root:
|
||||
return root
|
||||
|
||||
# Default: <settings_dir>/sidecars
|
||||
try:
|
||||
from .settings_paths import get_settings_dir
|
||||
|
||||
return os.path.join(get_settings_dir(), "sidecars")
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.warning("sidecar_paths: cannot resolve default sidecar root: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def sanitize_path_component(name: str) -> str:
|
||||
"""Return a filesystem-safe single path component."""
|
||||
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", name or "")
|
||||
return safe or "_"
|
||||
|
||||
|
||||
def _iter_model_roots() -> List[str]:
|
||||
"""Return every configured model root for the active library."""
|
||||
|
||||
try:
|
||||
from ..config import config
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.debug("sidecar_paths: config unavailable: %s", exc)
|
||||
return []
|
||||
|
||||
roots: List[str] = []
|
||||
for attr in (
|
||||
"loras_roots",
|
||||
"base_models_roots",
|
||||
"embeddings_roots",
|
||||
"other_roots",
|
||||
"extra_loras_roots",
|
||||
"extra_checkpoints_roots",
|
||||
"extra_unet_roots",
|
||||
"extra_embeddings_roots",
|
||||
):
|
||||
value = getattr(config, attr, None)
|
||||
if value:
|
||||
roots.extend(value)
|
||||
return roots
|
||||
|
||||
|
||||
def _normalize_for_match(path: str) -> str:
|
||||
return os.path.normpath(os.path.abspath(path))
|
||||
|
||||
|
||||
def resolve_centralized_dir(model_path: str) -> Optional[str]:
|
||||
"""Return the centralized mirror directory for ``model_path``.
|
||||
|
||||
The mirror layout is ``<sidecar_root>/<library>/<root_basename>/<rel_dir>``
|
||||
where ``rel_dir`` is the model's directory relative to the model root that
|
||||
contains it. The longest matching root wins so nested roots resolve to the
|
||||
most specific mirror. Returns ``None`` when centralized storage is inactive
|
||||
or the path is not under any configured model root.
|
||||
"""
|
||||
|
||||
root = get_sidecar_root()
|
||||
if not root:
|
||||
return None
|
||||
|
||||
target = _normalize_for_match(model_path)
|
||||
model_dir = os.path.dirname(target)
|
||||
|
||||
best_root: Optional[str] = None
|
||||
for candidate in _iter_model_roots():
|
||||
if not candidate:
|
||||
continue
|
||||
normalized = _normalize_for_match(candidate)
|
||||
if model_dir == normalized or model_dir.startswith(normalized + os.sep):
|
||||
if best_root is None or len(normalized) > len(best_root):
|
||||
best_root = normalized
|
||||
|
||||
if best_root is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
from ..services.settings_manager import get_settings_manager
|
||||
|
||||
library = get_settings_manager().get_active_library_name() or "default"
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
library = "default"
|
||||
|
||||
rel_dir = os.path.relpath(model_dir, best_root)
|
||||
parts = [root, sanitize_path_component(library), sanitize_path_component(os.path.basename(best_root))]
|
||||
if rel_dir and rel_dir != os.curdir:
|
||||
parts.extend(sanitize_path_component(part) for part in rel_dir.split(os.sep) if part not in ("", os.curdir))
|
||||
return os.path.join(*parts)
|
||||
|
||||
|
||||
def get_sidecar_dir(model_path: str) -> str:
|
||||
"""Return the directory holding the model's sidecar/preview assets.
|
||||
|
||||
Centralized mode falls back to the model's own directory (with a warning)
|
||||
when the path lies outside every configured model root.
|
||||
"""
|
||||
|
||||
if get_storage_mode() == STORAGE_MODE_CENTRALIZED:
|
||||
mirror = resolve_centralized_dir(model_path)
|
||||
if mirror:
|
||||
return mirror
|
||||
logger.warning(
|
||||
"sidecar_paths: %s is outside configured model roots; storing sidecar alongside",
|
||||
model_path,
|
||||
)
|
||||
return os.path.dirname(os.path.abspath(model_path))
|
||||
|
||||
|
||||
def get_metadata_path(model_path: str) -> str:
|
||||
"""Return the ``.metadata.json`` sidecar path for a model file."""
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(model_path))[0] + METADATA_SUFFIX
|
||||
return os.path.join(get_sidecar_dir(model_path), base_name)
|
||||
|
||||
|
||||
def is_metadata_path(path: str) -> bool:
|
||||
"""Return True when ``path`` already points at a metadata sidecar file."""
|
||||
|
||||
return path.endswith(METADATA_SUFFIX)
|
||||
|
||||
|
||||
def resolve_metadata_path(path: str) -> str:
|
||||
"""Accept either a model path or a sidecar path and return the sidecar path."""
|
||||
|
||||
if is_metadata_path(path):
|
||||
return path
|
||||
return get_metadata_path(path)
|
||||
|
||||
|
||||
def get_preview_dir(model_path: str) -> str:
|
||||
"""Return the directory holding the model's preview assets."""
|
||||
|
||||
return get_sidecar_dir(model_path)
|
||||
Reference in New Issue
Block a user