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
+192
View File
@@ -1192,6 +1192,9 @@ export class SettingsManager {
this.updateExampleImagesOpenSettingsVisibility();
// Load sidecar storage settings
this.loadSidecarStorageSettings();
// Load download path templates
this.loadDownloadPathTemplates();
@@ -1280,6 +1283,9 @@ export class SettingsManager {
this.attachPathField('exampleImagesLocalRoot', {
onAfterSelect: () => this.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root'),
});
this.attachPathField('sidecarStoragePath', {
onAfterSelect: () => this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path'),
});
}
loadDownloadBackendSettings() {
@@ -3379,6 +3385,192 @@ export class SettingsManager {
this.updateExampleImagesOpenSettingsVisibility();
}
loadSidecarStorageSettings() {
const currentMode = state.global.settings.sidecar_storage_mode === 'centralized'
? 'centralized'
: 'alongside';
const modeSelect = document.getElementById('sidecarStorageMode');
if (modeSelect) {
modeSelect.value = currentMode;
}
// Baseline used to detect a mode change in handleSidecarStorageModeChange
this._loadedSidecarStorageMode = currentMode;
const pathInput = document.getElementById('sidecarStoragePath');
if (pathInput) {
pathInput.value = state.global.settings.sidecar_storage_path || '';
}
this.updateSidecarStorageVisibility();
}
updateSidecarStorageVisibility() {
const modeSelect = document.getElementById('sidecarStorageMode');
const pathSetting = document.getElementById('sidecarStoragePathSetting');
if (!pathSetting) return;
const mode = modeSelect ? modeSelect.value : state.global.settings.sidecar_storage_mode;
pathSetting.style.display = mode === 'centralized' ? 'block' : 'none';
}
async handleSidecarStorageModeChange() {
const modeSelect = document.getElementById('sidecarStorageMode');
if (!modeSelect) return;
const previousMode = this._loadedSidecarStorageMode || 'alongside';
await this.saveSelectSetting('sidecarStorageMode', 'sidecar_storage_mode');
this.updateSidecarStorageVisibility();
const newMode = modeSelect.value;
this._loadedSidecarStorageMode = newMode;
// Existing sidecars are not moved automatically; offer to migrate them.
if (newMode !== previousMode) {
const direction = newMode === 'centralized' ? 'to_centralized' : 'to_alongside';
const confirmed = await this.confirmSidecarMigration(direction);
if (confirmed) {
await this.migrateSidecars(direction);
} else {
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
}
}
}
// Entry point for the "Migrate Sidecars Now" button: the direction follows
// the currently saved storage mode.
async confirmAndMigrateSidecars() {
const direction = state.global.settings.sidecar_storage_mode === 'centralized'
? 'to_centralized'
: 'to_alongside';
const confirmed = await this.confirmSidecarMigration(direction);
if (confirmed) {
await this.migrateSidecars(direction);
}
}
confirmSidecarMigration(direction) {
const modalElement = document.getElementById('sidecarMigrationConfirmModal');
if (!modalElement) {
return Promise.resolve(false);
}
const isToCentralized = direction === 'to_centralized';
const titleElement = modalElement.querySelector('[data-role="title"]');
if (titleElement) {
titleElement.textContent = isToCentralized
? translate('modals.sidecarMigrationConfirm.titleToCentralized', {}, 'Move sidecars to centralized storage?')
: translate('modals.sidecarMigrationConfirm.titleToAlongside', {}, 'Move sidecars back next to model files?');
}
const messageElement = modalElement.querySelector('[data-role="message"]');
if (messageElement) {
messageElement.textContent = isToCentralized
? translate('settings.sidecarStorage.confirmToCentralized', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them into the centralized storage directory now? You can also do this later with the "Migrate Sidecars Now" button.')
: translate('settings.sidecarStorage.confirmToAlongside', {}, 'The storage mode changed, but existing .metadata.json sidecars and preview images are not moved automatically. Move them back next to their model files now? You can also do this later with the "Migrate Sidecars Now" button.');
}
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
if (!confirmButton || !cancelButton) {
return Promise.resolve(false);
}
confirmButton.textContent = translate('modals.sidecarMigrationConfirm.confirmButton', {}, 'Migrate Now');
return new Promise((resolve) => {
let resolved = false;
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
cancelButton.removeEventListener('click', handleCancel);
document.removeEventListener('keydown', handleEscape, true);
};
const finalize = (proceed) => {
if (resolved) {
return;
}
resolved = true;
cleanup();
modalElement.classList.remove('show');
// Keep body.modal-open: the settings modal underneath is still open.
resolve(proceed);
};
const handleConfirm = (event) => {
event.preventDefault();
finalize(true);
};
const handleCancel = (event) => {
event.preventDefault();
finalize(false);
};
// Capture phase + stopPropagation so ESC never reaches the
// settings modal's own ESC handler underneath.
const handleEscape = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
finalize(false);
}
};
confirmButton.addEventListener('click', handleConfirm);
cancelButton.addEventListener('click', handleCancel);
document.addEventListener('keydown', handleEscape, true);
modalElement.classList.add('show');
cancelButton.focus();
});
}
async migrateSidecars(direction) {
const migrateBtn = document.getElementById('migrateSidecarsBtn');
try {
if (migrateBtn) {
migrateBtn.disabled = true;
migrateBtn.textContent = translate('settings.sidecarStorage.migratingButton', {}, 'Migrating...');
}
state.loadingManager?.showSimpleLoading(
translate('settings.sidecarStorage.migrating', {}, 'Migrating sidecars...')
);
const response = await fetch('/api/lm/sidecars/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// The new mode is already saved by the time migration runs,
// so the backend guard requires force=true to confirm the
// "switch first, then migrate" flow.
body: JSON.stringify({ direction, force: true }),
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || 'Migration failed');
}
state.loadingManager?.hide();
showToast('settings.sidecarStorage.migrateSuccess', {}, 'success');
// Reload so cards pick up metadata/preview paths from the new location
resetAndReload(true);
} catch (error) {
console.error('Error migrating sidecars:', error);
state.loadingManager?.hide();
showToast('settings.sidecarStorage.migrateFailed', { message: error.message }, 'error');
} finally {
if (migrateBtn) {
migrateBtn.disabled = false;
migrateBtn.textContent = translate('settings.sidecarStorage.migrateButton', {}, 'Migrate Sidecars Now');
}
}
}
async loadMetadataArchiveSettings() {
try {
// Load current settings from state
+2
View File
@@ -62,6 +62,8 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
download_skip_base_models: [],
backup_auto_enabled: true,
backup_retention_count: 5,
sidecar_storage_mode: 'alongside',
sidecar_storage_path: '',
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,