mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-26 21:44:09 -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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user