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
+84
View File
@@ -23,6 +23,7 @@ from py.routes.handlers.misc_handlers import (
NodeRegistryHandler,
ServiceRegistryAdapter,
SettingsHandler,
SidecarMigrationHandler,
_collect_comfyui_session_logs,
_is_wsl,
_wsl_to_windows_path,
@@ -2557,3 +2558,86 @@ async def test_get_model_versions_status_supported_type_stays_interactive():
"hasBeenDownloaded": False,
}
]
class DummySidecarMigrationUseCase:
def __init__(self, result):
self.result = result
self.calls = []
async def execute_with_error_handling(self, *, direction, progress_cb=None, force=False):
self.calls.append({"direction": direction, "force": force})
return self.result
def _sidecar_migration_handler(result):
use_case = DummySidecarMigrationUseCase(result)
handler = SidecarMigrationHandler(
use_case_factory=lambda: use_case,
progress_callback_factory=lambda: None,
)
return handler, use_case
@pytest.mark.asyncio
async def test_sidecar_migration_handler_runs_to_centralized():
result = {"success": True, "direction": "to_centralized", "moved": 3}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "to_centralized", "force": True}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert payload["moved"] == 3
assert use_case.calls == [{"direction": "to_centralized", "force": True}]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_rejects_bad_direction():
handler, use_case = _sidecar_migration_handler({"success": True})
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "sideways"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 400
assert payload["success"] is False
assert use_case.calls == []
@pytest.mark.asyncio
async def test_sidecar_migration_handler_accepts_get_query_params():
result = {"success": True, "direction": "to_alongside", "moved": 0}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest( # pyright: ignore[reportArgumentType]
query={"direction": "to_alongside", "force": "true"},
method="GET",
)
)
payload = _json_payload(response)
assert response.status == 200
assert payload["success"] is True
assert use_case.calls == [{"direction": "to_alongside", "force": True}]
@pytest.mark.asyncio
async def test_sidecar_migration_handler_guard_refusal_is_400():
result = {"success": False, "error": "sidecar storage is already centralized"}
handler, use_case = _sidecar_migration_handler(result)
response = await handler.migrate_sidecars(
FakeRequest(json_data={"direction": "to_centralized"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert response.status == 400
assert payload["success"] is False
assert "already centralized" in payload["error"]
assert use_case.calls == [{"direction": "to_centralized", "force": False}]