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
@@ -0,0 +1,294 @@
"""Sidecar migration use case: layout moves, conflicts, guards."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List
import pytest
from py.config import config
from py.services.settings_manager import get_settings_manager
from py.services.use_cases.sidecar_migration_use_case import SidecarMigrationUseCase
def _normalize(path) -> str:
return str(path).replace(os.sep, "/")
@pytest.fixture
def library_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Single lora root under tmp_path; every other root emptied."""
root = tmp_path / "loras"
root.mkdir()
for attr, value in (
("loras_roots", [str(root)]),
("base_models_roots", []),
("checkpoints_roots", []),
("embeddings_roots", []),
("other_roots", []),
("extra_loras_roots", []),
("extra_checkpoints_roots", []),
("extra_unet_roots", []),
("extra_embeddings_roots", []),
):
monkeypatch.setattr(config, attr, value, raising=False)
return root
@pytest.fixture
def sidecar_root(tmp_path: Path) -> Path:
"""Configured centralized root (mode-independent for migration)."""
root = tmp_path / "sidecars"
get_settings_manager().set("sidecar_storage_path", str(root))
return root
def _set_mode(mode: str) -> None:
get_settings_manager().set("sidecar_storage_mode", mode)
def _mirror_dir(sidecar_root: Path, *rel: str) -> Path:
"""Expected mirror directory for a library-relative path."""
library = get_settings_manager().get_active_library_name()
return sidecar_root.joinpath(library, "loras", *rel)
def _write_model(directory: Path, stem: str) -> Path:
directory.mkdir(parents=True, exist_ok=True)
model = directory / f"{stem}.safetensors"
model.write_bytes(b"weights")
return model
def _write_sidecar(directory: Path, stem: str, model: Path, *, preview_ext: str | None = ".preview.webp") -> Path:
directory.mkdir(parents=True, exist_ok=True)
payload: Dict[str, Any] = {
"file_name": stem,
"file_path": _normalize(model),
}
if preview_ext:
payload["preview_url"] = _normalize(directory / f"{stem}{preview_ext}")
sidecar = directory / f"{stem}.metadata.json"
sidecar.write_text(json.dumps(payload), encoding="utf-8")
return sidecar
class _FakeCache:
def __init__(self, raw_data: List[Dict[str, Any]]) -> None:
self.raw_data = raw_data
class _FakeScanner:
def __init__(self, raw_data: List[Dict[str, Any]]) -> None:
self._cache = _FakeCache(raw_data)
async def get_cached_data(self) -> _FakeCache:
return self._cache
def _make_use_case(model_paths: List[str]) -> SidecarMigrationUseCase:
scanner = _FakeScanner([{"file_path": path} for path in model_paths])
async def scanner_factory() -> _FakeScanner:
return scanner
return SidecarMigrationUseCase(
scanner_factories=(("lora", scanner_factory),),
settings_service=get_settings_manager(),
)
class _ProgressRecorder:
def __init__(self) -> None:
self.payloads: List[Dict[str, Any]] = []
async def on_progress(self, payload: Dict[str, Any]) -> None:
self.payloads.append(payload)
@pytest.mark.asyncio
async def test_migrate_to_centralized_moves_sidecar_and_previews(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
model = _write_model(library_root / "sub", "model")
sidecar = _write_sidecar(library_root / "sub", "model", model)
preview = library_root / "sub" / "model.preview.webp"
preview.write_bytes(b"preview")
extra_preview = library_root / "sub" / "model.png"
extra_preview.write_bytes(b"extra")
recorder = _ProgressRecorder()
use_case = _make_use_case([str(model)])
summary = await use_case.migrate_to_centralized(recorder, force=True)
assert summary["success"] is True
assert summary["direction"] == "to_centralized"
assert summary["moved"] == 3
assert summary["models_moved"] == 1
assert summary["skipped"] == 0
assert summary["conflicts"] == 0
assert summary["errors"] == []
mirror = _mirror_dir(sidecar_root, "sub")
assert not sidecar.exists()
assert not preview.exists()
assert not extra_preview.exists()
moved_sidecar = mirror / "model.metadata.json"
assert moved_sidecar.exists()
assert (mirror / "model.preview.webp").exists()
assert (mirror / "model.png").exists()
# Model files never move.
assert model.exists()
metadata = json.loads(moved_sidecar.read_text(encoding="utf-8"))
assert metadata["file_path"] == _normalize(model)
assert metadata["file_name"] == "model"
# Recorded extension wins when rewriting preview_url.
assert metadata["preview_url"] == _normalize(mirror / "model.preview.webp")
statuses = [payload["status"] for payload in recorder.payloads]
assert statuses[0] == "started"
assert statuses[-1] == "completed"
assert all(p["type"] == "sidecar_migration_progress" for p in recorder.payloads)
@pytest.mark.asyncio
async def test_migrate_to_alongside_reverses_layout(
library_root: Path, sidecar_root: Path
):
_set_mode("alongside")
model = _write_model(library_root / "sub", "model")
mirror = _mirror_dir(sidecar_root, "sub")
sidecar = _write_sidecar(mirror, "model", model)
preview = mirror / "model.preview.webp"
preview.write_bytes(b"preview")
use_case = _make_use_case([str(model)])
summary = await use_case.migrate_to_alongside(force=True)
assert summary["success"] is True
assert summary["moved"] == 2
assert not sidecar.exists()
assert not preview.exists()
moved_sidecar = library_root / "sub" / "model.metadata.json"
assert moved_sidecar.exists()
assert (library_root / "sub" / "model.preview.webp").exists()
metadata = json.loads(moved_sidecar.read_text(encoding="utf-8"))
assert metadata["file_path"] == _normalize(model)
assert metadata["preview_url"] == _normalize(
library_root / "sub" / "model.preview.webp"
)
@pytest.mark.asyncio
async def test_migrate_conflict_keeps_newer_file(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
mirror = _mirror_dir(sidecar_root)
# Model A: destination (mirror) sidecar is newer -> destination wins.
model_a = _write_model(library_root, "model_a")
src_a = _write_sidecar(library_root, "model_a", model_a, preview_ext=None)
dst_a = _write_sidecar(mirror, "model_a", model_a, preview_ext=None)
os.utime(src_a, (1000, 1000))
os.utime(dst_a, (2000, 2000))
# Model B: source (alongside) sidecar is newer -> source replaces.
model_b = _write_model(library_root, "model_b")
src_b = _write_sidecar(library_root, "model_b", model_b, preview_ext=None)
dst_b = _write_sidecar(mirror, "model_b", model_b, preview_ext=None)
(mirror / "model_b.metadata.json").write_text(
json.dumps({"stale": True}), encoding="utf-8"
)
os.utime(src_b, (3000, 3000))
os.utime(dst_b, (2000, 2000))
use_case = _make_use_case([str(model_a), str(model_b)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["conflicts"] == 2
assert summary["moved"] == 1
# A: destination kept, source deleted, content untouched.
assert not src_a.exists()
metadata_a = json.loads(dst_a.read_text(encoding="utf-8"))
assert metadata_a["file_name"] == "model_a"
# B: newer source replaced the stale destination.
assert not src_b.exists()
metadata_b = json.loads(dst_b.read_text(encoding="utf-8"))
assert metadata_b.get("stale") is None
assert metadata_b["file_name"] == "model_b"
@pytest.mark.asyncio
async def test_migrate_missing_model_file_is_skipped(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
missing_model = library_root / "ghost.safetensors"
sidecar = _write_sidecar(library_root, "ghost", missing_model, preview_ext=None)
use_case = _make_use_case([str(missing_model)])
summary = await use_case.migrate_to_centralized(force=True)
assert summary["success"] is True
assert summary["skipped"] == 1
assert summary["moved"] == 0
# Sidecar stays put when the model file is gone.
assert sidecar.exists()
@pytest.mark.asyncio
async def test_migrate_empty_library_is_noop(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
recorder = _ProgressRecorder()
use_case = _make_use_case([])
summary = await use_case.migrate_to_centralized(recorder, force=True)
assert summary["success"] is True
assert summary["models_total"] == 0
assert summary["moved"] == 0
statuses = [payload["status"] for payload in recorder.payloads]
assert statuses == ["started", "completed"]
@pytest.mark.asyncio
async def test_migrate_to_centralized_refuses_when_already_centralized(
library_root: Path, sidecar_root: Path
):
_set_mode("centralized")
use_case = _make_use_case([])
summary = await use_case.migrate_to_centralized()
assert summary["success"] is False
assert "already centralized" in summary["error"]
assert summary["moved"] == 0
@pytest.mark.asyncio
async def test_migrate_to_alongside_refuses_when_already_alongside(
library_root: Path, sidecar_root: Path
):
_set_mode("alongside")
use_case = _make_use_case([])
summary = await use_case.migrate_to_alongside()
assert summary["success"] is False
assert "already alongside" in summary["error"]
assert summary["moved"] == 0