feat: optional centralized storage for sidecar metadata and previews (#1045)

Add an opt-in 'centralized' sidecar storage mode alongside the default
'alongside' layout. In centralized mode, .metadata.json sidecars and
preview assets live under a configurable root (sidecar_storage_path,
default <settings_dir>/sidecars), mirroring the library-relative
directory structure: <root>/<library>/<root_basename>/<rel_dir>/.

Backend:
- settings: sidecar_storage_mode / sidecar_storage_path with validation;
  changing either refreshes the preview allowlist
- config: centralized root added to preview-serving allowlist
- lifecycle: delete / move / rename / folder-rename / folder-delete and
  undoable-delete staging all operate on the mirror tree in centralized
  mode (model files themselves never move); EXDEV-tolerant cross-
  filesystem moves
- scanners: pending-hash filesystem scan walks the mirror tree in
  centralized mode; preview discovery reads from the sidecar dir;
  .civitai.info stays co-located in both modes
- migration: SidecarMigrationUseCase moves sidecars+previews between
  layouts both directions (keep-newer conflict resolution, preview_url
  rewriting, WebSocket progress), exposed as POST+GET
  /api/lm/sidecars/migrate with a mode guard (force=true for the
  settings-first flow)

Frontend:
- settings modal: sidecar storage section (mode select + path input with
  browse/validation), mode-change confirmation offering immediate
  migration (force=true), and a 'Migrate Sidecars Now' action
- i18n keys synced to all locales ([TODO: Translate] placeholders)

Docs: metadata-json-schema.md gains a storage-location section;
AGENTS.md records the sidecar_paths helper convention.
This commit is contained in:
Will Miao
2026-09-26 10:00:58 +08:00
parent 297d8787bd
commit f5e983eaaa
39 changed files with 2704 additions and 86 deletions
+12 -8
View File
@@ -8,7 +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
from .sidecar_paths import get_metadata_path, get_preview_dir, resolve_metadata_path
logger = logging.getLogger(__name__)
@@ -189,6 +189,10 @@ class MetadataManager:
temp_path = f"{metadata_path}.tmp"
try:
# Centralized sidecar mirrors may not exist yet (unlike the model's
# own directory in alongside mode, which always does).
os.makedirs(os.path.dirname(metadata_path), exist_ok=True)
# Convert to dict if needed
if isinstance(metadata, BaseModelMetadata):
metadata_dict = metadata.to_dict()
@@ -251,10 +255,9 @@ class MetadataManager:
try:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
preview_url = find_preview_file(base_name, get_preview_dir(file_path))
# Calculate file hash
start_hash_time = time.perf_counter()
@@ -378,15 +381,16 @@ class MetadataManager:
# Check if preview exists at the current location
preview_url = metadata.preview_url
if preview_url:
# Get directory parts of both paths
file_dir = os.path.dirname(file_path)
# Get directory parts of both paths; the preview directory is the
# sidecar/preview dir (the model's own dir in alongside mode, the
# centralized mirror otherwise).
file_dir = get_preview_dir(file_path)
preview_dir = os.path.dirname(preview_url)
# Update preview if it doesn't exist OR if model and preview are in different directories
if not os.path.exists(preview_url) or file_dir != preview_dir:
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
new_preview_url = find_preview_file(base_name, dir_path)
new_preview_url = find_preview_file(base_name, file_dir)
if new_preview_url:
metadata.preview_url = normalize_path(new_preview_url)
need_update = True
+51 -14
View File
@@ -64,15 +64,8 @@ def is_centralized() -> bool:
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 ""
def _resolve_root_from_settings() -> str:
"""Resolve the configured/default centralized root, ignoring the active mode."""
configured = _get_settings_value("sidecar_storage_path", "")
if configured and isinstance(configured, str):
@@ -90,6 +83,31 @@ def get_sidecar_root() -> str:
return ""
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 ""
return _resolve_root_from_settings()
def get_configured_sidecar_root() -> str:
"""Return the centralized sidecar root regardless of the active mode.
Unlike :func:`get_sidecar_root`, this resolves the configured
``sidecar_storage_path`` (or the ``<settings_dir>/sidecars`` default) even
when the storage mode is ``alongside``. Migration tooling needs both
layouts at once and must not depend on which mode is currently active.
"""
return _resolve_root_from_settings()
def sanitize_path_component(name: str) -> str:
"""Return a filesystem-safe single path component."""
@@ -137,19 +155,38 @@ def resolve_centralized_dir(model_path: str) -> Optional[str]:
or the path is not under any configured model root.
"""
root = get_sidecar_root()
return resolve_centralized_dir_for_dir(
os.path.dirname(_normalize_for_match(model_path))
)
def resolve_centralized_dir_for_dir(
model_dir: str, *, sidecar_root: Optional[str] = None
) -> Optional[str]:
"""Return the centralized mirror directory for a model *directory*.
Same layout as :func:`resolve_centralized_dir`, but accepts the directory
itself. Used by folder-level operations (folder rename, mirror-tree walks)
that have no model file path to derive from. Passing a configured model
root returns the mirror base for that root.
``sidecar_root`` overrides the root lookup; pass
:func:`get_configured_sidecar_root` to resolve mirror paths independently
of the active storage mode (migration tooling).
"""
root = sidecar_root if sidecar_root is not None else get_sidecar_root()
if not root:
return None
target = _normalize_for_match(model_path)
model_dir = os.path.dirname(target)
normalized_dir = _normalize_for_match(model_dir)
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 normalized_dir == normalized or normalized_dir.startswith(normalized + os.sep):
if best_root is None or len(normalized) > len(best_root):
best_root = normalized
@@ -163,7 +200,7 @@ def resolve_centralized_dir(model_path: str) -> Optional[str]:
except Exception: # pragma: no cover - defensive fallback
library = "default"
rel_dir = os.path.relpath(model_dir, best_root)
rel_dir = os.path.relpath(normalized_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))