A model root that moved earlier is mirrored under a pinned name derived
from its old path. If anything resolved against the new sidecar path before
the relocation ran, the destination got a map naming the mirror after the
*current* path. migrate_root's keep-newer transfer then dropped the source
map, so the moved metadata stayed orphaned under the pinned component while
reads followed the new name and rebuilt defaults — losing favorites, notes
and tags a second time.
The identity map is now relocated by relocate_root_map() instead of the
generic transfer: entries recorded under the old sidecar root win for the
roots they describe, destination-only entries are preserved, and the cache
is dropped so the next resolution reloads the merged map. A merge that
cannot be written is reported as a migration error rather than silently
stranding the moved metadata.
Reported by the Codex review on #1131.
Centralized sidecars were addressed by a hash of the model root's absolute
path, so moving or renaming a root produced a new mirror directory. The
scanner then found no sidecar there, rebuilt default metadata, and silently
lost favorites, notes, tags and usage tips for every model under that root,
leaving the old metadata orphaned on disk.
Mirrors are now addressed by a root identity pinned in
<sidecar_root>/.lm-sidecar-roots.json. The identity starts as the existing
deterministic <basename>-<path digest> -- so pre-existing mirrors keep
resolving even if the map is lost, and a relocated sidecar root keeps its
names -- and is re-anchored to the root's new path when it moves, matched by
basename and recorded sample directories. Ambiguous matches are never
guessed: the mirror is left untouched and reported.
Also:
- drop the <library> path segment (single-library direction); a legacy
library prefix is only recognised while adopting an existing mirror, which
also keeps the unreleased centralized layout usable
- surface stranded mirrors in Doctor and in the log instead of silently
rebuilding sidecars
- keep the default alongside mode untouched: the identity map is loaded and
reconciled lazily, only while centralized storage is in use
After migrating to centralized sidecar storage users had no indication
where their files went, and portable-mode installs silently placed the
sidecar root inside the plugin folder where a reinstall or git clean
would delete it.
- Migration now also covers models excluded from the library view and
returns the resolved sidecar root in its result payload
- get_settings exposes the resolved sidecar root, whether it is the
default, and whether it lives inside the installation folder
- New POST /api/lm/sidecars/open-location endpoint opens (or copies)
the sidecar storage folder
- Settings UI always shows the effective storage path with an
open-folder button, and warns when the root is inside the
installation folder (portable-mode hazard)
- Migration confirmation shows the destination; on completion a result
dialog summarizes moved/skipped/conflict counts with the storage
location and an open-folder action
- Ignore /sidecars/ at the repository root so portable-mode sidecars
are never committed
Refs #1045
Codex review on #1124:
- P1: mirror layout root component is now <basename>-<roothash>
(sha256 of the normalized root path), so two roots sharing a basename
no longer map to the same mirror directory and overwrite each other's
sidecars
- P1: changing sidecar_storage_path while centralized no longer strands
assets in the old root — new relocate_root migration direction moves
the whole mirror tree, rewrites preview_url prefixes inside sidecars,
reconciles scanner caches, and prunes the emptied old tree; the
settings UI detects the path change and offers the relocation
- P2: migration enumerates the same preview candidates as
find_preview_file — case-insensitive variants (model.WEBP) and the
legacy .example.0.jpeg suffix — instead of exact lowercase
PREVIEW_EXTENSIONS only
- P2: _rollback_model_staging restores staged files with the
EXDEV-tolerant mover, so a failed undoable-delete staging no longer
strands a cross-filesystem centralized sidecar copy
Tests: same-basename root injectivity, mixed-case/example preview
migration, relocate_root happy path + guards + route 400, frontend
relocation prompt flow. Verified end-to-end in a sandboxed standalone
server: uppercase/legacy previews migrate, root relocation moves the
tree and the list API serves the new locations immediately without a
rescan.
Sandbox E2E showed that after a migration the list API kept serving
pre-migration preview_url values; the first request to a stale URL made
the preview route's stale-URL cleanup wipe the reference from the cache
entirely, recoverable only by a full rebuild rescan.
The use case now records each migrated model's final preview location
(from the destination directory, covering conflict-keep cases), updates
the owning scanner's cache entries via ModelCache.update_preview_url,
and persists the cache. Per-scanner reconcile failures are logged and
skipped; per-model migration errors no longer prevent reconciliation of
the healthy models.
Verified end-to-end in a sandboxed standalone server: after
to_centralized and to_alongside migrations the list endpoint immediately
returns the correct preview URLs with no rescan, previews serve with
HTTP 200 in both layouts, and the mirror tree is empty after migrating
back.
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.