feat: Enhance asset management with improved rename handling and foreign key updates

This commit is contained in:
Ewald
2026-02-13 17:39:42 +01:00
parent b700149b2d
commit 77fc6ffdcf
11 changed files with 344 additions and 48 deletions
+20 -4
View File
@@ -476,9 +476,9 @@ export async function getOutputDirectorySetting() {
return get(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY);
}
export async function setOutputDirectorySetting(outputDirectory) {
export async function setOutputDirectorySetting(outputDirectory, options = {}) {
const value = String(outputDirectory ?? "").trim();
return post(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY, { output_directory: value });
return post(ENDPOINTS.SETTINGS_OUTPUT_DIRECTORY, { output_directory: value }, options);
}
export async function getSecuritySettings() {
@@ -624,11 +624,27 @@ export async function mergeDuplicateTags(keepAssetId, mergeAssetIds = []) {
}
export async function deleteAsset(assetId) {
return post("/mjr/am/asset/delete", { asset_id: normalizeAssetId(assetId) });
const id = normalizeAssetId(assetId);
const res = await post("/mjr/am/asset/delete", { asset_id: id });
if (res?.ok) _emitAssetsDeleted([id]);
return res;
}
export async function deleteAssets(assetIds) {
return post("/mjr/am/assets/delete", { ids: assetIds });
const ids = Array.isArray(assetIds) ? assetIds.map((x) => normalizeAssetId(x)).filter(Boolean) : [];
const res = await post("/mjr/am/assets/delete", { ids: assetIds });
if (res?.ok) _emitAssetsDeleted(ids);
return res;
}
function _emitAssetsDeleted(ids) {
try {
const normalized = (Array.isArray(ids) ? ids : [ids])
.map((x) => String(x || "").trim())
.filter(Boolean);
if (!normalized.length) return;
window.dispatchEvent(new CustomEvent("mjr:assets-deleted", { detail: { ids: normalized } }));
} catch {}
}
export async function renameAsset(assetId, newName) {
+14 -2
View File
@@ -280,6 +280,15 @@ export function buildAssetViewURL(asset) {
}
if (!type) type = "output";
const hasNativeBucket = rawPath.includes("/output/") || rawPath.includes("/input/");
const subfolderLooksAbsolute =
/^[a-zA-Z]:\//.test(subfolder) || subfolder.startsWith("/");
// Fallback: non-native output/input roots (or broken absolute subfolder values)
// cannot be served by ComfyUI `/view`, so use backend filepath streaming URL.
if (rawPath && (subfolderLooksAbsolute || (!hasNativeBucket && type !== "custom"))) {
return buildDownloadURL(rawPath, { inline: true });
}
if (type === "custom") {
const rid = String(pickRootId(asset) || "").trim();
if (rid) return buildCustomViewURL(filename, subfolder, rid);
@@ -304,7 +313,10 @@ export function buildSearchURL(params = {}) {
/**
* Build download URL for asset
*/
export function buildDownloadURL(filepath) {
export function buildDownloadURL(filepath, options = {}) {
if (!filepath) return "";
return `${ENDPOINTS.DOWNLOAD}?filepath=${encodeURIComponent(filepath)}`;
const inline = !!options?.inline;
let url = `${ENDPOINTS.DOWNLOAD}?filepath=${encodeURIComponent(filepath)}`;
if (inline) url += "&preview=1";
return url;
}
+11
View File
@@ -383,11 +383,22 @@ const DICTIONARY = {
"status.dbSaveHint": "Create a DB backup snapshot now.",
"status.dbRestoreHint": "Restore selected DB backup and restart indexing.",
"status.dbHealthOk": "DB health: ok",
"status.dbHealthError": "DB health: error",
"status.indexHealthOk": "Index health: ok",
"status.indexHealthPartial": "Index health: partial",
"status.indexHealthEmpty": "Index health: empty",
"status.pending": "Pending",
"status.dbRestoreInProgress": "DB restore in progress",
"status.scanningScope": "Scanning scope: {scope}",
"status.toast.info": "Index status: checking",
"status.toast.success": "Index status: ready",
"status.toast.warning": "Index status: attention needed",
"status.toast.error": "Index status: error",
"toast.dbRestoreStopping": "Stopping running workers",
"toast.dbRestoreResetting": "Unlocking and resetting database",
"toast.dbRestoreReplacing": "Replacing database files",
"toast.dbRestoreRescan": "Restarting scan",
"toast.dbRestoreSuccess": "Database backup restored",
"hotkey.scan": "Scan (S)",
"hotkey.search": "Search (Ctrl+F)",
"hotkey.details": "Toggle details (D)",
+44 -15
View File
@@ -1671,6 +1671,11 @@ export const registerMajoorSettings = (app, onApplied) => {
.catch(() => {});
} catch {}
let _outputDirCommittedValue = String(settings.paths?.outputDirectory || "");
let _outputDirSaveTimer = null;
let _outputDirSaveSeq = 0;
let _outputDirSaveAbort = null;
safeAddSetting({
id: `${SETTINGS_PREFIX}.Paths.OutputDirectory`,
category: cat(t("cat.advanced"), "Paths"),
@@ -1682,27 +1687,50 @@ export const registerMajoorSettings = (app, onApplied) => {
placeholder: "D:\\\\____COMFY_OUTPUTS",
},
onChange: async (value) => {
const previous = String(settings.paths?.outputDirectory || "");
const next = String(value || "").trim();
settings.paths = settings.paths || {};
settings.paths.outputDirectory = next;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
// The Comfy settings text input can fire on each keystroke.
// Debounce + cancel in-flight requests to prevent backend/UI stalls.
try {
const res = await setOutputDirectorySetting(next);
if (!res?.ok) {
throw new Error(res?.error || "Failed to set output directory");
if (_outputDirSaveTimer) {
clearTimeout(_outputDirSaveTimer);
_outputDirSaveTimer = null;
}
const serverValue = String(res?.data?.output_directory || next);
settings.paths.outputDirectory = serverValue;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
} catch (error) {
settings.paths.outputDirectory = previous;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
comfyToast(error?.message || "Failed to set output directory", "error");
}
} catch {}
_outputDirSaveTimer = setTimeout(async () => {
_outputDirSaveTimer = null;
const seq = ++_outputDirSaveSeq;
try {
_outputDirSaveAbort?.abort?.();
} catch {}
_outputDirSaveAbort = typeof AbortController !== "undefined" ? new AbortController() : null;
try {
const res = await setOutputDirectorySetting(
next,
_outputDirSaveAbort ? { signal: _outputDirSaveAbort.signal } : {}
);
if (seq !== _outputDirSaveSeq) return;
if (!res?.ok) {
throw new Error(res?.error || "Failed to set output directory");
}
const serverValue = String(res?.data?.output_directory || next).trim();
settings.paths.outputDirectory = serverValue;
_outputDirCommittedValue = serverValue;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
} catch (error) {
if (seq !== _outputDirSaveSeq) return;
const aborted = String(error?.name || "") === "AbortError" || String(error?.code || "") === "ABORTED";
if (aborted) return;
settings.paths.outputDirectory = _outputDirCommittedValue;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
comfyToast(error?.message || "Failed to set output directory", "error");
}
}, 700);
},
});
@@ -1714,6 +1742,7 @@ export const registerMajoorSettings = (app, onApplied) => {
settings.paths = settings.paths || {};
if (settings.paths.outputDirectory !== serverValue) {
settings.paths.outputDirectory = serverValue;
_outputDirCommittedValue = serverValue;
saveMajoorSettings(settings);
notifyApplied("paths.outputDirectory");
}
+19 -1
View File
@@ -9,7 +9,7 @@ import { ensureStyleLoaded } from "./app/style.js";
import { registerMajoorSettings, startRuntimeStatusDashboard } from "./app/settings.js";
import { getComfyApi, registerSidebarTabCompat } from "./app/comfyApiBridge.js";
import { initDragDrop } from "./features/dnd/DragDrop.js";
import { loadAssets, upsertAsset } from "./features/grid/GridView.js";
import { loadAssets, upsertAsset, removeAssetsFromGrid } from "./features/grid/GridView.js";
import { renderAssetsManager, getActiveGridContainer } from "./features/panel/AssetsManagerPanel.js";
import { extractOutputFiles } from "./utils/extractOutputFiles.js";
import { post } from "./api/client.js";
@@ -136,6 +136,12 @@ app.registerExtension({
}
} catch {}
try {
if (window._mjrAssetsDeletedHandler) {
window.removeEventListener("mjr:assets-deleted", window._mjrAssetsDeletedHandler);
}
} catch {}
// Listen for ComfyUI execution - extract output files and send to backend
api._mjrExecutedHandler = (event) => {
const outputFiles = dedupeFiles(extractOutputFiles(event?.detail?.output));
@@ -256,6 +262,18 @@ app.registerExtension({
};
api.addEventListener("mjr-db-restore-status", api._mjrDbRestoreStatusHandler);
window._mjrAssetsDeletedHandler = (event) => {
try {
const ids = Array.isArray(event?.detail?.ids)
? event.detail.ids.map((x) => String(x || "")).filter(Boolean)
: [];
if (!ids.length) return;
const grid = getActiveGridContainer();
if (grid) removeAssetsFromGrid(grid, ids);
} catch {}
};
window.addEventListener("mjr:assets-deleted", window._mjrAssetsDeletedHandler);
console.log("📂 Majoor [✅] Real-time listener registered");
} else {
console.warn("📂 Majoor [⚠️] API not available, real-time updates disabled");
+1
View File
@@ -437,6 +437,7 @@ export function createGridContainer() {
} catch {}
try {
container._mjrSetSelection = (ids, activeId = "") => setSelectionIds(container, ids, { activeId });
container._mjrRemoveAssets = (assetIds, options = {}) => removeAssetsFromGrid(container, assetIds, options);
container._mjrSyncSelection = (ids) => syncSelectionClasses(container, ids);
container._mjrGetRenderedCards = () => {
try {
+11 -2
View File
@@ -195,6 +195,10 @@ class IndexService:
mtime = None
async with self.db.atransaction(mode="immediate"):
defer_fk = await self.db.aexecute("PRAGMA defer_foreign_keys = ON")
if not defer_fk.ok:
return Result.Err("DB_ERROR", defer_fk.error or "Failed to defer foreign key checks")
if mtime is None:
upd = await self.db.aexecute(
"UPDATE assets SET filepath = ?, filename = ?, subfolder = ?, updated_at = CURRENT_TIMESTAMP WHERE filepath = ?",
@@ -208,14 +212,19 @@ class IndexService:
if not upd.ok:
return Result.Err("DB_ERROR", upd.error or "Failed to update asset filepath")
await self.db.aexecute(
sj = await self.db.aexecute(
"UPDATE scan_journal SET filepath = ?, dir_path = ?, mtime = COALESCE(?, mtime), last_seen = CURRENT_TIMESTAMP WHERE filepath = ?",
(new_fp, subfolder, mtime, old_fp),
)
await self.db.aexecute(
if not sj.ok:
return Result.Err("DB_ERROR", sj.error or "Failed to update scan_journal filepath")
mc = await self.db.aexecute(
"UPDATE metadata_cache SET filepath = ?, last_updated = CURRENT_TIMESTAMP WHERE filepath = ?",
(new_fp, old_fp),
)
if not mc.ok:
return Result.Err("DB_ERROR", mc.error or "Failed to update metadata_cache filepath")
except Exception as exc:
return Result.Err("DB_ERROR", str(exc))
+4 -3
View File
@@ -912,7 +912,8 @@ class MetadataService:
"kind": classify_file(file_path),
"ext": os.path.splitext(file_path)[1].lower()
}
# Try to get birthtime (Unix/Mac)
if hasattr(stat, "st_birthtime"):
info["birthtime"] = stat.st_birthtime
# st_birthtime is not available on all platforms/filesystems.
birthtime = getattr(stat, "st_birthtime", None)
if birthtime is not None:
info["birthtime"] = birthtime
return info
+70 -21
View File
@@ -922,10 +922,8 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
)
)
db_updated = False
# Update database record
try:
# Update assets table
try:
mtime = int(new_path.stat().st_mtime)
except FileNotFoundError:
@@ -937,24 +935,39 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
_safe_error_message(exc, "Failed to stat renamed file"),
)
)
update_res = await svc["db"].aexecute(
"UPDATE assets SET filename = ?, filepath = ?, mtime = ? WHERE id = ?",
(new_name, str(new_path), mtime, asset_id)
)
if not update_res.ok:
return _json_response(update_res)
db_updated = True
# Update related tables - clean old cache entries
await svc["db"].aexecute("DELETE FROM scan_journal WHERE filepath = ?", (str(current_path),))
await svc["db"].aexecute("DELETE FROM metadata_cache WHERE filepath = ?", (str(current_path),))
async with svc["db"].atransaction(mode="immediate"):
defer_fk = await svc["db"].aexecute("PRAGMA defer_foreign_keys = ON")
if not defer_fk.ok:
return _json_response(Result.Err("DB_ERROR", defer_fk.error or "Failed to defer foreign key checks"))
update_res = await svc["db"].aexecute(
"UPDATE assets SET filename = ?, filepath = ?, mtime = ? WHERE id = ?",
(new_name, str(new_path), mtime, asset_id)
)
if not update_res.ok:
return _json_response(Result.Err("DB_ERROR", update_res.error or "Failed to update assets filepath"))
# Keep FK-linked tables in sync with renamed filepath.
sj_res = await svc["db"].aexecute(
"UPDATE scan_journal SET filepath = ? WHERE filepath = ?",
(str(new_path), str(current_resolved)),
)
if not sj_res.ok:
return _json_response(Result.Err("DB_ERROR", sj_res.error or "Failed to update scan_journal filepath"))
mc_res = await svc["db"].aexecute(
"UPDATE metadata_cache SET filepath = ? WHERE filepath = ?",
(str(new_path), str(current_resolved)),
)
if not mc_res.ok:
return _json_response(Result.Err("DB_ERROR", mc_res.error or "Failed to update metadata_cache filepath"))
except Exception as exc:
if not db_updated:
try:
if new_path.exists() and not current_resolved.exists():
new_path.rename(current_resolved)
except Exception as rollback_exc:
logger.error("Failed to rollback rename for asset %s: %s", asset_id, rollback_exc)
try:
if new_path.exists() and not current_resolved.exists():
new_path.rename(current_resolved)
except Exception as rollback_exc:
logger.error("Failed to rollback rename for asset %s: %s", asset_id, rollback_exc)
return _json_response(
Result.Err(
"DB_ERROR",
@@ -966,7 +979,7 @@ def register_asset_routes(routes: web.RouteTableDef) -> None:
"renamed": 1,
"old_name": current_filename,
"new_name": new_name,
"old_path": str(current_path),
"old_path": str(current_resolved),
"new_path": str(new_path)
}))
@@ -1138,8 +1151,26 @@ async def download_asset(request: web.Request) -> web.StreamResponse:
Query param: filepath (absolute path, must be within allowed roots)
"""
# Optional preview mode for in-app media display (viewer/grid fallback URLs).
# Preview mode can trigger many concurrent thumbnail requests from the grid, so it
# needs a much higher bucket than user-triggered downloads.
preview = str(request.query.get("preview", "")).strip().lower() in ("1", "true", "yes", "on")
# Rate limiting
allowed, retry_after = _check_rate_limit(request, "download_asset", max_requests=30, window_seconds=60)
if preview:
allowed, retry_after = _check_rate_limit(
request,
"download_asset_preview",
max_requests=2000,
window_seconds=60,
)
else:
allowed, retry_after = _check_rate_limit(
request,
"download_asset",
max_requests=30,
window_seconds=60,
)
if not allowed:
return web.Response(status=429, text=f"Rate limit exceeded. Retry after {retry_after}s")
@@ -1179,7 +1210,10 @@ async def download_asset(request: web.Request) -> web.StreamResponse:
response = web.StreamResponse()
response.headers["Content-Type"] = mime_type
response.headers["Content-Disposition"] = f'attachment; filename="{filename}"'
disposition = "inline" if preview else "attachment"
response.headers["Content-Disposition"] = f'{disposition}; filename="{filename}"'
if preview:
response.headers["Cache-Control"] = "private, max-age=60"
try:
await response.prepare(request)
@@ -1189,6 +1223,21 @@ async def download_asset(request: web.Request) -> web.StreamResponse:
if not chunk:
break
await response.write(chunk)
try:
await response.write_eof()
except Exception:
pass
except (ConnectionResetError, BrokenPipeError):
# Client disconnected mid-stream (tab closed/navigation/virtualized preview churn).
logger.debug("Client disconnected while streaming file %s", filepath)
return response
except RuntimeError as e:
if "Connection lost" in str(e):
logger.debug("Client connection lost while streaming file %s", filepath)
return response
logger.error(f"Error streaming file {filepath}: {e}")
# Cannot return a new response after prepare()
return response
except Exception as e:
logger.error(f"Error streaming file {filepath}: {e}")
# Cannot return a new response after prepare()
+95
View File
@@ -0,0 +1,95 @@
import json
from pathlib import Path
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from mjr_am_backend.adapters.db.sqlite import Sqlite
from mjr_am_backend.routes.handlers.assets import register_asset_routes
async def _init_schema(db: Sqlite) -> None:
await db.aexecutescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE assets (
id INTEGER PRIMARY KEY,
filename TEXT NOT NULL,
filepath TEXT NOT NULL UNIQUE,
mtime INTEGER DEFAULT 0
);
CREATE TABLE scan_journal (
filepath TEXT PRIMARY KEY,
state_hash TEXT,
FOREIGN KEY (filepath) REFERENCES assets(filepath) ON DELETE CASCADE
);
CREATE TABLE metadata_cache (
filepath TEXT PRIMARY KEY,
state_hash TEXT,
FOREIGN KEY (filepath) REFERENCES assets(filepath) ON DELETE CASCADE
);
"""
)
@pytest.mark.asyncio
async def test_asset_rename_updates_fk_tables_without_integrity_error(monkeypatch, tmp_path: Path):
monkeypatch.setenv("MAJOOR_ALLOW_RENAME", "1")
db = Sqlite(str(tmp_path / "test.db"))
await _init_schema(db)
old_file = tmp_path / "old_name.png"
old_file.write_bytes(b"x")
old_fp = str(old_file)
new_name = "new_name.png"
new_fp = str(tmp_path / new_name)
await db.aexecute(
"INSERT INTO assets(id, filename, filepath, mtime) VALUES (?, ?, ?, ?)",
(1, old_file.name, old_fp, int(old_file.stat().st_mtime)),
)
await db.aexecute("INSERT INTO scan_journal(filepath, state_hash) VALUES (?, ?)", (old_fp, "s"))
await db.aexecute("INSERT INTO metadata_cache(filepath, state_hash) VALUES (?, ?)", (old_fp, "m"))
import mjr_am_backend.routes.handlers.assets as assets_mod
async def _mock_require_services():
return ({"db": db}, None)
monkeypatch.setattr(assets_mod, "_require_services", _mock_require_services)
monkeypatch.setattr(assets_mod, "_is_path_allowed", lambda *_args, **_kwargs: True)
monkeypatch.setattr(assets_mod, "_is_path_allowed_custom", lambda *_args, **_kwargs: True)
routes = web.RouteTableDef()
register_asset_routes(routes)
app = web.Application()
app.add_routes(routes)
client = TestClient(TestServer(app))
await client.start_server()
try:
resp = await client.post(
"/mjr/am/asset/rename",
data=json.dumps({"asset_id": 1, "new_name": new_name}),
headers={"Content-Type": "application/json", "X-Requested-With": "XMLHttpRequest"},
)
payload = await resp.json()
assert payload["ok"] is True, payload
q_asset = await db.aquery("SELECT filepath, filename FROM assets WHERE id = 1")
assert q_asset.ok and q_asset.data
assert q_asset.data[0]["filepath"] == new_fp
assert q_asset.data[0]["filename"] == new_name
q_sj = await db.aquery("SELECT filepath FROM scan_journal")
assert q_sj.ok and q_sj.data
assert q_sj.data[0]["filepath"] == new_fp
q_mc = await db.aquery("SELECT filepath FROM metadata_cache")
assert q_mc.ok and q_mc.data
assert q_mc.data[0]["filepath"] == new_fp
finally:
await client.close()
await db.aclose()
@@ -85,3 +85,58 @@ async def test_get_asset_does_not_self_heal_incomplete_geninfo(services):
assert gi.get("negative") in (None, {})
assert gi.get("checkpoint") in (None, {})
@pytest.mark.asyncio
async def test_rename_file_updates_fk_linked_tables_without_integrity_error(services, tmp_path):
db = services["db"]
index = services["index"]
old_file = tmp_path / "old_name.png"
new_file = tmp_path / "new_name.png"
old_file.write_bytes(b"png")
new_file.write_bytes(b"png")
old_fp = str(old_file)
new_fp = str(new_file)
res = await db.aexecute(
"""
INSERT INTO assets (filepath, filename, kind, ext, subfolder, source, root_id, size, mtime)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
old_fp,
old_file.name,
"image",
".png",
str(old_file.parent),
"output",
None,
old_file.stat().st_size,
int(old_file.stat().st_mtime),
),
)
assert res.ok, res.error
res = await db.aexecute(
"INSERT INTO scan_journal(filepath, dir_path, state_hash, mtime, size) VALUES (?, ?, ?, ?, ?)",
(old_fp, str(old_file.parent), "state", int(old_file.stat().st_mtime), old_file.stat().st_size),
)
assert res.ok, res.error
res = await db.aexecute(
"INSERT INTO metadata_cache(filepath, state_hash, metadata_hash, metadata_raw) VALUES (?, ?, ?, ?)",
(old_fp, "state", "meta", "{}"),
)
assert res.ok, res.error
rename_res = await index.rename_file(old_fp, new_fp)
assert rename_res.ok, rename_res.error
q_assets = await db.aquery("SELECT filepath FROM assets WHERE filepath = ?", (new_fp,))
assert q_assets.ok and q_assets.data
q_sj = await db.aquery("SELECT filepath FROM scan_journal WHERE filepath = ?", (new_fp,))
assert q_sj.ok and q_sj.data
q_mc = await db.aquery("SELECT filepath FROM metadata_cache WHERE filepath = ?", (new_fp,))
assert q_mc.ok and q_mc.data