mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-27 05:54:08 -03:00
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:
@@ -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
|
||||
Reference in New Issue
Block a user