feat(server): W5-A file manager module (/files)

Roots/list/stat with inline business status, path guard (realpath within
registered roots, symlink escape, offline root 503), mkdir/rename syncing
asset_location and path_binding revisions, two-phase move/copy/organize
(planId + source facts), trash with reference checks and purgeTrash task,
identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:02:20 +08:00
1 parent 09f6e3a3e5
commit 29cd10b27e
20 files changed
+4380 -1

No files matched your search

@@ -0,0 +1,143 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { ProbeResult } from "@umt/adapters";
import type {
FileProbeItem,
FilesProbeRequest,
FilesProbeResult,
FilesScrapeRequest,
FilesScrapeResult,
} from "@umt/contracts";
import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName, mediaSummary } from "./list.ts";
// probe:ffprobe 摘要;已登记资产写回 media_asset.media_info.probe,未登记只缓存 24h(DATA_DIR/cache/probe)。
// scrape:只对已登记作品,等价 POST /media-entities/{id}/refresh(入队 refreshMetadata,字段锁优先、不动路径);
// NFO/图片投影 W5 实现(TODO),请求时返回 not_implemented。
const CACHE_TTL_MS = 24 * 3600_000;
export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise<FilesProbeResult> {
const items: FileProbeItem[] = [];
const cacheDir = join(resolve(d.options.dataDir), "cache", "probe");
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
const base = { root: it.root as FileProbeItem["root"], path: r.rel };
if (!r.lst?.isFile() && !r.isSymlink) {
items.push({
...base,
status: "unknown",
reason: "not_a_file",
summary: null,
container: null,
subtitleTracks: 0,
savedToAsset: false,
cached: false,
});
continue;
}
if (kindOfName(r.abs) !== "video") {
items.push({
...base,
status: "unknown",
reason: "not_video",
summary: null,
container: null,
subtitleTracks: 0,
savedToAsset: false,
cached: false,
});
continue;
}
const st = await stat(r.abs);
const key = createHash("sha1").update(`${r.abs}|${st.size}|${st.mtimeMs}`).digest("hex");
const cacheFile = join(cacheDir, `${key}.json`);
let result: ProbeResult | null = null;
let cached = false;
try {
const c = JSON.parse(await readFile(cacheFile, "utf8")) as { at: number; result: ProbeResult };
if (Date.now() - c.at < CACHE_TTL_MS) {
result = c.result;
cached = true;
}
} catch {
// 无缓存
}
result ??= await d.ao.ports.probe(r.abs);
let saved = false;
if (result.status === "ok" && r.def.libraryRootId) {
const upd = await sql<{ id: string }>`
update media_asset a set media_info = a.media_info || jsonb_build_object('probe', ${jsonb(result)}::jsonb)
from asset_location l
where l.media_asset_id = a.id and l.library_root_id = ${r.def.libraryRootId}
and l.relative_path = ${r.rel} and l.status <> 'removed'
returning a.id`.execute(d.ctx.db);
saved = upd.rows.length > 0;
}
if (result.status === "ok" && !saved && !cached) {
await mkdir(cacheDir, { recursive: true }).catch(() => undefined);
const tmp = `${cacheFile}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify({ at: Date.now(), result }))
.then(() => Deno.rename(tmp, cacheFile))
.catch(() => undefined);
}
items.push({
...base,
status: result.status,
reason: result.status === "ok" ? null : result.reason,
summary: result.status === "ok" ? mediaSummary({ probe: result }) : null,
container: result.status === "ok" ? (result.container ?? null) : null,
subtitleTracks: result.status === "ok" ? result.subtitles.length : 0,
savedToAsset: saved,
cached,
});
}
return { items };
}
export async function scrape(
d: FmDeps,
principal: Principal | undefined,
body: FilesScrapeRequest,
): Promise<FilesScrapeResult> {
const db = d.ctx.db;
const e = await db
.selectFrom("media_entity")
.select(["id", "status", "field_locks"])
.where("id", "=", body.mediaEntityId)
.executeTakeFirst();
if (!e) throw new FilesError("not_found", "作品不存在");
const registered = await db
.selectFrom("media_asset")
.select(["id"])
.where("media_entity_id", "=", e.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!registered) throw new FilesError("bad_request", "作品还没有已登记资产;未登记文件请先识别并整理入库,再刮削");
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: e.id },
{ idempotencyKey: `refreshMetadata:files:${body.idempotencyKey}`, traceId: randomUUID() },
);
const overrides = await db
.selectFrom("field_override")
.select(["field"])
.where("media_entity_id", "=", e.id)
.where("locked", "=", true)
.execute();
const lockedFields = [...new Set([...(e.field_locks ?? []), ...overrides.map((o) => o.field)])].sort();
// TODO(W5):writeNfo/images → 把最终字段投影成 NFO/海报写到绑定目录(临时文件 + 原子提交;非本系统 NFO 不覆盖)
const result: FilesScrapeResult = {
mediaEntityId: e.id,
taskId: runId,
lockedFields,
nfo: body.writeNfo ? "not_implemented" : "not_requested",
images: body.images ? "not_implemented" : "not_requested",
};
await audit(d, principal, "scrape", { ...result });
return result;
}