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
+29 -22
View File
@@ -11,7 +11,7 @@ from ..services.service_registry import ServiceRegistry
from ..services.pending_delete_service import get_pending_delete_service
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils.metadata_manager import MetadataManager
from ..utils.sidecar_paths import get_metadata_path
from ..utils.sidecar_paths import get_metadata_path, get_preview_dir, get_sidecar_dir
logger = logging.getLogger(__name__)
@@ -42,19 +42,22 @@ async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]:
"""Delete the primary model artefacts within ``target_dir``."""
"""Delete the primary model artefacts within ``target_dir``.
Sidecars and previews are taken from the model's sidecar directory — the
model's own directory in alongside mode, the centralized mirror otherwise.
"""
main_extension = ".safetensors" if main_extension is None else main_extension
main_file = f"{file_name}{main_extension}" if main_extension else file_name
patterns = [
main_file,
os.path.basename(get_metadata_path(os.path.join(target_dir, main_file))),
]
model_path = os.path.join(target_dir, main_file)
sidecar_dir = get_sidecar_dir(model_path)
patterns = [os.path.basename(get_metadata_path(model_path))]
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{file_name}{ext}")
deleted: List[str] = []
main_path = os.path.join(target_dir, main_file).replace(os.sep, "/")
main_path = model_path.replace(os.sep, "/")
if os.path.exists(main_path):
os.remove(main_path)
@@ -62,8 +65,8 @@ async def delete_model_artifacts(
else:
logger.warning("Model file not found: %s", main_file)
for pattern in patterns[1:]:
path = os.path.join(target_dir, pattern)
for pattern in patterns:
path = os.path.join(sidecar_dir, pattern)
if os.path.exists(path):
try:
os.remove(path)
@@ -389,17 +392,21 @@ class ModelLifecycleService:
raise ValueError("A file with this name already exists")
metadata_filename = os.path.basename(get_metadata_path(file_path))
patterns = [
f"{old_file_name}{old_extension}",
metadata_filename,
f"{metadata_filename}.bak",
# Sidecars/previews live in the sidecar dir (the model's own dir in
# alongside mode, the centralized mirror otherwise); the model file
# itself always stays in target_dir.
sidecar_dir = get_sidecar_dir(file_path)
patterns: List[tuple[str, str]] = [
(target_dir, f"{old_file_name}{old_extension}"),
(sidecar_dir, metadata_filename),
(sidecar_dir, f"{metadata_filename}.bak"),
]
for ext in PREVIEW_EXTENSIONS:
patterns.append(f"{old_file_name}{ext}")
patterns.append((sidecar_dir, f"{old_file_name}{ext}"))
existing_files: List[tuple[str, str]] = []
for pattern in patterns:
path = os.path.join(target_dir, pattern)
for pattern_dir, pattern in patterns:
path = os.path.join(pattern_dir, pattern)
if os.path.exists(path):
existing_files.append((path, pattern))
@@ -418,9 +425,9 @@ class ModelLifecycleService:
for old_path, pattern in existing_files:
ext = self._get_multipart_ext(pattern)
new_path = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
os.sep, "/"
)
new_path = os.path.join(
os.path.dirname(old_path), f"{new_file_name}{ext}"
).replace(os.sep, "/")
os.rename(old_path, new_path)
renamed_files.append(new_path)
@@ -437,9 +444,9 @@ class ModelLifecycleService:
if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"])
ext = self._get_multipart_ext(old_preview)
new_preview = os.path.join(target_dir, f"{new_file_name}{ext}").replace(
os.sep, "/"
)
new_preview = os.path.join(
get_preview_dir(new_file_path), f"{new_file_name}{ext}"
).replace(os.sep, "/")
metadata["preview_url"] = new_preview
await self._metadata_manager.save_metadata(new_file_path, metadata)