From 77fc6ffdcf74ee2b91195566132e06ec9f07aede Mon Sep 17 00:00:00 2001 From: Ewald <129623145+MajoorWaldi@users.noreply.github.com> Date: Fri, 13 Feb 2026 17:39:42 +0100 Subject: [PATCH] feat: Enhance asset management with improved rename handling and foreign key updates --- js/api/client.js | 24 ++++- js/api/endpoints.js | 16 +++- js/app/i18n.js | 11 +++ js/app/settings.js | 59 +++++++++--- js/entry.js | 20 +++- js/features/grid/GridView.js | 1 + mjr_am_backend/features/index/service.py | 13 ++- mjr_am_backend/features/metadata/service.py | 7 +- mjr_am_backend/routes/handlers/assets.py | 91 ++++++++++++++---- tests/assets/test_assets_rename_fk.py | 95 +++++++++++++++++++ .../test_index_service_geninfo_self_heal.py | 55 +++++++++++ 11 files changed, 344 insertions(+), 48 deletions(-) create mode 100644 tests/assets/test_assets_rename_fk.py diff --git a/js/api/client.js b/js/api/client.js index b96d4d79..98d07066 100644 --- a/js/api/client.js +++ b/js/api/client.js @@ -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) { diff --git a/js/api/endpoints.js b/js/api/endpoints.js index 4d382595..d9887169 100644 --- a/js/api/endpoints.js +++ b/js/api/endpoints.js @@ -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; } diff --git a/js/app/i18n.js b/js/app/i18n.js index a393d220..6a0264d5 100644 --- a/js/app/i18n.js +++ b/js/app/i18n.js @@ -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)", diff --git a/js/app/settings.js b/js/app/settings.js index 877fb881..fdf8b9ab 100644 --- a/js/app/settings.js +++ b/js/app/settings.js @@ -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"); } diff --git a/js/entry.js b/js/entry.js index 4d0d5347..518ea9f8 100644 --- a/js/entry.js +++ b/js/entry.js @@ -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"); diff --git a/js/features/grid/GridView.js b/js/features/grid/GridView.js index a0757652..57ed24b7 100644 --- a/js/features/grid/GridView.js +++ b/js/features/grid/GridView.js @@ -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 { diff --git a/mjr_am_backend/features/index/service.py b/mjr_am_backend/features/index/service.py index 59b640e5..59c8939a 100644 --- a/mjr_am_backend/features/index/service.py +++ b/mjr_am_backend/features/index/service.py @@ -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)) diff --git a/mjr_am_backend/features/metadata/service.py b/mjr_am_backend/features/metadata/service.py index 2e4bbdc6..ccf9736a 100644 --- a/mjr_am_backend/features/metadata/service.py +++ b/mjr_am_backend/features/metadata/service.py @@ -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 diff --git a/mjr_am_backend/routes/handlers/assets.py b/mjr_am_backend/routes/handlers/assets.py index db9f4e5c..0ab89a96 100644 --- a/mjr_am_backend/routes/handlers/assets.py +++ b/mjr_am_backend/routes/handlers/assets.py @@ -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() diff --git a/tests/assets/test_assets_rename_fk.py b/tests/assets/test_assets_rename_fk.py new file mode 100644 index 00000000..0f4e42fa --- /dev/null +++ b/tests/assets/test_assets_rename_fk.py @@ -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() diff --git a/tests/database/test_index_service_geninfo_self_heal.py b/tests/database/test_index_service_geninfo_self_heal.py index e161fc1f..2f1d355e 100644 --- a/tests/database/test_index_service_geninfo_self_heal.py +++ b/tests/database/test_index_service_geninfo_self_heal.py @@ -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 +