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
+30 -1
View File
@@ -99,6 +99,8 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
"recipes_path": "",
"sidecar_storage_mode": "alongside",
"sidecar_storage_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
"download_filename_templates": {},
@@ -1616,9 +1618,30 @@ class SettingsManager:
return os.path.abspath(os.path.normpath(os.path.expanduser(stripped)))
@staticmethod
def _normalize_sidecar_storage_mode(value: Any) -> str:
"""Return a valid sidecar storage mode, falling back to ``alongside``."""
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in ("alongside", "centralized"):
return normalized
return "alongside"
def _refresh_sidecar_storage_config(self) -> None:
"""Rebuild dependent config state after sidecar storage settings change."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_preview_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug(
"Failed to refresh config after sidecar storage change: %s", exc
)
def _get_effective_recipes_dir(self, recipes_path: Optional[str] = None) -> str:
"""Resolve the effective recipes directory for the active library."""
normalized_custom = self._normalize_recipes_path_value(
self.settings.get("recipes_path", "")
if recipes_path is None
@@ -1815,6 +1838,10 @@ class SettingsManager:
target_recipes_dir = self._get_effective_recipes_dir(value)
self._validate_recipes_storage_path(target_recipes_dir)
self._migrate_recipes_directory(current_recipes_dir, target_recipes_dir)
elif key == "sidecar_storage_mode":
value = self._normalize_sidecar_storage_mode(value)
elif key == "sidecar_storage_path":
value = self._normalize_recipes_path_value(value)
self.settings[key] = value
portable_switch_pending = False
if key == "use_portable_settings" and isinstance(value, bool):
@@ -1845,6 +1872,8 @@ class SettingsManager:
self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if key in ("sidecar_storage_mode", "sidecar_storage_path"):
self._refresh_sidecar_storage_config()
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if portable_switch_pending: