feat: Enhance asset management with improved rename handling and foreign key updates
This commit is contained in:
+20
-4
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user