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
+20
View File
@@ -891,6 +891,17 @@ class Config:
if self.recipes_path:
preview_roots.update(self._expand_preview_root(self.recipes_path))
# Centralized sidecar storage holds preview assets outside the model
# roots; allow serving them when the mode is active.
try:
from .utils.sidecar_paths import get_sidecar_root # Local import to avoid circular dependency
sidecar_root = get_sidecar_root()
except Exception: # pragma: no cover - defensive fallback
sidecar_root = ""
if sidecar_root:
preview_roots.update(self._expand_preview_root(sidecar_root))
for target, link in self._path_mappings.items():
preview_roots.update(self._expand_preview_root(target))
preview_roots.update(self._expand_preview_root(link))
@@ -1494,6 +1505,15 @@ class Config:
self.other_roots = self._init_other_paths()
self._rebuild_preview_roots()
def refresh_preview_roots(self) -> None:
"""Rebuild the preview allowlist after path-affecting settings change.
Called when ``sidecar_storage_mode`` / ``sidecar_storage_path`` are
updated so centralized preview assets become servable (or stop being
servable) without a restart.
"""
self._rebuild_preview_roots()
def get_other_models_availability(self) -> Dict[str, Any]:
"""Report the other-model folders the host can actually expose.