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,322 @@
import { createHash } from "node:crypto";
import { lstat, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, sep } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "@umt/adapters";
import type { ApiErrorCode, FileRootId, FilesErrorReason } from "@umt/contracts";
import type { LibraryRootRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import type { AoDeps, Exec } from "../acquisition-organize/services/common.ts";
import { loadInstances } from "../acquisition-organize/services/common.ts";
import type { ServerContext } from "../types.ts";
// 文件管理模块共用:依赖、错误、根定义、路径守卫、计划/幂等/审计小工具。
export interface FmOptions {
/** 单次列目录最多读取的项数(超出 truncated) */
listMaxEntries: number;
/** 目录摘要(视频数/已登记数)每个目录最多数的项数 */
dirSummaryMax: number;
trashRetentionDays: number;
/** 回收站跨卷(复制到 DATA_DIR/trash)允许的最大字节 */
trashCrossMaxBytes: number;
/** dryRun 计划有效期 */
planTtlMs: number;
/** identify 目录展开的视频上限 */
identifyMaxVideos: number;
/** 是否允许永久删除(仍仅 admin 会话) */
allowPermanentDelete: boolean;
/** 根健康探测超时 */
healthTimeoutMs: number;
/** 回收站过期清理周期(0 = 不自动跑) */
purgeIntervalMs: number;
backgroundLoops: boolean;
/** 数据目录(跨卷回收站 DATA_DIR/trash、probe 缓存 DATA_DIR/cache/probe) */
dataDir: string;
}
export interface FmDeps {
ctx: ServerContext;
/** acquisition-organize 的服务依赖(planImport / 守卫 / 授权 / 端口复用) */
ao: AoDeps;
options: FmOptions;
}
const envNum = (k: string, dflt: number) => {
const v = Number(Deno.env.get(k));
return Number.isFinite(v) && v > 0 ? v : dflt;
};
export function defaultOptions(ctx: ServerContext): FmOptions {
return {
listMaxEntries: envNum("FILES_LIST_MAX_ENTRIES", 20_000),
dirSummaryMax: 500,
trashRetentionDays: envNum("FILES_TRASH_RETENTION_DAYS", 7),
trashCrossMaxBytes: envNum("FILES_TRASH_CROSS_MAX_BYTES", 2 * 1024 ** 3),
planTtlMs: envNum("FILES_PLAN_TTL_SEC", 1800) * 1000,
identifyMaxVideos: 200,
allowPermanentDelete: Deno.env.get("FILES_ALLOW_PERMANENT_DELETE") === "true",
healthTimeoutMs: 2000,
purgeIntervalMs: 24 * 3600_000,
backgroundLoops: true,
dataDir: ctx.config?.DATA_DIR ?? "./data",
};
}
// ── 错误 ──
const REASON_STATUS: Record<FilesErrorReason | "bad_request" | "forbidden", { status: number; code: ApiErrorCode }> = {
root_unavailable: { status: 503, code: "upstream_unavailable" },
path_escape: { status: 400, code: "bad_request" },
bad_request: { status: 400, code: "bad_request" },
not_found: { status: 404, code: "not_found" },
exists: { status: 409, code: "conflict" },
not_writable: { status: 403, code: "forbidden" },
forbidden: { status: 403, code: "forbidden" },
plan_stale: { status: 409, code: "conflict" },
download_active: { status: 409, code: "conflict" },
needs_retire: { status: 409, code: "conflict" },
needs_unbind: { status: 409, code: "conflict" },
permanent_disabled: { status: 403, code: "forbidden" },
approval_required: { status: 403, code: "forbidden" },
};
/** 业务错误:路由统一转成信封,message 以 `<reason>: ` 开头便于前端分支 */
export class FilesError extends Error {
override name = "FilesError";
constructor(
readonly reason: keyof typeof REASON_STATUS,
message: string,
) {
super(message);
}
get status() {
return REASON_STATUS[this.reason].status;
}
get code() {
return REASON_STATUS[this.reason].code;
}
}
const OFFLINE_CODES = new Set(["EIO", "ENOTCONN", "ESTALE", "ETIMEDOUT", "EHOSTDOWN", "ENODEV"]);
export const errnoOf = (e: unknown): string | undefined => (e as { code?: string } | null)?.code;
export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? "");
// ── 根 ──
export interface RootDef {
id: FileRootId;
kind: "library" | "download";
name: string;
hostPath: string;
mediaKind: LibraryRootRow["media_kind"] | null;
/** 库根 managed;下载根 true(可整理散文件,但不能作为移动/复制目标) */
modifiable: boolean;
libraryRootId: string | null;
instanceId: string | null;
sentinel: string | null;
}
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
/** 已登记的根:启用的库根 + 下载器路径映射本机前缀(无映射 = 同机,取保存路径);"/" 永不出现 */
export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
const out: RootDef[] = [];
const libs = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).orderBy("name").execute();
for (const r of libs) {
const hostPath = norm(r.local_path);
if (hostPath === "/" || !hostPath.startsWith("/")) continue;
out.push({
id: `lib:${r.id}`,
kind: "library",
name: r.name,
hostPath,
mediaKind: r.media_kind,
modifiable: r.management_mode === "managed",
libraryRootId: r.id,
instanceId: null,
sentinel: r.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
});
}
for (const t of await loadInstances(exec, { enabledOnly: false })) {
const paths = t.mappings.length > 0 ? t.mappings.map((m) => m.hostPath) : t.localSavePath ? [t.localSavePath] : [];
paths.forEach((p, i) => {
const hostPath = norm(p);
if (hostPath === "/" || !hostPath.startsWith("/")) return;
out.push({
id: `dl:${t.inst.id}:${i}`,
kind: "download",
name: paths.length > 1 ? `${t.inst.name} #${i + 1}` : t.inst.name,
hostPath,
mediaKind: null,
modifiable: true,
libraryRootId: null,
instanceId: t.inst.id,
sentinel: null,
});
});
}
return out;
}
export async function rootDef(exec: Exec, id: string): Promise<RootDef> {
const r = (await loadRootDefs(exec)).find((x) => x.id === id);
if (!r) throw new FilesError("not_found", `根不存在或未启用: ${id}`);
return r;
}
export interface RootHealthView {
health: "healthy" | "offline" | "unknown";
reason: string | null;
checkedAt: Date;
/** 在线时的 realpath */
real: string | null;
}
const withTimeout = <T>(p: Promise<T>, ms: number, fallback: T): Promise<T> =>
Promise.race([p, new Promise<T>((res) => setTimeout(() => res(fallback), ms).unref?.())]);
/** 实时探测:库根按哨兵(checkRoot),下载根按目录可 stat;≤ timeout,超时 = unknown(按不可用处理) */
export async function probeRoot(def: RootDef, timeoutMs: number): Promise<RootHealthView> {
const checkedAt = new Date();
const run = async (): Promise<RootHealthView> => {
if (def.sentinel) {
const h = await checkRoot(def.hostPath, def.sentinel);
if (h.kind === "offline") return { health: "offline", reason: h.reason, checkedAt, real: null };
} else {
try {
const st = await stat(def.hostPath);
if (!st.isDirectory()) return { health: "offline", reason: "not_directory", checkedAt, real: null };
} catch (e) {
return {
health: "offline",
reason: errnoOf(e) === "ENOENT" ? "root_missing" : (errnoOf(e) ?? "error"),
checkedAt,
real: null,
};
}
}
try {
return { health: "healthy", reason: null, checkedAt, real: await realpath(def.hostPath) };
} catch (e) {
return { health: "offline", reason: errnoOf(e) ?? "error", checkedAt, real: null };
}
};
return withTimeout(run(), timeoutMs, { health: "unknown", reason: "timeout", checkedAt, real: null });
}
/** 在线才返回 realpath;离线/超时 → 503 root_unavailable(失败 ≠ 空目录) */
export async function onlineRoot(d: FmDeps, def: RootDef): Promise<string> {
const h = await probeRoot(def, d.options.healthTimeoutMs);
if (h.health !== "healthy" || !h.real)
throw new FilesError("root_unavailable", `根 ${def.name} 不可用(${h.reason ?? h.health}),不显示为空目录`);
return h.real;
}
// ── 路径守卫 ──
export const within = (p: string, root: string) =>
p === root || p.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
export const relOf = (rootReal: string, abs: string) => (abs === rootReal ? "" : abs.slice(rootReal.length + 1));
export interface Resolved {
def: RootDef;
rootReal: string;
/** 根内相对路径(规范化) */
rel: string;
/** 父目录 realpath + 名字(不跟随最后一段的符号链接) */
abs: string;
/** 目标存在时的 lstat */
lst: Awaited<ReturnType<typeof lstat>> | null;
isSymlink: boolean;
}
function checkRel(rel: string) {
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径: ${rel}`);
const segs = rel.split("/").filter((s) => s.length > 0);
if (segs.some((s) => s === ".." || s === ".")) throw new FilesError("path_escape", `路径不允许包含 . 或 ..: ${rel}`);
return segs.join("/");
}
/**
* 把 {root, path} 解析成本机路径并复核:父目录 realpath 必须在根 realpath 之下;
* 最后一段是符号链接时其目标也必须在根内(否则 path_escape,禁止任何操作)。
*/
export async function resolveIn(
d: FmDeps,
rootId: string,
relIn: string,
opts: { mustExist?: boolean; rootReal?: string; def?: RootDef } = {},
): Promise<Resolved> {
const def = opts.def ?? (await rootDef(d.ctx.db, rootId));
const rootReal = opts.rootReal ?? (await onlineRoot(d, def));
const rel = checkRel(relIn);
if (rel === "") {
const lst = await lstat(rootReal).catch(() => null);
return { def, rootReal, rel, abs: rootReal, lst, isSymlink: false };
}
const joined = join(rootReal, rel);
let parentReal: string;
try {
parentReal = await realpath(dirname(joined));
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`);
}
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接逃出根: ${rel}`);
const abs = join(parentReal, basename(joined));
let lst: Awaited<ReturnType<typeof lstat>> | null = null;
try {
lst = await lstat(abs);
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`);
}
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在: ${rel}`);
const isSymlink = !!lst?.isSymbolicLink();
if (isSymlink) {
const target = await realpath(abs).catch(() => null);
if (!target || !within(target, rootReal))
throw new FilesError("path_escape", `符号链接指向根外或无法解析,已禁用操作: ${rel}`);
}
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
}
/** 执行阶段复核(TOCTOU):再次 realpath,必须仍在根内 */
export async function recheck(abs: string, rootReal: string): Promise<string> {
let r: string;
try {
r = await realpath(abs);
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
throw new FilesError("plan_stale", `源已不存在: ${abs}`);
}
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时发现路径逃出根(符号链接被替换?): ${abs}`);
return r;
}
// ── 身份 / 授权 ──
export const isAdmin = (p: Principal | undefined) => p?.kind === "admin_session" || p?.kind === "dev_admin";
export const actorOfP = (p: Principal | undefined) => (p ? `${p.kind}:${p.id}` : "system");
// ── 小工具 ──
export const jsonb = (v: unknown) => JSON.stringify(v ?? {});
export function canonicalJson(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
if (v && typeof v === "object" && !(v instanceof Date)) {
const o = v as Record<string, unknown>;
return `{${Object.keys(o)
.filter((k) => o[k] !== undefined)
.sort()
.map((k) => `${JSON.stringify(k)}:${canonicalJson(o[k])}`)
.join(",")}}`;
}
return JSON.stringify(v instanceof Date ? v.toISOString() : v) ?? "null";
}
export const hashJson = (v: unknown) => createHash("sha256").update(canonicalJson(v), "utf8").digest("hex");
export const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
const TEMP_RE = /(\.uhd-partial|\.!qB|\.part)$/i;
export const isTempName = (name: string) => TEMP_RE.test(name);
@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto";
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type {
FileIdentifyCandidate,
FileIdentifyItem,
FilesIdentifyRequest,
FilesIdentifyResult,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { type IdentityCandidate, type ParsedRelease, parseReleasePath, queryTitles, rankCandidates } from "@umt/domain";
import { localIdentityCandidate } from "@umt/mapping";
import type { Principal } from "../../http/auth.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
import { kindOfName } from "./list.ts";
// 识别(只读,不落库):parseReleasePath(文件 + 父/祖父目录上下文)→ 本地召回(别名 pg_trgm + 外部身份)
// → 远端候选(catalog.lookup 能力,走 TMDB 缓存;失败只降级为提示)→ rankCandidates,每项前 5。
const TRGM = 0.45;
const LEVEL: Record<string, FileIdentifyCandidate["level"]> = {
rule_confirmed: "high",
multi_evidence: "medium",
needs_confirm: "low",
};
interface Video {
root: string;
rel: string;
}
async function collectVideos(d: FmDeps, body: FilesIdentifyRequest): Promise<{ videos: Video[]; truncated: boolean }> {
const max = d.options.identifyMaxVideos;
const videos: Video[] = [];
let truncated = false;
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (!r.lst?.isDirectory() || r.isSymlink) {
if (videos.length >= max) truncated = true;
else videos.push({ root: it.root, rel: r.rel });
continue;
}
const stack = [r.rel];
while (stack.length > 0) {
const rel = stack.shift()!;
const names = (await readdir(join(r.rootReal, rel), { withFileTypes: true }).catch(() => [])).sort((a, b) =>
a.name.localeCompare(b.name),
);
for (const n of names) {
if (n.name.startsWith(".")) continue;
const child = rel ? `${rel}/${n.name}` : n.name;
if (n.isDirectory()) stack.push(child);
else if (n.isFile() && kindOfName(n.name) === "video") {
if (videos.length >= max) {
truncated = true;
break;
}
videos.push({ root: it.root, rel: child });
}
}
if (truncated) break;
}
}
return { videos, truncated };
}
function segments(rel: string) {
const parts = rel.split("/");
const file = parts.pop()!;
const parent = parts.pop();
const grandParent = parts.pop();
return { file, ...(parent ? { parent } : {}), ...(grandParent ? { grandParent } : {}) };
}
export async function identify(
d: FmDeps,
principal: Principal | undefined,
body: FilesIdentifyRequest,
): Promise<FilesIdentifyResult> {
const db = d.ctx.db;
const { videos, truncated } = await collectVideos(d, body);
const localCache = new Map<string, IdentityCandidate[]>();
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null }>();
const entityKinds = new Map<string, string>();
const localFor = async (p: ParsedRelease): Promise<IdentityCandidate[]> => {
const key = `${queryTitles(p).join("|")}|${body.hints?.tmdbId ?? ""}`;
const hit = localCache.get(key);
if (hit) return hit;
const ids = new Set<string>();
for (const q of queryTitles(p)) {
const rows = await sql<{ media_entity_id: string }>`
select distinct a.media_entity_id from title_alias a join media_entity e on e.id = a.media_entity_id
where e.status not in ('merged', 'archived')
and (a.normalized = ${q} or (length(${q}) >= 3 and similarity(a.normalized, ${q}) >= ${TRGM}))
limit 20`.execute(db);
for (const r of rows.rows) ids.add(r.media_entity_id);
}
const explicit = [
...p.explicitIds.map((x) => ({ provider: x.provider as string, id: x.id })),
...(body.hints?.tmdbId ? [{ provider: "tmdb", id: String(body.hints.tmdbId) }] : []),
];
for (const x of explicit) {
const rows = await db
.selectFrom("external_identity")
.select(["media_entity_id"])
.where("provider", "=", x.provider)
.where("external_id", "=", x.id)
.where("status", "=", "accepted")
.execute();
for (const r of rows) if (r.media_entity_id) ids.add(r.media_entity_id);
}
const out: IdentityCandidate[] = [];
if (ids.size > 0) {
const list = [...ids];
const ents = await db.selectFrom("media_entity").selectAll().where("id", "in", list).execute();
const aliases = await db
.selectFrom("title_alias")
.select(["media_entity_id", "alias"])
.where("media_entity_id", "in", list)
.execute();
const idents = await db
.selectFrom("external_identity")
.select(["media_entity_id", "provider", "external_id", "status"])
.where("media_entity_id", "in", list)
.execute();
for (const e of ents) {
entityKinds.set(e.id, e.kind);
const c = localIdentityCandidate(
{ id: e.id, kind: e.kind, title: e.title, originalTitle: e.original_title, year: e.year },
aliases.filter((a) => a.media_entity_id === e.id).map((a) => a.alias),
idents
.filter((i) => i.media_entity_id === e.id)
.map((i) => ({ provider: i.provider, externalId: i.external_id, status: i.status })),
);
if (c) out.push(c);
}
}
localCache.set(key, out);
return out;
};
const remoteFor = async (p: ParsedRelease) => {
const q = p.titleCn ?? p.title ?? p.titleEn;
if (!q) return { list: [], warning: null };
const key = `${q}|${body.hints?.year ?? p.year ?? ""}`;
const hit = remoteCache.get(key);
if (hit) return hit;
let res: { list: IdentityCandidate[]; warning: string | null };
try {
const out = (await d.ctx.capabilities.invoke(
"catalog.lookup",
{ query: q, includeRemote: true, limit: 10, ...(body.hints?.year ? { year: body.hints.year } : {}) },
{
actorId: actorOfP(principal),
taskId: `files.identify:${randomUUID()}`,
scopeIds: [],
signal: AbortSignal.timeout(15_000),
source: "web",
},
)) as {
remote?: { provider: string; entityType: string; externalId: string; title: string; year: number | null }[];
};
const list: IdentityCandidate[] = (out.remote ?? [])
.filter((m) => m.entityType === "movie" || m.entityType === "tv")
.map((m) => ({
provider: m.provider,
entityType: m.entityType as "movie" | "tv",
externalId: m.externalId,
titles: [m.title],
...(m.year !== null ? { year: m.year } : {}),
aliases: [],
}));
res = { list, warning: null };
} catch (e) {
res = { list: [], warning: `远端候选不可用(仅本地召回):${errMessage(e).slice(0, 200)}` };
}
remoteCache.set(key, res);
return res;
};
const items: FileIdentifyItem[] = [];
for (const v of videos) {
const parsed0 = parseReleasePath(segments(v.rel));
const parsed: ParsedRelease = {
...parsed0,
...(parsed0.year === undefined && body.hints?.year ? { year: body.hints.year } : {}),
};
const warnings: string[] = [];
const local = parsed.title || parsed.explicitIds.length || body.hints?.tmdbId ? await localFor(parsed) : [];
const remote = parsed.title ? await remoteFor(parsed) : { list: [], warning: null };
if (remote.warning) warnings.push(remote.warning);
if (!parsed.title) warnings.push("文件名无法解析出标题");
// 已关联到本地作品的远端候选不重复出现
const linked = new Set(local.flatMap((c) => (c.linkedExternalIds ?? []).map((l) => `${l.provider}:${l.id}`)));
const all = [...local, ...remote.list.filter((c) => !linked.has(`${c.provider}:${c.externalId}`))];
const kindHint = body.hints?.mediaKind;
const ranked = rankCandidates(parsed, all)
.filter((r) => r.level !== "rejected")
.filter((r) => !kindHint || (kindHint === "movie") === (r.candidate.entityType === "movie"))
.slice(0, 5);
const candidates: FileIdentifyCandidate[] = ranked.map((r, i) => {
const c = r.candidate;
const localKind = c.localEntityId ? entityKinds.get(c.localEntityId) : undefined;
return {
mediaEntityId: c.localEntityId ?? null,
provider: c.provider,
externalId: c.localEntityId ? null : c.externalId,
title: c.titles[0] ?? c.externalId,
year: c.year ?? null,
kind: (localKind ?? (c.entityType === "movie" ? "movie" : "series")) as FileIdentifyCandidate["kind"],
level: LEVEL[r.level] ?? "low",
score: 5 - i,
reasons: r.evidence,
};
});
const best = candidates[0];
let contentUnitId: string | null = null;
let contentUnit: string | null = null;
const season = parsed.season ?? (parsed.episodes ? 1 : undefined);
if (season !== undefined) {
const pad = (n: number) => String(n).padStart(2, "0");
contentUnit = parsed.episodes ? `S${pad(season)}E${pad(parsed.episodes.start)}` : `S${pad(season)}`;
if (best?.mediaEntityId) {
let q = db
.selectFrom("content_unit")
.select(["id"])
.where("media_entity_id", "=", best.mediaEntityId)
.where("season_number", "=", season)
.where("status", "<>", "retired");
q = parsed.episodes ? q.where("episode_number", "=", parsed.episodes.start) : q.where("kind", "=", "season");
contentUnitId = (await q.executeTakeFirst())?.id ?? null;
}
}
const def = v.root.startsWith("lib:") ? v.root.slice(4) : null;
const existing = def
? await db
.selectFrom("asset_location")
.select(["media_asset_id"])
.where("library_root_id", "=", def)
.where("relative_path", "=", v.rel)
.where("status", "<>", "removed")
.executeTakeFirst()
: undefined;
items.push({
root: v.root as FileIdentifyItem["root"],
path: v.rel,
parsed: {
title: parsed.title ?? null,
year: parsed.year ?? null,
season: season ?? null,
episodes: parsed.episodes
? Array.from(
{ length: Math.min(200, parsed.episodes.end - parsed.episodes.start + 1) },
(_, i) => parsed.episodes!.start + i,
)
: [],
resolution: parsed.resolution ?? null,
source: parsed.source ?? null,
releaseGroup: parsed.releaseGroup?.name ?? null,
mediaKind:
parsed.episodes || parsed.season !== undefined ? "series" : parsed.mediaTypeHint === "tv" ? "series" : null,
},
candidates,
suggestion: { mediaEntityId: best?.mediaEntityId ?? null, contentUnitId, contentUnit },
existing: { assetId: existing?.media_asset_id ?? null },
warnings,
});
}
if (videos.length === 0 && body.items.length > 0 && !truncated)
throw new FilesError("bad_request", "所选项中没有视频文件");
return { items, truncated };
}
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { defineTask } from "@umt/tasks";
import { z } from "zod";
import type { AoOverrides } from "../acquisition-organize/index.ts";
import { createPorts } from "../acquisition-organize/ports.ts";
import { timeSlot } from "../acquisition-organize/services/common.ts";
import type { ServerContext, ServerModule } from "../types.ts";
import { defaultOptions, errMessage, type FmDeps, type FmOptions } from "./common.ts";
import { registerFileRoutes } from "./routes.ts";
import { purgeTrash } from "./trash.ts";
/**
* W5-A 文件管理(/files,docs/plan/06-file-manager-spec.md):只浏览已登记根(库根 + 下载器路径映射),
* 行内业务状态、改名同步资产与路径绑定修订、移动/复制两段式 planId、整理走 planImport、回收站 + 引用检查、识别/探测/刮削。
* 复用 acquisition-organize 的服务导出(planImport / 路径守卫 / Move 授权 / H&R),不改其文件。
* 持久任务 purgeTrash:每日清理过期回收站项(FILES_TRASH_RETENTION_DAYS,默认 7 天)。
*/
export interface FmOverrides {
ao?: AoOverrides;
options?: Partial<FmOptions>;
}
export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } {
let deps: FmDeps | null = null;
let timer: ReturnType<typeof setInterval> | null = null;
const lazy = (ctx: ServerContext): FmDeps => {
if (!deps) {
const { options: aoOptions, ...aoRest } = overrides.ao ?? {};
deps = {
ctx,
ao: { ctx, ports: createPorts(ctx, { ...aoRest, ...(aoOptions ? { options: aoOptions } : {}) }) },
options: { ...defaultOptions(ctx), ...overrides.options },
};
}
return deps;
};
const enqueuePurge = async (d: FmDeps) => {
try {
await d.ctx.engine.enqueue(
"purgeTrash",
{},
{
idempotencyKey: `purgeTrash:${timeSlot(d.ao.ports.now(), d.options.purgeIntervalMs)}`,
traceId: randomUUID(),
},
);
} catch (err) {
d.ctx.logger.warn({ err: { message: errMessage(err) } }, "回收站清理入队失败");
}
};
return {
name: "file-manager",
deps() {
if (!deps) throw new Error("file-manager 尚未装配");
return deps;
},
registerRoutes(app, ctx) {
registerFileRoutes(app, lazy(ctx));
},
registerTasks(registry, ctx) {
registry.register(
defineTask({
name: "purgeTrash",
version: 1,
inputSchema: z.object({}).passthrough(),
retryPolicy: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: async (t) =>
t.step.run("purge", async () => {
const r = await purgeTrash(lazy(ctx), undefined, {});
return { purged: r.purged.length, failed: r.failed.length };
}),
}),
);
},
start(ctx) {
const d = lazy(ctx);
if (!d.options.backgroundLoops || d.options.purgeIntervalMs <= 0) return;
void enqueuePurge(d);
timer = setInterval(() => void enqueuePurge(d), Math.min(d.options.purgeIntervalMs, 3600_000));
},
stop() {
if (timer) clearInterval(timer);
timer = null;
},
};
}
export const fileManagerModule = createFileManagerModule();
@@ -0,0 +1,159 @@
import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { actorOfP, FilesError, type FmDeps, hashJson, jsonb } from "./common.ts";
// 计划冻结(artifact kind=plan, title=files_plan)、幂等键(artifact title=files_idem)、审计与 SSE。
export interface StoredPlan<C> {
id: string;
content: C & { op: string; requestHash: string; actor: string };
contentHash: string;
expiresAt: Date;
}
/** 冻结 dryRun 计划;content.request 与 organize 路由同形时,一次性审批(plan_type=organize_plan)可直接绑定 */
export async function savePlan<C extends object>(
d: FmDeps,
op: string,
requestHash: string,
principal: Principal | undefined,
content: C,
): Promise<{ id: string; expiresAt: Date; contentHash: string }> {
const expiresAt = new Date(d.ao.ports.now().getTime() + d.options.planTtlMs);
const full = { ...content, op, requestHash, actor: actorOfP(principal) };
const contentHash = hashJson(full);
const row = await d.ctx.db
.insertInto("artifact")
.values({
kind: "plan",
subject_type: `files_${op}`,
title: "files_plan",
content: jsonb(full),
content_hash: contentHash,
expires_at: expiresAt,
})
.returning("id")
.executeTakeFirstOrThrow();
return { id: row.id, expiresAt, contentHash };
}
/** 取执行用计划:不存在 / 过期 / 操作或请求不符 → 409 plan_stale */
export async function loadPlan<C>(d: FmDeps, planId: string, op: string, requestHash: string): Promise<StoredPlan<C>> {
const row = await d.ctx.db
.selectFrom("artifact")
.select(["id", "content", "content_hash", "expires_at", "title", "kind"])
.where("id", "=", planId)
.executeTakeFirst();
if (row?.kind !== "plan" || row.title !== "files_plan")
throw new FilesError("plan_stale", "planId 不存在,请重新预览");
const content = row.content as StoredPlan<C>["content"];
if (content.op !== op) throw new FilesError("plan_stale", `planId 属于 ${content.op} 操作,不能用于 ${op}`);
if (!row.expires_at || row.expires_at.getTime() <= d.ao.ports.now().getTime())
throw new FilesError("plan_stale", "计划已过期,请重新预览");
if (content.requestHash !== requestHash) throw new FilesError("plan_stale", "请求内容与预览时不一致,请重新预览");
return { id: row.id, content, contentHash: row.content_hash, expiresAt: row.expires_at };
}
/** 计划用完即作废(防重放执行) */
export async function retirePlan(d: FmDeps, planId: string): Promise<void> {
await d.ctx.db.updateTable("artifact").set({ expires_at: d.ao.ports.now() }).where("id", "=", planId).execute();
}
/** API Key 的一次性审批:绑定该计划 id 与内容哈希,消费后标 consumed */
export async function consumeApproval(d: FmDeps, planId: string, contentHash: string): Promise<string | null> {
const r = await sql<{ id: string }>`
update approval set status = 'consumed', updated_at = now()
where id = (
select id from approval
where plan_id = ${planId} and status = 'approved' and payload_hash = ${contentHash}
and plan_type in ('files_plan', 'organize_plan')
and (expires_at is null or expires_at > now())
order by created_at desc limit 1)
and status = 'approved'
returning id`.execute(d.ctx.db);
return r.rows[0]?.id ?? null;
}
// ── 幂等 ──
/**
* 同一调用者 + 操作 + 幂等键:首次执行并存结果;重放返回首次结果;
* 同键不同请求体 → 409;首次仍在执行 → 409(稍后重试)。执行失败不占键。
*/
export async function idempotent<T>(
d: FmDeps,
principal: Principal | undefined,
op: string,
key: string,
request: unknown,
fn: () => Promise<T>,
): Promise<T> {
const db = d.ctx.db;
const k = `${op}:${actorOfP(principal)}:${key}`;
const reqHash = hashJson(request);
const replay = async (): Promise<T | undefined> => {
const row = await db
.selectFrom("artifact")
.select(["content"])
.where("title", "=", "files_idem")
.where(sql<boolean>`content->>'key' = ${k}`)
.executeTakeFirst();
if (!row) return undefined;
const c = row.content as { reqHash: string; state: string; result?: T };
if (c.reqHash !== reqHash) throw new FilesError("exists", "idempotencyKey 已用于不同的请求");
if (c.state !== "done") throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
return c.result as T;
};
const prev = await replay();
if (prev !== undefined) return prev;
const ins = await sql<{ id: string }>`
insert into artifact (kind, subject_type, title, content, content_hash, expires_at)
values ('log', ${`files_${op}`}, 'files_idem', ${jsonb({ key: k, reqHash, state: "pending" })}::jsonb,
${hashJson(k)}, now() + interval '7 days')
on conflict ((content->>'key')) where title = 'files_idem' do nothing
returning id`.execute(db);
const id = ins.rows[0]?.id;
if (!id) {
const again = await replay();
if (again !== undefined) return again;
throw new FilesError("exists", "同一 idempotencyKey 的请求仍在执行,请稍后重试");
}
try {
const result = await fn();
await db
.updateTable("artifact")
.set({ content: jsonb({ key: k, reqHash, state: "done", result }) })
.where("id", "=", id)
.execute();
return result;
} catch (err) {
await db.deleteFrom("artifact").where("id", "=", id).execute();
throw err;
}
}
// ── 审计 / 事件 ──
export async function audit(
d: FmDeps,
principal: Principal | undefined,
op: string,
payload: Record<string, unknown>,
): Promise<void> {
await d.ctx.db
.insertInto("audit_event")
.values({
actor_type: principal?.kind === "api_key" ? "api_key" : "user",
actor_id: actorOfP(principal),
action: `files.${op}`,
target_type: "file",
target_id: null,
payload: jsonb(payload),
})
.execute();
}
/** SSE `files.changed`:受影响的根与目录(相对路径),前端据此刷新 */
export function filesChanged(d: FmDeps, rootId: string, dirs: string[]): void {
void d.ctx.events.publish("files.changed", { rootId, dirs: [...new Set(dirs)] });
}
@@ -0,0 +1,572 @@
import { constants } from "node:fs";
import { access, lstat, opendir, readdir, realpath, stat } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import type {
FileAssetLink,
FileBindingRef,
FileDownloadLink,
FileEntry,
FileFilter,
FileKindTag,
FileMediaSummary,
FileRoot,
FileStat,
FileStatus,
FilesListQuery,
FilesListResponse,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { fileKindOf } from "@umt/domain";
import { hostPathOf, loadInstances } from "../acquisition-organize/services/common.ts";
import { hnrSatisfied } from "../acquisition-organize/services/retention.ts";
import {
errnoOf,
FilesError,
type FmDeps,
isOfflineErr,
isTempName,
loadRootDefs,
probeRoot,
type RootDef,
relOf,
resolveIn,
within,
} from "./common.ts";
// 读接口:根列表、列目录(流式读取 + 上限截断 + 服务端排序/过滤/分页)、单项详情;行内业务状态批量关联。
export async function listRoots(d: FmDeps): Promise<{ items: FileRoot[] }> {
const defs = await loadRootDefs(d.ctx.db);
const items = await Promise.all(
defs.map(async (def): Promise<FileRoot> => {
const h = await probeRoot(def, d.options.healthTimeoutMs);
let writable = false;
let usage: FileRoot["usage"] = null;
if (h.real) {
writable = await isWritableTarget(def, h.real);
const du = await d.ao.ports.diskUsage(h.real).catch(() => null);
if (du?.status === "ok") usage = { totalBytes: du.totalBytes, freeBytes: du.availableBytes };
}
return {
id: def.id as FileRoot["id"],
kind: def.kind,
name: def.name,
hostPath: def.hostPath,
mediaKind: def.mediaKind,
writable,
health: h.health,
checkedAt: h.checkedAt.toISOString(),
healthReason: h.reason,
usage,
};
}),
);
return { items };
}
/** 进程对目录有 W_OK(access 语义) */
async function canWrite(p: string): Promise<boolean> {
return access(p, constants.W_OK).then(
() => true,
() => false,
);
}
export async function isWritableTarget(def: RootDef, rootReal: string): Promise<boolean> {
return def.kind === "library" && def.modifiable && (await canWrite(rootReal));
}
// ── 条目 ──
interface RawEntry {
name: string;
rel: string;
abs: string;
type: FileEntry["type"];
size: number;
mtimeMs: number | null;
escape: boolean;
}
export function kindOfName(name: string): FileKindTag {
const k = fileKindOf(name);
return (k ?? "other") as FileKindTag;
}
async function rawOf(abs: string, rel: string, rootReal: string): Promise<RawEntry | null> {
let st: Awaited<ReturnType<typeof lstat>>;
try {
st = await lstat(abs);
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
return null; // 读取期间被删除
}
const type: FileEntry["type"] = st.isSymbolicLink()
? "symlink"
: st.isDirectory()
? "dir"
: st.isFile()
? "file"
: "other";
let escaped = false;
if (type === "symlink") {
const t = await realpath(abs).catch(() => null);
escaped = !t || !within(t, rootReal);
}
return {
name: basename(abs),
rel,
abs,
type,
size: type === "file" ? Number(st.size) : 0,
mtimeMs: Number(st.mtimeMs) || null,
escape: escaped,
};
}
interface DownloadRef {
taskId: string;
status: FileDownloadLink["status"];
progress: number | null;
hnr: FileDownloadLink["hnr"];
real: string;
}
/** 纳管下载任务的内容路径(本机 realpath)+ 进度 + H&R(常数次查询) */
export async function managedDownloads(d: FmDeps): Promise<DownloadRef[]> {
const db = d.ctx.db;
const rows = await db
.selectFrom("download_task")
.selectAll()
.where("managed", "=", true)
.where("removed_at", "is", null)
.where("status", "not in", ["removed", "failed", "missing"])
.execute();
if (rows.length === 0) return [];
const topo = new Map((await loadInstances(db, { enabledOnly: false })).map((t) => [t.inst.id, t]));
const relIds = [...new Set(rows.map((r) => r.source_release_id).filter((x): x is string => !!x))];
const rels = relIds.length
? await db.selectFrom("source_release").selectAll().where("id", "in", relIds).execute()
: [];
const siteIds = [...new Set(rels.map((r) => r.site_id))];
const sites = siteIds.length ? await db.selectFrom("site").selectAll().where("id", "in", siteIds).execute() : [];
const out: DownloadRef[] = [];
for (const r of rows) {
const t = topo.get(r.instance_id);
if (!t) continue;
const content = r.content_path ?? (r.save_path && r.name ? `${r.save_path.replace(/\/+$/, "")}/${r.name}` : null);
const host = content ? hostPathOf(t, content) : null;
if (!host) continue;
const real = await realpath(host).catch(() => host);
const rel = rels.find((x) => x.id === r.source_release_id);
const site = rel ? sites.find((s) => s.id === rel.site_id) : undefined;
const h = hnrSatisfied(r, rel, site);
out.push({
taskId: r.id,
status: r.status,
progress: r.progress ?? null,
hnr: h === true ? "satisfied" : h === false ? "unsatisfied" : "unknown",
real,
});
}
return out;
}
const DOWNLOADING = new Set(["queued", "submitting", "downloading", "paused", "stalled"]);
export const isDownloading = (s: string) => DOWNLOADING.has(s);
/** 与路径重叠的纳管任务(路径在任务内容内,或任务内容在该目录下) */
export function downloadsFor(downloads: DownloadRef[], abs: string): DownloadRef[] {
return downloads.filter((t) => within(abs, t.real) || within(t.real, abs));
}
interface AssetRow {
loc_id: string;
relative_path: string;
status: string;
is_primary: boolean;
asset_id: string;
media_entity_id: string | null;
media_info: unknown;
title: string | null;
year: number | null;
}
export function mediaSummary(info: unknown): FileMediaSummary | null {
const mi = (info ?? {}) as {
probe?: {
status?: string;
durationSec?: number;
video?: { codec?: string; width?: number; height?: number; hdr?: string }[];
audio?: unknown[];
};
_umt?: { quality?: { resolution?: string; hdr?: string[] } };
};
const p = mi.probe?.status === "ok" ? mi.probe : undefined;
const q = mi._umt?.quality;
if (!p && !q) return null;
const v = p?.video?.[0];
const resolution = v?.height ? `${v.width ?? "?"}x${v.height}` : (q?.resolution ?? null);
return {
resolution,
hdr: v?.hdr ?? (q?.hdr?.length ? q.hdr.join("/") : null),
videoCodec: v?.codec ?? null,
audioTracks: p?.audio?.length ?? 0,
durationSec: p?.durationSec ?? null,
};
}
const unitLabel = (u: { season_number: number | null; episode_number: number | null }[]): string | null => {
const eps = u.filter((x) => x.episode_number !== null).sort((a, b) => a.episode_number! - b.episode_number!);
const pad = (n: number) => String(n).padStart(2, "0");
if (eps.length > 0) {
const s = pad(eps[0]!.season_number ?? 1);
const first = pad(eps[0]!.episode_number!);
const last = eps.length > 1 ? `-E${pad(eps[eps.length - 1]!.episode_number!)}` : "";
return `S${s}E${first}${last}`;
}
const season = u.find((x) => x.season_number !== null);
return season ? `S${pad(season.season_number!)}` : null;
};
/** 已登记资产关联:按 library_root_id + relative_path 批量(常数次查询) */
async function assetLinks(d: FmDeps, def: RootDef, rels: string[]) {
const out = new Map<string, { link: FileAssetLink; available: boolean; media: FileMediaSummary | null }>();
if (def.kind !== "library" || rels.length === 0) return out;
const db = d.ctx.db;
const rows = (
await sql<AssetRow>`
select l.id as loc_id, l.relative_path, l.status, l.is_primary, a.id as asset_id, a.media_entity_id, a.media_info,
e.title, e.year
from asset_location l
join media_asset a on a.id = l.media_asset_id
left join media_entity e on e.id = a.media_entity_id
where l.library_root_id = ${def.libraryRootId} and l.relative_path = any(${rels}::text[]) and l.status <> 'removed'`.execute(
db,
)
).rows;
if (rows.length === 0) return out;
const assetIds = [...new Set(rows.map((r) => r.asset_id))];
const units = await db
.selectFrom("asset_unit_coverage as c")
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
.select(["c.media_asset_id", "u.season_number", "u.episode_number"])
.where("c.media_asset_id", "in", assetIds)
.execute();
const slots = await db
.selectFrom("acquisition_slot")
.select(["id", "status", "current_asset_id"])
.where("current_asset_id", "in", assetIds)
.execute();
for (const r of rows) {
if (!r.media_entity_id) continue;
const slot = slots.find((s) => s.current_asset_id === r.asset_id);
const prev = out.get(r.relative_path);
if (prev?.available) continue;
out.set(r.relative_path, {
available: r.status === "available",
media: mediaSummary(r.media_info),
link: {
assetId: r.asset_id,
locationId: r.loc_id,
mediaEntityId: r.media_entity_id,
title: r.title ?? "",
year: r.year,
contentUnit: unitLabel(units.filter((u) => u.media_asset_id === r.asset_id)),
slotId: slot?.id ?? null,
slotStatus: slot?.status ?? null,
isPrimary: r.is_primary,
},
});
}
return out;
}
const STATUS_RANK: Record<FileStatus, number> = {
temp: 0,
downloading: 1,
registered: 2,
seeding: 3,
unidentified: 4,
plain: 5,
};
/** 给原始条目挂业务状态(资产/下载/临时/待识别);只查常数次库 */
export async function annotate(
d: FmDeps,
def: RootDef,
raws: RawEntry[],
downloads?: DownloadRef[],
): Promise<FileEntry[]> {
const assets = await assetLinks(
d,
def,
raws.map((r) => r.rel),
);
const dls = downloads ?? (await managedDownloads(d));
return raws.map((r) => {
const fileKind = r.type === "file" || r.type === "symlink" ? kindOfName(r.name) : null;
const a = assets.get(r.rel);
const hits = r.escape ? [] : downloadsFor(dls, r.abs);
const active = hits.find((t) => isDownloading(t.status));
const dl = active ?? hits[0];
let status: FileStatus = "plain";
if (isTempName(r.name)) status = "temp";
else if (active) status = "downloading";
else if (a?.available) status = "registered";
else if (dl) status = "seeding";
else if (fileKind === "video") status = "unidentified";
const ext = r.type === "dir" ? null : extname(r.name).replace(/^\./, "").toLowerCase() || null;
return {
name: r.name,
path: r.rel,
type: r.type,
size: r.size,
mtime: r.mtimeMs ? new Date(r.mtimeMs).toISOString() : null,
ext,
fileKind,
status,
escape: r.escape,
asset: a?.link ?? null,
download: dl ? { taskId: dl.taskId, status: dl.status, progress: dl.progress, hnr: dl.hnr } : null,
media: a?.media ?? null,
dirSummary: null,
};
});
}
// ── 列目录 ──
const HIDDEN_NAMES = new Set([".umt-trash", ".uhd-root"]);
const isHidden = (name: string, def: RootDef) =>
name.startsWith(".") || HIDDEN_NAMES.has(name) || name === def.sentinel || name.endsWith(".uhd-partial");
function globToRegex(q: string): RegExp {
const esc = q
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${esc}$`, "i");
}
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
function encodeCursor(offset: number, mtimeMs: number) {
return Buffer.from(JSON.stringify({ o: offset, m: mtimeMs }), "utf8").toString("base64url");
}
function decodeCursor(c: string | undefined): { o: number; m: number } | null {
if (!c) return null;
try {
const v = JSON.parse(Buffer.from(c, "base64url").toString("utf8")) as { o?: unknown; m?: unknown };
if (typeof v.o === "number" && v.o >= 0 && typeof v.m === "number") return { o: v.o, m: v.m };
} catch {
// 落到下面
}
throw new FilesError("bad_request", "cursor 无效");
}
const matchFilter = (e: FileEntry, f: FileFilter) => {
switch (f) {
case "video":
return e.fileKind === "video";
case "subtitle":
return e.fileKind === "subtitle";
case "registered":
return e.status === "registered";
case "unregistered":
return e.type === "file" && !e.asset;
case "downloading":
return e.status === "downloading";
case "temp":
return e.status === "temp";
case "unidentified":
return e.status === "unidentified";
}
};
export async function listDir(d: FmDeps, q: FilesListQuery): Promise<FilesListResponse> {
const r = await resolveIn(d, q.root, q.path);
if (r.isSymlink || !r.lst?.isDirectory()) {
const st = await stat(r.abs).catch(() => null);
if (!st?.isDirectory()) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
}
const dirReal = await realpath(r.abs);
const dirMtime = Number((await stat(dirReal)).mtimeMs);
const cursor = decodeCursor(q.cursor);
const max = d.options.listMaxEntries;
// 流式读取,超过上限即停(truncated),不把整个超大目录读进内存
const names: string[] = [];
let truncated = false;
try {
const dir = await opendir(dirReal, { bufferSize: 256 });
for await (const ent of dir) {
if (!q.hidden && isHidden(ent.name, r.def)) continue;
if (names.length >= max) {
truncated = true;
break;
}
names.push(ent.name);
}
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取目录失败(${errnoOf(e)}),根可能已离线`);
throw new FilesError("bad_request", `读取目录失败: ${errnoOf(e) ?? String(e)}`);
}
let pool = names;
if (q.q) {
const needle = q.q;
const re = /[*?]/.test(needle) ? globToRegex(needle) : null;
const lower = needle.toLowerCase();
pool = names.filter((n) => (re ? re.test(n) : n.toLowerCase().includes(lower)));
}
const raws: RawEntry[] = [];
for (let i = 0; i < pool.length; i += 64) {
const batch = await Promise.all(
pool.slice(i, i + 64).map((n) => rawOf(join(dirReal, n), relOf(r.rootReal, join(dirReal, n)), r.rootReal)),
);
for (const b of batch) if (b) raws.push(b);
}
const downloads = await managedDownloads(d);
let entries = await annotate(d, r.def, raws, downloads);
if (q.filter?.length) entries = entries.filter((e) => q.filter!.some((f) => matchFilter(e, f)));
const dirFirst = (a: FileEntry, b: FileEntry) => (a.type === "dir" ? 0 : 1) - (b.type === "dir" ? 0 : 1);
const cmp: Record<string, (a: FileEntry, b: FileEntry) => number> = {
name: (a, b) => collator.compare(a.name, b.name),
mtime: (a, b) => (a.mtime ?? "").localeCompare(b.mtime ?? "") || collator.compare(a.name, b.name),
size: (a, b) => a.size - b.size || collator.compare(a.name, b.name),
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status] || collator.compare(a.name, b.name),
};
const base = cmp[q.sort]!;
const sign = q.order === "desc" ? -1 : 1;
entries.sort((a, b) => dirFirst(a, b) || sign * base(a, b));
const offset = cursor?.o ?? 0;
const stale = cursor ? cursor.m !== dirMtime : false;
const page = entries.slice(offset, offset + q.limit);
await fillDirSummaries(d, r.def, r.rootReal, page);
const next = offset + q.limit < entries.length ? encodeCursor(offset + q.limit, dirMtime) : null;
return {
root: q.root,
path: r.rel,
entries: page,
nextCursor: next,
total: entries.length,
truncated,
stale,
scannedAt: new Date().toISOString(),
};
}
/** 当前页目录的懒摘要:递归数视频(上限 dirSummaryMax 项)+ 一次查询数已登记 */
async function fillDirSummaries(d: FmDeps, def: RootDef, rootReal: string, page: FileEntry[]) {
const dirs = page.filter((e) => e.type === "dir");
if (dirs.length === 0) return;
const reg = new Map<string, number>();
if (def.kind === "library") {
const likes = dirs.map((e) => `${e.path.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`);
const rows = await sql<{ relative_path: string }>`
select relative_path from asset_location
where library_root_id = ${def.libraryRootId} and status = 'available'
and relative_path like any(${likes}::text[])
limit 200000`.execute(d.ctx.db);
for (const row of rows.rows) {
const top = dirs.find((e) => row.relative_path.startsWith(`${e.path}/`));
if (top) reg.set(top.path, (reg.get(top.path) ?? 0) + 1);
}
}
const max = d.options.dirSummaryMax;
await Promise.all(
dirs.map(async (e) => {
let seen = 0;
let videos = 0;
let partial = false;
const stack = [join(rootReal, e.path)];
while (stack.length > 0 && !partial) {
const dir = stack.pop()!;
const names = await readdir(dir, { withFileTypes: true }).catch(() => []);
for (const n of names) {
if (++seen > max) {
partial = true;
break;
}
if (n.name.startsWith(".")) continue;
if (n.isDirectory()) stack.push(join(dir, n.name));
else if (n.isFile() && kindOfName(n.name) === "video") videos++;
}
}
e.dirSummary = { videoCount: videos, registeredCount: reg.get(e.path) ?? 0, partial };
}),
);
}
// ── 单项详情 ──
/** 同目录同主干(stem)的字幕/NFO/图片/音轨 */
export async function sidecarsOf(abs: string): Promise<string[]> {
const name = basename(abs);
const stem = name.slice(0, name.length - extname(name).length);
if (!stem) return [];
const names = await readdir(dirname(abs)).catch(() => [] as string[]);
return names.filter((n) => {
if (n === name) return false;
if (!(n.startsWith(`${stem}.`) || n.startsWith(`${stem}-`))) return false;
const k = kindOfName(n);
return k === "subtitle" || k === "nfo" || k === "image" || k === "audio";
});
}
export async function bindingsAt(d: FmDeps, def: RootDef, rel: string, prefix = false): Promise<FileBindingRef[]> {
if (def.kind !== "library") return [];
let q = d.ctx.db
.selectFrom("path_binding as b")
.innerJoin("media_entity as e", "e.id", "b.media_entity_id")
.select(["b.id", "b.binding_kind", "b.media_entity_id", "b.season_number", "e.title"])
.where("b.library_root_id", "=", def.libraryRootId!)
.where("b.status", "=", "active");
q = prefix
? q.where((eb) =>
rel === ""
? eb.lit(true)
: eb.or([
eb("b.relative_path", "=", rel),
eb("b.relative_path", "like", `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`),
]),
)
: q.where("b.relative_path", "=", rel);
const rows = await q.execute();
return rows.map((b) => ({
bindingId: b.id,
kind: b.binding_kind,
mediaEntityId: b.media_entity_id,
title: b.title,
seasonNumber: b.season_number,
}));
}
export async function statEntry(d: FmDeps, root: string, path: string): Promise<FileStat> {
const r = await resolveIn(d, root, path);
const raw = await rawOf(r.abs, r.rel, r.rootReal);
if (!raw) throw new FilesError("not_found", `不存在: ${path}`);
const [entry] = await annotate(d, r.def, [raw]);
if (entry!.type === "dir") await fillDirSummaries(d, r.def, r.rootReal, [entry!]);
const st = r.lst!;
const real = await realpath(r.abs).catch(() => r.abs);
const nlink = Number(st.nlink);
return {
entry: entry!,
realPath: real,
inode: Number(st.ino),
device: Number(st.dev),
nlink,
sharedInode: st.isFile() && nlink > 1,
sidecars: st.isFile()
? (await sidecarsOf(r.abs)).map((n) => (r.rel.includes("/") ? `${dirname(r.rel)}/${n}` : n))
: [],
bindings: await bindingsAt(d, r.def, r.rel),
};
}
export type { RawEntry };
export { rawOf };
@@ -0,0 +1,195 @@
import { lstat, mkdir } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import { moveSafe } from "@umt/adapters";
import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts";
import { sanitizeSegment } from "@umt/domain";
import type { Principal } from "../../http/auth.ts";
import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts";
import { audit, filesChanged } from "./ledger.ts";
import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts";
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
// mkdir / rename:同目录改名不覆盖(moveSafe = link+unlink / lstat+rename),执行后 inode 比对;
// 已登记资产同事务同步 asset_location,目录命中 path_binding 写修订。
const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
function assertCleanName(name: string) {
if (sanitizeSegment(name) !== name)
throw new FilesError("bad_request", `文件名含非法字符或首尾空白/点,建议:${sanitizeSegment(name)}`);
}
export async function mkdirIn(
d: FmDeps,
principal: Principal | undefined,
body: FilesMkdirRequest,
): Promise<FilesMkdirResult> {
const r = await resolveIn(d, body.root, body.path);
if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `根 ${r.def.name} 不可写`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
assertCleanName(body.name);
await recheck(r.abs, r.rootReal);
const abs = join(r.abs, body.name);
try {
await mkdir(abs);
} catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在: ${body.name}`);
throw new FilesError("bad_request", `创建目录失败: ${errMessage(e)}`);
}
const rel = relOf(r.rootReal, abs);
await audit(d, principal, "mkdir", { root: body.root, path: rel });
filesChanged(d, body.root, [r.rel]);
return { root: body.root, path: rel };
}
interface Pair {
from: string;
to: string;
fromAbs: string;
toAbs: string;
role: "main" | "sidecar";
}
export async function renameEntry(
d: FmDeps,
principal: Principal | undefined,
body: FilesRenameRequest,
): Promise<FilesRenameResult> {
const r = await resolveIn(d, body.root, body.path);
if (r.rel === "") throw new FilesError("bad_request", "不能改名根目录");
if (!r.def.modifiable) throw new FilesError("not_writable", `根 ${r.def.name} 为只读登记(register_only)`);
assertCleanName(body.newName);
const oldName = basename(r.abs);
if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同");
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
// 纳管下载任务的内容(或目录下有):改名会让下载器找不到文件
const hits = downloadsFor(await managedDownloads(d), r.abs);
if (hits.length > 0)
throw new FilesError(
"download_active",
`该路径属于纳管下载任务(${hits.map((h) => h.taskId).join(", ")}),改名会让下载器找不到文件;请先完成整理或移交`,
);
const dirAbs = dirname(r.abs);
const pRel = parentRel(r.rel);
const relIn = (n: string) => (pRel ? `${pRel}/${n}` : n);
const pairs: Pair[] = [
{ from: r.rel, to: relIn(body.newName), fromAbs: r.abs, toAbs: join(dirAbs, body.newName), role: "main" },
];
if (!isDir && body.withSidecars && kindOfName(oldName) === "video") {
const oldStem = oldName.slice(0, oldName.length - extname(oldName).length);
const newStem = body.newName.slice(0, body.newName.length - extname(body.newName).length);
for (const n of await sidecarsOf(r.abs)) {
const nn = `${newStem}${n.slice(oldStem.length)}`;
pairs.push({ from: relIn(n), to: relIn(nn), fromAbs: join(dirAbs, n), toAbs: join(dirAbs, nn), role: "sidecar" });
}
}
// 不覆盖:任一目标已存在 → 409,原文件不动
const taken: string[] = [];
for (const p of pairs) {
const st = await lstat(p.toAbs).catch((e) => (errnoOf(e) === "ENOENT" ? null : e));
if (st) taken.push(p.to);
}
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖: ${taken.join(", ")}`);
const lib = r.def.libraryRootId;
let locCount = 0;
let bindingIds: string[] = [];
if (lib) {
for (const p of pairs) {
const pv = await relocatePreview(d.ctx.db, lib, p.from, isDir && p.role === "main");
locCount += pv.locations;
bindingIds = [...bindingIds, ...pv.bindingIds];
}
}
const renames = pairs.map((p) => ({ from: p.from, to: p.to, role: p.role }));
if (body.dryRun)
return {
dryRun: true,
renames,
assetLocations: locCount,
bindings: bindingIds.map((id) => ({ oldBindingId: id, newBindingId: null })),
recordId: null,
};
// 执行:逐个 no-clobber 改名;任一失败回滚已改的
const done: Pair[] = [];
const rollback = async () => {
for (const p of done.reverse()) await moveSafe(p.toAbs, p.fromAbs).catch(() => undefined);
};
for (const p of pairs) {
try {
await recheck(p.fromAbs, r.rootReal);
const before = await lstat(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `改名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs);
if (after.ino !== before.ino || after.dev !== before.dev)
throw new FilesError("bad_request", `改名后 inode 不一致,已中止: ${p.to}`);
done.push(p);
} catch (e) {
await rollback();
throw e;
}
}
let recordId: string | null = null;
const bindings: FilesRenameResult["bindings"] = [];
let synced = 0;
if (lib) {
try {
await d.ctx.db.transaction().execute(async (trx) => {
const files: RecordFile[] = [];
const entities = new Set<string>();
for (const p of pairs) {
const rr = await relocate(trx, {
fromLib: lib,
fromRel: p.from,
toLib: lib,
toRel: p.to,
isDir: isDir && p.role === "main",
actor: actorOfP(principal),
opKind: "rename",
});
synced += rr.locations.length;
bindings.push(...rr.bindings);
for (const e of rr.mediaEntityIds) entities.add(e);
if (rr.locations.length > 0 || rr.bindings.length > 0)
files.push({
src: p.from,
dst: p.to,
size: 0,
status: "succeeded",
role: p.role === "main" ? "main" : "other",
});
}
if (files.length > 0)
recordId = await writeRecord(d, trx, {
principal,
action: "rename",
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
sourcePath: r.abs,
targetLibraryRootId: lib,
targetPath: pairs[0]!.to,
plan: { op: "rename", renames, bindings },
files,
});
});
} catch (e) {
// 数据库同步失败:文件改回原名,保持"路径变更与登记同事务"
await rollback();
throw e;
}
}
await audit(d, principal, "rename", {
root: body.root,
renames,
assetLocations: synced,
bindings,
recordId,
});
filesChanged(d, body.root, [pRel]);
return { dryRun: false, renames, assetLocations: synced, bindings, recordId };
}
@@ -0,0 +1,244 @@
import { stat } from "node:fs/promises";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts";
import {
authorizeMove,
hnrBlockForMove,
managedTasksUnder,
PathGuardError,
requestKey,
sourceGuard,
} from "../acquisition-organize/services/organize-guard.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
// 整理向导落地:按 (mediaEntityId, contentUnitId) 分组,每组走 planImport(与 POST /organization-records 同一实现,
// 同格式整理记录)。dryRun 冻结计划 + 源文件 size/mtime;执行校验 planId、源事实与重算的前后路径一致,
// 任一组 plan_stale / 授权 / H&R 失败只影响该组。
type Req = {
sourcePaths: string[];
mediaEntityId: string;
contentUnitId?: string | undefined;
action: "copy" | "move";
targetLibraryRootId?: string | undefined;
};
interface Frozen {
request: Req;
pairs: { src: string; dst: string | null }[];
facts: { path: string; size: number; mtimeMs: number }[];
mediaEntityId: string;
status: string;
reason: string | null;
}
const pairsOf = (plan: Awaited<ReturnType<typeof planImport>>) =>
(plan.plan?.organize.files ?? []).map((f) => ({ src: f.src, dst: f.dst ?? null, kind: f.kind, size: f.size }));
async function factsOf(paths: string[]) {
return Promise.all(
paths.map(async (p) => {
const s = await stat(p).catch(() => null);
return { path: p, size: s ? Number(s.size) : -1, mtimeMs: s ? Number(s.mtimeMs) : -1 };
}),
);
}
export async function organizeFiles(
d: FmDeps,
principal: Principal | undefined,
body: FilesOrganizeRequest,
): Promise<FilesOrganizeResult> {
const db = d.ctx.db;
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal);
// 分组(保持首次出现顺序)
const groups = new Map<string, { entity: string; unit: string | null; paths: string[]; warnings: string[] }>();
const roots = new Set<string>();
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
if (body.action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`);
roots.add(it.root);
const key = `${it.mediaEntityId}|${it.contentUnitId ?? ""}`;
const g = groups.get(key) ?? { entity: it.mediaEntityId, unit: it.contentUnitId ?? null, paths: [], warnings: [] };
g.paths.push(r.abs);
if (it.season !== undefined || it.episodes !== undefined || it.slotId !== undefined)
g.warnings.push(`${r.rel}: 逐项季/集/Slot 覆写暂未生效(按文件名解析),待 planImport 支持 fileOverrides`);
groups.set(key, g);
}
const guard = await sourceGuard(db);
const out: FilesOrganizeGroup[] = [];
const recordIds: string[] = [];
for (const g of groups.values()) {
const req: Req = {
sourcePaths: g.paths,
mediaEntityId: g.entity,
...(g.unit ? { contentUnitId: g.unit } : {}),
action: body.action,
...(body.targetLibraryRootId ? { targetLibraryRootId: body.targetLibraryRootId } : {}),
};
const reqHash = hashJson(requestKey(req));
const base = { mediaEntityId: g.entity, contentUnitId: g.unit, warnings: g.warnings };
const importReq = (dryRun: boolean, taskId?: string) => ({
...req,
trigger: "manual" as const,
actorType: actorType as "api_key" | "user",
actorId: actor,
dryRun,
pathGuard: guard.check,
...(taskId ? { taskId } : {}),
});
try {
if (body.dryRun) {
const plan = await planImport(d.ao, importReq(true));
const pairs = pairsOf(plan);
const saved = await savePlan<Frozen & { plan: unknown; truncated: boolean }>(
d,
"organize",
reqHash,
principal,
{
request: req,
pairs: pairs.map(({ src, dst }) => ({ src, dst })),
facts: await factsOf(pairs.map((p) => p.src)),
mediaEntityId: g.entity,
status: plan.status,
reason: plan.reason,
plan: plan.plan ?? null,
truncated: plan.truncated === true,
},
);
out.push({
...base,
planId: saved.id,
files: pairs.map((p) => ({ source: p.src, target: p.dst, kind: p.kind, size: p.size })),
truncated: plan.truncated === true,
recordId: null,
taskId: null,
error: plan.status === "failed" ? { code: "plan_blocked", message: plan.reason ?? "计划不可执行" } : null,
});
continue;
}
// ── 执行 ──
const planId = await pickPlan(d, body.planIds ?? [], reqHash);
const frozen = (await loadPlan<Frozen>(d, planId, "organize", reqHash)).content;
const now = await factsOf(frozen.facts.map((f) => f.path));
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化: ${changed.path}`);
const again = await planImport(d.ao, importReq(true));
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (body.action === "move") {
const managed = await managedTasksUnder(db, req.sourcePaths);
if (!isAdmin(principal)) {
const auth = await authorizeMove(d.ao, principal, req, g.entity);
if (!auth.allowed) {
await audit(d, principal, "organize_move_denied", { reason: auth.reason, planId });
throw new FilesError("approval_required", auth.reason);
}
}
const block = await hnrBlockForMove(d.ao, managed, { entityId: g.entity, actor });
if (block)
throw new FilesError(
"download_active",
`来源下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},拒绝 Move(未改为 Copy);已建待处理 ${block.decisionId}`,
);
}
await retirePlan(d, planId);
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: `files:${planId}`,
input: { ...req, planId },
subjectType: "organize_request",
triggeredBy: actor,
});
const plan = await planImport(d.ao, importReq(false, taskId));
if ((plan.status === "running" || plan.status === "queued") && plan.recordId) {
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId: plan.recordId, taskId },
{ idempotencyKey: `verifyAndImport:files:${planId}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
await db
.updateTable("organization_record")
.set({ task_id: taskId })
.where("id", "=", plan.recordId)
.where("task_id", "is", null)
.execute();
recordIds.push(plan.recordId);
out.push({
...base,
planId,
// 执行时 planImport 不回传计划体;冻结计划与刚重算的 dryRun 一致(上面已校验),直接回显
files: pairsOf(again).map((p) => ({ source: p.src, target: p.dst, kind: p.kind, size: p.size })),
truncated: false,
recordId: plan.recordId,
taskId,
error: null,
});
} else {
await markTask(db, taskId, "failed", { error: plan.reason ?? plan.status });
out.push({
...base,
planId,
files: [],
truncated: false,
recordId: plan.recordId,
taskId,
error: { code: "plan_failed", message: plan.reason ?? plan.status },
});
}
} catch (e) {
const code = e instanceof FilesError ? e.reason : e instanceof PathGuardError ? "path_escape" : "failed";
out.push({
...base,
planId: null,
files: [],
truncated: false,
recordId: null,
taskId: null,
error: { code, message: errMessage(e) },
});
}
}
if (!body.dryRun) {
await audit(d, principal, "organize", {
action: body.action,
groups: out.map((g) => ({
mediaEntityId: g.mediaEntityId,
planId: g.planId,
recordId: g.recordId,
error: g.error,
})),
});
for (const r of roots) filesChanged(d, r, []);
}
return { dryRun: body.dryRun, groups: out, recordIds };
}
/** 执行请求携带全部组的 planId:找与本组请求一致的那个 */
async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<string> {
if (planIds.length === 0) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planIds");
const rows = await d.ctx.db
.selectFrom("artifact")
.select(["id", "content"])
.where("id", "in", planIds)
.where("title", "=", "files_plan")
.execute();
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,请重新预览");
return hit.id;
}
@@ -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;
}
@@ -0,0 +1,215 @@
import { randomUUID } from "node:crypto";
import type { OrganizationAction } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import type { Exec } from "../acquisition-organize/services/common.ts";
import { emitRecord } from "../acquisition-organize/services/organize.ts";
import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts";
// 显式路径变更的数据库同步:asset_location 前缀替换、path_binding 修订(旧 superseded → 新 active)、
// asset_operation 账本与 organization_record(外部程序按 changeSeq 增量感知)。
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
export interface RelocateInput {
fromLib: string;
fromRel: string;
toLib: string;
toRel: string;
isDir: boolean;
actor: string;
opKind: "rename" | "move";
}
export interface RelocateResult {
locations: { id: string; assetId: string; from: string; to: string }[];
bindings: { oldBindingId: string; newBindingId: string }[];
mediaEntityIds: string[];
}
/** 同事务:把 fromLib/fromRel(目录含其下全部)改到 toLib/toRel */
export async function relocate(trx: Exec, input: RelocateInput): Promise<RelocateResult> {
const { fromLib, fromRel, toLib, toRel, isDir } = input;
const mapRel = (p: string) => (p === fromRel ? toRel : `${toRel}${p.slice(fromRel.length)}`);
const locs = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "l.media_asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", fromLib)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", fromRel), eb("l.relative_path", "like", likePrefix(fromRel))])
: eb("l.relative_path", "=", fromRel),
)
.execute();
const out: RelocateResult = { locations: [], bindings: [], mediaEntityIds: [] };
const entities = new Set<string>();
for (const l of locs) {
const to = mapRel(l.relative_path);
await trx
.updateTable("asset_location")
.set({ library_root_id: toLib, relative_path: to, last_seen_at: new Date() })
.where("id", "=", l.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: `files:${randomUUID()}`,
phase: "commit",
kind: input.opKind,
status: "succeeded",
intent: jsonb({
fromLibraryRootId: fromLib,
from: l.relative_path,
toLibraryRootId: toLib,
to,
by: input.actor,
}),
result: jsonb({ locationId: l.id }),
media_asset_id: l.media_asset_id,
started_at: new Date(),
finished_at: new Date(),
})
.execute();
out.locations.push({ id: l.id, assetId: l.media_asset_id, from: l.relative_path, to });
if (l.media_entity_id) entities.add(l.media_entity_id);
}
if (isDir) {
const binds = await trx
.selectFrom("path_binding")
.selectAll()
.where("library_root_id", "=", fromLib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", fromRel), eb("relative_path", "like", likePrefix(fromRel))]))
.execute();
for (const b of binds) {
// 显式路径变更(REQ §7.1.1):旧绑定 superseded,新绑定 active 并互相链接;仅译名刷新永不走到这里
await trx.updateTable("path_binding").set({ status: "superseded" }).where("id", "=", b.id).execute();
const nb = await trx
.insertInto("path_binding")
.values({
media_entity_id: b.media_entity_id,
content_unit_id: b.content_unit_id,
season_number: b.season_number,
binding_kind: b.binding_kind,
library_root_id: toLib,
relative_path: mapRel(b.relative_path),
template_version: b.template_version,
status: "active",
bound_by: input.actor,
note: `files.${input.opKind}: ${b.relative_path} → ${mapRel(b.relative_path)}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx.updateTable("path_binding").set({ superseded_by_id: nb.id }).where("id", "=", b.id).execute();
out.bindings.push({ oldBindingId: b.id, newBindingId: nb.id });
entities.add(b.media_entity_id);
}
}
out.mediaEntityIds = [...entities];
return out;
}
/** 预览:受影响的位置数与绑定(不写库) */
export async function relocatePreview(exec: Exec, lib: string, rel: string, isDir: boolean) {
const locs = await exec
.selectFrom("asset_location")
.select((eb) => eb.fn.countAll<string>().as("n"))
.where("library_root_id", "=", lib)
.where("status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("relative_path", "=", rel), eb("relative_path", "like", likePrefix(rel))])
: eb("relative_path", "=", rel),
)
.executeTakeFirst();
const binds = isDir
? await exec
.selectFrom("path_binding")
.select(["id"])
.where("library_root_id", "=", lib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", rel), eb("relative_path", "like", likePrefix(rel))]))
.execute()
: [];
return { locations: Number(locs?.n ?? 0), bindingIds: binds.map((b) => b.id) };
}
export interface RecordFile {
src: string;
dst: string | null;
size: number;
status: "succeeded" | "failed" | "skipped";
error?: string;
role?: "main" | "subtitle" | "nfo" | "image" | "audio" | "other";
}
/** 写一条已结束的整理记录(文件管理的显式操作),并发 organization.changed */
export async function writeRecord(
d: FmDeps,
trx: Exec,
input: {
principal: Principal | undefined;
action: OrganizationAction;
mediaEntityId: string | null;
sourcePath: string;
targetLibraryRootId: string | null;
targetPath: string | null;
plan: Record<string, unknown>;
files: RecordFile[];
reason?: string | null;
},
): Promise<string> {
const ok = input.files.filter((f) => f.status === "succeeded").length;
const failed = input.files.filter((f) => f.status === "failed").length;
const skipped = input.files.filter((f) => f.status === "skipped").length;
const status = failed === 0 ? "succeeded" : ok > 0 ? "partial" : "failed";
const now = new Date();
const rec = await trx
.insertInto("organization_record")
.values({
trigger: "manual",
actor_type: input.principal?.kind === "api_key" ? "api_key" : "user",
actor_id: actorOfP(input.principal),
media_entity_id: input.mediaEntityId,
source_path_view: "local",
source_path: input.sourcePath,
target_library_root_id: input.targetLibraryRootId,
target_path: input.targetPath,
action: input.action,
status,
reason: input.reason ?? null,
plan: jsonb({ version: "files.v1", ...input.plan }),
plan_fingerprint: hashJson(input.plan),
file_total: input.files.length,
file_succeeded: ok,
file_failed: failed,
file_skipped: skipped,
started_at: now,
finished_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
let ordinal = 0;
for (const f of input.files) {
await trx
.insertInto("organization_file_result")
.values({
organization_record_id: rec.id,
ordinal: ordinal++,
role: f.role ?? "main",
mode: input.action,
source_path: f.src,
source_size_bytes: f.size,
target_library_root_id: f.dst ? input.targetLibraryRootId : null,
target_path: f.dst,
status: f.status,
error: f.error ? jsonb({ message: f.error }) : null,
started_at: now,
finished_at: now,
})
.execute();
}
await emitRecord(d.ao, trx, rec.id);
return rec.id;
}
@@ -0,0 +1,101 @@
import { createRoute } from "@hono/zod-openapi";
import { Api, apiResponse, type EndpointDef } from "@umt/contracts";
import type { Context } from "hono";
import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts";
import type { ModuleApp } from "../types.ts";
import { FilesError, type FmDeps, isAdmin } from "./common.ts";
import { identify } from "./identify.ts";
import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts";
import { probeFiles, scrape } from "./probe.ts";
import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
// /files 路由:scope 由 contracts 端点定义(读 organize.read / 写 organize.execute),处理器内再按授权矩阵细分。
// FilesError → 信封 + 对应状态(根离线 503,message 以 `<reason>: ` 开头)。
// biome-ignore lint/suspicious/noExplicitAny: 入参已按 contracts schema 校验
type Input = { query: any; body: any };
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
const route = createRoute({
method: def.method,
path: def.path,
tags: ["files"],
summary: def.summary,
middleware: [requireScope(def.scope)] as const,
request: {
...(def.query ? { query: def.query as z.ZodObject } : {}),
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
},
responses: {
200: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
app.openapi(route, (async (c: Context<AuthEnv>) => {
const v = c.req as unknown as { valid(t: string): unknown };
try {
const data = await handler(c, {
query: def.query ? v.valid("query") : {},
body: def.body ? v.valid("json") : undefined,
});
return c.json({ ok: true as const, data }, 200);
} catch (err) {
if (err instanceof FilesError)
return c.json(errorBody(err.code, `${err.reason}: ${err.message}`), err.status as 400);
throw err;
}
}) as never);
}
/** 写操作的幂等包装(dryRun 不占键) */
const idem = <B extends { idempotencyKey: string; dryRun?: boolean }>(
d: FmDeps,
c: Context<AuthEnv>,
op: string,
body: B,
fn: () => Promise<unknown>,
) => {
if (body.dryRun) return fn();
const { idempotencyKey, ...rest } = body;
return idempotent(d, c.get("principal"), op, idempotencyKey, rest, fn);
};
export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
const F = Api.files;
mount(app, F.roots, async () => listRoots(d));
mount(app, F.list, async (_c, { query }) => listDir(d, query));
mount(app, F.stat, async (_c, { query }) => statEntry(d, query.root, query.path));
mount(app, F.mkdir, async (c, { body }) => idem(d, c, "mkdir", body, () => mkdirIn(d, c.get("principal"), body)));
mount(app, F.rename, async (c, { body }) =>
idem(d, c, "rename", body, () => renameEntry(d, c.get("principal"), body)),
);
mount(app, F.move, async (c, { body }) => idem(d, c, "move", body, () => transfer(d, c.get("principal"), body)));
mount(app, F.copy, async (c, { body }) => {
const b = { ...body, mode: "copy" as const };
return idem(d, c, "copy", b, () => transfer(d, c.get("principal"), b));
});
mount(app, F.organize, async (c, { body }) =>
idem(d, c, "organize", body, () => organizeFiles(d, c.get("principal"), body)),
);
mount(app, F.delete, async (c, { body }) =>
idem(d, c, "delete", body, () => deleteFiles(d, c.get("principal"), body)),
);
mount(app, F.trash, async (_c, { query }) => listTrash(d, query));
mount(app, F.trashRestore, async (c, { body }) =>
idem(d, c, "trash_restore", body, () => restoreTrash(d, c.get("principal"), body)),
);
mount(app, F.trashPurge, async (c, { body }) => {
// 授权矩阵:清空回收站仅 admin 会话,API Key(含 admin scope)一律拒绝
if (!isAdmin(c.get("principal"))) throw new FilesError("forbidden", "清空回收站只允许控制台管理员会话");
return idem(d, c, "trash_purge", body, () => purgeTrash(d, c.get("principal"), body));
});
mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body));
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
}
@@ -0,0 +1,303 @@
import { lstat, mkdir, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { copyWithVerify, crossDeviceMove, moveSafe } from "@umt/adapters";
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { authorizeMove } from "../acquisition-organize/services/organize-guard.ts";
import {
actorOfP,
errMessage,
FilesError,
type FmDeps,
hashJson,
isAdmin,
type Resolved,
recheck,
relOf,
resolveIn,
within,
} from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts";
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts";
// 移动/复制到目录:dryRun 冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)→ planId;
// 执行校验 planId(未过期、请求一致)与源事实未变,否则 409 plan_stale。
// 搬运原语与整理流程相同(adapters moveSafe / crossDeviceMove / copyWithVerify:no-clobber、跨卷 Copy→核验→删源、EXDEV 不降级)。
interface SourceSnap {
root: string;
rel: string;
abs: string;
isDir: boolean;
size: number;
fingerprint: string;
files: { rel: string; size: number }[];
truncated: boolean;
}
async function snapshot(d: FmDeps, r: Resolved, rootId: string): Promise<SourceSnap> {
const st = await lstat(r.abs);
if (!st.isDirectory()) {
const f = { size: Number(st.size), mtimeMs: Number(st.mtimeMs), ino: Number(st.ino) };
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: false,
size: f.size,
fingerprint: hashJson(f),
files: [{ rel: "", size: f.size }],
truncated: false,
};
}
const max = d.ao.ports.options.walkMaxFiles;
const files: { rel: string; size: number; mtimeMs: number }[] = [];
let truncated = false;
const walk = async (dir: string, prefix: string): Promise<void> => {
const names = (await readdir(dir)).sort();
for (const n of names) {
if (files.length >= max) {
truncated = true;
return;
}
const p = join(dir, n);
const s = await lstat(p);
const rel = prefix ? `${prefix}/${n}` : n;
if (s.isDirectory()) await walk(p, rel);
else if (s.isFile()) files.push({ rel, size: Number(s.size), mtimeMs: Number(s.mtimeMs) });
}
};
await walk(r.abs, "");
return {
root: rootId,
rel: r.rel,
abs: r.abs,
isDir: true,
size: files.reduce((a, f) => a + f.size, 0),
fingerprint: hashJson(files),
files: files.map(({ rel, size }) => ({ rel, size })),
truncated,
};
}
export async function transfer(
d: FmDeps,
principal: Principal | undefined,
body: FilesMoveRequest,
): Promise<FilesTransferResult> {
const mode = body.mode;
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "不能移动/复制根目录本身");
if (mode === "move" && !s.def.modifiable)
throw new FilesError("not_writable", `源根 ${s.def.name} 为只读登记,不能移动(可复制)`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不移动符号链接本身: ${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
const downloads = await managedDownloads(d);
const hnrBlocks: FilesTransferResult["hnrBlocks"] = [];
if (mode === "move")
for (const s of sources)
for (const h of downloadsFor(downloads, s.abs))
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
const targetDev = Number((await stat(t.abs)).dev);
const pairs: FilePlanPair[] = [];
let needBytes = 0;
for (const [i, s] of sources.entries()) {
const dst = join(t.abs, names[i]!);
const conflict = !!(await lstat(dst).catch(() => null));
const registered = s.def.libraryRootId
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
: false;
pairs.push({
source: { root: body.items[i]!.root, path: s.rel },
target: { root: body.target.root, path: relOf(t.rootReal, dst) },
size: snaps[i]!.size,
conflict,
registered,
});
const sameDev = Number((await lstat(s.abs)).dev) === targetDev;
if (!(mode === "move" && sameDev)) needBytes += snaps[i]!.size;
}
const du = await d.ao.ports.diskUsage(t.abs).catch(() => null);
const freeBytes = du?.status === "ok" ? du.availableBytes : null;
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
const truncated = snaps.some((s) => s.truncated);
const totalBytes = snaps.reduce((a, s) => a + s.size, 0);
const requestHash = hashJson({ items: body.items, target: body.target, mode });
const request = {
sourcePaths: sources.map((s) => s.abs),
action: mode,
targetLibraryRootId: t.def.libraryRootId ?? undefined,
};
if (body.dryRun) {
const plan = await savePlan(d, "transfer", requestHash, principal, {
request,
sources: snaps.map((s) => ({ abs: s.abs, fingerprint: s.fingerprint })),
pairs,
});
return {
dryRun: true,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode,
pairs,
totalBytes,
freeBytes,
spaceOk,
hnrBlocks,
truncated,
executed: null,
};
}
// ── 执行 ──
if (!body.planId) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planId");
const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[] }>(
d,
body.planId,
"transfer",
requestHash,
);
for (const [i, s] of snaps.entries()) {
const frozen = plan.content.sources[i];
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
}
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (mode === "move") {
if (hnrBlocks.length > 0)
throw new FilesError(
"download_active",
`源属于纳管下载任务(${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")}),裸移动会让下载器丢失文件;请用整理入库(Move 会按 H&R 判定并移交)`,
);
if (!isAdmin(principal)) {
const entity = await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["a.media_entity_id"])
.where("l.library_root_id", "=", sources[0]!.def.libraryRootId ?? "00000000-0000-0000-0000-000000000000")
.where("l.relative_path", "=", sources[0]!.rel)
.executeTakeFirst();
const auth = await authorizeMove(d.ao, principal, request, entity?.media_entity_id ?? null);
if (!auth.allowed) {
await audit(d, principal, "move_denied", { reason: auth.reason, planId: plan.id });
throw new FilesError("approval_required", auth.reason);
}
}
}
await retirePlan(d, plan.id);
const hash = d.ao.ports.options.verifyHash;
const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = [];
const recFiles: RecordFile[] = [];
const moved: { s: Resolved; dstRel: string; isDir: boolean }[] = [];
for (const [i, s] of sources.entries()) {
const pair = pairs[i]!;
const dst = join(t.abs, names[i]!);
const fail = (code: string, message: string) => {
failed.push({ path: s.rel, code, message });
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "failed", error: message });
};
try {
await recheck(s.abs, s.rootReal);
if (await lstat(dst).catch(() => null)) {
fail("exists", "目标已存在,不覆盖");
continue;
}
const isDir = snaps[i]!.isDir;
if (mode === "move") {
const r = await moveSafe(s.abs, dst);
if (r.status === "exists") {
fail("exists", "目标已存在,不覆盖");
continue;
}
if (r.status === "exdev") {
if (isDir) {
fail("exdev_dir", "跨卷移动目录暂不支持(W5),请先复制再删除,或逐个文件移动");
continue;
}
const x = await crossDeviceMove(s.abs, dst, { hash });
if (x.status !== "completed") {
fail(x.status, `跨卷移动未完成(${x.status}):${JSON.stringify(x.steps.at(-1) ?? {})}`);
continue;
}
}
moved.push({ s, dstRel: pair.target.path, isDir });
} else if (isDir) {
for (const f of snaps[i]!.files) {
await copyWithVerify(join(s.abs, f.rel), join(dst, f.rel), { hash, createParents: true });
}
await mkdir(dst, { recursive: true });
} else {
await copyWithVerify(s.abs, dst, { hash });
}
succeeded.push(s.rel);
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "succeeded" });
} catch (e) {
fail(e instanceof FilesError ? e.reason : "io_error", errMessage(e));
}
}
const recordId = await d.ctx.db.transaction().execute(async (trx) => {
const entities = new Set<string>();
if (mode === "move" && t.def.libraryRootId) {
for (const m of moved) {
if (!m.s.def.libraryRootId) continue;
const rr = await relocate(trx, {
fromLib: m.s.def.libraryRootId,
fromRel: m.s.rel,
toLib: t.def.libraryRootId,
toRel: m.dstRel,
isDir: m.isDir,
actor: actorOfP(principal),
opKind: "move",
});
for (const e of rr.mediaEntityIds) entities.add(e);
}
}
return writeRecord(d, trx, {
principal,
action: mode === "move" ? "relocate" : "copy",
mediaEntityId: entities.size === 1 ? [...entities][0]! : null,
sourcePath: sources.length === 1 ? sources[0]!.abs : dirname(sources[0]!.abs),
targetLibraryRootId: t.def.libraryRootId,
targetPath: t.rel,
plan: { op: "transfer", mode, planId: plan.id, pairs },
files: recFiles,
});
});
await audit(d, principal, mode, { planId: plan.id, succeeded, failed, recordId, target: body.target });
for (const it of body.items) filesChanged(d, it.root, [it.path.includes("/") ? dirname(it.path) : ""]);
filesChanged(d, body.target.root, [t.rel]);
return {
dryRun: false,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode,
pairs,
totalBytes,
freeBytes,
spaceOk,
hnrBlocks,
truncated,
executed: { succeeded, failed, recordId },
};
}
@@ -0,0 +1,510 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, readdir, rm } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { copyWithVerify, moveSafe } from "@umt/adapters";
import type {
FileDeleteCheck,
FilesDeleteRequest,
FilesDeleteResult,
FilesTrashList,
FilesTrashPurgeRequest,
FilesTrashPurgeResult,
FilesTrashQuery,
FilesTrashRestoreRequest,
FilesTrashRestoreResult,
TrashItem,
} from "@umt/contracts";
import { sql, type TrashItemRow } from "@umt/db";
import type { Principal } from "../../http/auth.ts";
import { upsertDecision } from "../acquisition-organize/services/common.ts";
import {
actorOfP,
errMessage,
errnoOf,
FilesError,
type FmDeps,
hashJson,
isAdmin,
jsonb,
onlineRoot,
recheck,
resolveIn,
rootDef,
within,
} from "./common.ts";
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts";
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
// 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。
// 删除永不连带下载器任务。
export const TRASH_DIR = ".umt-trash";
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
const day = (t: Date) => t.toISOString().slice(0, 10).replace(/-/g, "");
async function sizeOf(abs: string, cap = 200_000): Promise<{ size: number; files: string[] }> {
const st = await lstat(abs);
if (!st.isDirectory()) return { size: Number(st.size), files: [""] };
let size = 0;
const files: string[] = [];
const walk = async (dir: string, prefix: string) => {
for (const n of await readdir(dir)) {
if (files.length >= cap) return;
const p = join(dir, n);
const s = await lstat(p);
const rel = prefix ? `${prefix}/${n}` : n;
if (s.isDirectory()) await walk(p, rel);
else {
size += Number(s.size);
files.push(rel);
}
}
};
await walk(abs, "");
return { size, files };
}
interface CheckCtx {
abs: string | null;
rootReal: string | null;
libraryRootId: string | null;
}
async function checkItem(
d: FmDeps,
body: FilesDeleteRequest,
it: { root: string; path: string },
downloads: Awaited<ReturnType<typeof managedDownloads>>,
): Promise<{ check: FileDeleteCheck; ctx: CheckCtx }> {
const empty = (blocked: FileDeleteCheck["blocked"]): { check: FileDeleteCheck; ctx: CheckCtx } => ({
check: {
root: it.root as FileDeleteCheck["root"],
path: it.path,
isDir: false,
size: 0,
blocked,
downloads: [],
assets: [],
bindings: [],
sharedInode: false,
},
ctx: { abs: null, rootReal: null, libraryRootId: null },
});
let r: Awaited<ReturnType<typeof resolveIn>>;
try {
r = await resolveIn(d, it.root, it.path);
} catch (e) {
if (e instanceof FilesError && e.reason === "root_unavailable") return empty("root_unavailable");
if (e instanceof FilesError && e.reason === "not_found") return empty("not_found");
if (e instanceof FilesError && e.reason === "path_escape") return empty("escape");
throw e;
}
if (r.rel === "") return empty("is_root");
const isDir = !!r.lst?.isDirectory() && !r.isSymlink;
const size = r.isSymlink ? 0 : (await sizeOf(r.abs)).size;
const dls = downloadsFor(downloads, r.abs);
const lib = r.def.libraryRootId;
const assets = lib
? (
await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "a.id as asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", lib)
.where("l.status", "=", "available")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", r.rel), eb("l.relative_path", "like", likePrefix(r.rel))])
: eb("l.relative_path", "=", r.rel),
)
.execute()
).filter((a) => a.media_entity_id)
: [];
const bindings = isDir ? await bindingsAt(d, r.def, r.rel, true) : [];
let blocked: FileDeleteCheck["blocked"] = null;
if (!r.def.modifiable) blocked = "is_root";
else if (dls.some((t) => isDownloading(t.status) || t.hnr !== "satisfied")) blocked = "download_active";
else if (assets.length > 0 && !body.retireAssets) blocked = "needs_retire";
else if (bindings.length > 0 && !body.unbind) blocked = "needs_unbind";
return {
check: {
root: it.root as FileDeleteCheck["root"],
path: r.rel,
isDir,
size,
blocked,
downloads: dls.map((t) => ({ taskId: t.taskId, status: t.status, hnr: t.hnr })),
assets: assets.map((a) => ({
assetId: a.asset_id,
locationId: a.id,
mediaEntityId: a.media_entity_id!,
relativePath: a.relative_path,
})),
bindings,
sharedInode: !isDir && Number(r.lst?.nlink ?? 1) > 1,
},
ctx: { abs: r.abs, rootReal: r.rootReal, libraryRootId: lib },
};
}
const BLOCK_ERR: Record<
NonNullable<FileDeleteCheck["blocked"]>,
[ConstructorParameters<typeof FilesError>[0], string]
> = {
download_active: ["download_active", "属于纳管下载任务(未完成或做种义务未满足)"],
is_root: ["bad_request", "不能删除根本身或只读登记根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
root_unavailable: ["root_unavailable", "根不可用,不当作已删除"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径逃出根"],
};
export async function deleteFiles(
d: FmDeps,
principal: Principal | undefined,
body: FilesDeleteRequest,
): Promise<FilesDeleteResult> {
if (body.mode === "permanent") {
if (!d.options.allowPermanentDelete)
throw new FilesError("permanent_disabled", "永久删除未开启(FILES_ALLOW_PERMANENT_DELETE),请使用回收站");
if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
}
const downloads = await managedDownloads(d);
const results = [];
for (const it of body.items) results.push(await checkItem(d, body, it, downloads));
const checks = results.map((r) => r.check);
const requestHash = hashJson({
items: body.items,
mode: body.mode,
retireAssets: body.retireAssets,
unbind: body.unbind,
});
if (body.dryRun) {
const plan = await savePlan(d, "delete", requestHash, principal, { checks });
return {
dryRun: true,
planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(),
mode: body.mode,
checks,
trashed: [],
deleted: [],
failed: [],
recordId: null,
};
}
const blocked = checks.find((c) => c.blocked);
if (blocked) {
const [reason, msg] = BLOCK_ERR[blocked.blocked!];
throw new FilesError(reason, `${blocked.path}: ${msg}`);
}
let planId: string | null = null;
if (body.planId) planId = (await loadPlan(d, body.planId, "delete", requestHash)).id;
if (!isAdmin(principal)) {
// API Key:每次删除都要一次性审批,审批绑定 dryRun 的计划 id + 内容哈希
if (!planId) throw new FilesError("approval_required", "API Key 删除需先 dryRun 取得 planId 并获得一次性审批");
const plan = await loadPlan(d, planId, "delete", requestHash);
const ok = await consumeApproval(d, plan.id, plan.contentHash);
if (!ok) throw new FilesError("approval_required", `删除需要一次性审批(planType=files_plan, planId=${plan.id})`);
}
if (body.mode === "permanent" && body.items.length === 1 && body.confirmName !== basename(checks[0]!.path))
throw new FilesError("bad_request", "永久删除需在 confirmName 中输入文件名确认");
if (planId) await retirePlan(d, planId);
const now = d.ao.ports.now();
const expiresAt = new Date(now.getTime() + d.options.trashRetentionDays * 86_400_000);
const trashed: TrashItem[] = [];
const deleted: string[] = [];
const failed: FilesDeleteResult["failed"] = [];
const done: typeof results = [];
for (const res of results) {
const { check, ctx } = res;
const abs = ctx.abs!;
try {
await recheck(abs, ctx.rootReal!);
if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false });
deleted.push(check.path);
} else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt);
trashed.push(toTrashItem(row));
}
done.push(res);
} catch (e) {
failed.push({
path: check.path,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
}
}
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)
await d.ctx.db.transaction().execute(async (trx) => {
const locIds = done.flatMap((r) => r.check.assets.map((a) => a.locationId));
if (locIds.length > 0) {
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "in", locIds).execute();
const assetIds = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.assetId)))];
const retired = await sql<{ id: string }>`
update media_asset set status = 'retired'
where id = any(${assetIds}::uuid[])
and not exists (select 1 from asset_location l where l.media_asset_id = media_asset.id and l.status <> 'removed')
returning id`.execute(trx);
const retiredIds = retired.rows.map((x) => x.id);
if (retiredIds.length > 0) {
const slots = await trx
.updateTable("acquisition_slot")
.set({ status: "attention", status_reason: "asset_deleted" })
.where("current_asset_id", "in", retiredIds)
.returning(["id", "media_entity_id", "name"])
.execute();
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `Slot 满足资产已被删除:${s.name}`,
summary: "文件管理删除了满足该 Slot 的资产(进回收站或永久删除),Slot 退回 attention,请确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
evidence: { mediaEntityId: s.media_entity_id, assets: retiredIds, by: actorOfP(principal) },
});
}
}
const bindIds = done.flatMap((r) => r.check.bindings.map((b) => b.bindingId));
if (bindIds.length > 0)
await trx
.updateTable("path_binding")
.set({ status: "superseded", note: `files.delete by ${actorOfP(principal)}` })
.where("id", "in", bindIds)
.execute();
});
await audit(d, principal, body.mode === "permanent" ? "delete_permanent" : "delete", {
planId,
items: checks.map((c) => ({ root: c.root, path: c.path, assets: c.assets.length, bindings: c.bindings.length })),
trashed: trashed.map((t) => t.id),
deleted,
failed,
});
for (const c of checks) filesChanged(d, c.root, [c.path.includes("/") ? dirname(c.path) : ""]);
return {
dryRun: false,
planId,
planExpiresAt: null,
mode: body.mode,
checks,
trashed,
deleted,
failed,
recordId: null,
};
}
async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") {
const st = await lstat(src);
if (!st.isDirectory()) {
await copyWithVerify(src, dst, { hash, createParents: true });
return;
}
await mkdir(dst, { recursive: true });
for (const f of files) await copyWithVerify(join(src, f), join(dst, f), { hash, createParents: true });
}
async function moveToTrash(
d: FmDeps,
principal: Principal | undefined,
check: FileDeleteCheck,
ctx: CheckCtx,
expiresAt: Date,
): Promise<TrashItemRow> {
const abs = ctx.abs!;
const id = randomUUID();
const stamp = day(d.ao.ports.now());
let trashPath = join(ctx.rootReal!, TRASH_DIR, stamp, id, check.path);
let moved = false;
try {
await mkdir(dirname(trashPath), { recursive: true });
const r = await moveSafe(abs, trashPath);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在: ${trashPath}`);
moved = r.status === "moved";
} catch (e) {
if (e instanceof FilesError) throw e;
const code = errnoOf(e) ?? (e as { code?: string }).code;
if (!["EACCES", "EPERM", "EROFS", "EXDEV"].includes(code ?? "")) throw e;
}
if (!moved) {
// 根不可写或跨卷:复制到 DATA_DIR/trash(有上限),核验后删源
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes};请使用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
const { files } = await sizeOf(abs);
await copyTree(abs, trashPath, files, d.ao.ports.options.verifyHash);
await rm(abs, { recursive: true });
}
return d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: check.root,
library_root_id: ctx.libraryRootId,
root_host_path: ctx.rootReal!,
original_path: check.path,
trash_path: trashPath,
is_dir: check.isDir,
size_bytes: check.size,
actor: actorOfP(principal),
deleted_at: d.ao.ports.now(),
expires_at: expiresAt,
asset_snapshot:
check.assets.length || check.bindings.length ? jsonb({ assets: check.assets, bindings: check.bindings }) : null,
})
.returningAll()
.executeTakeFirstOrThrow();
}
export function toTrashItem(r: TrashItemRow): TrashItem {
return {
id: r.id,
rootId: r.root_id as TrashItem["rootId"],
originalPath: r.original_path,
trashPath: r.trash_path,
isDir: r.is_dir,
size: Number(r.size_bytes),
actor: r.actor,
deletedAt: r.deleted_at.toISOString(),
expiresAt: r.expires_at.toISOString(),
assetSnapshot: (r.asset_snapshot as Record<string, unknown> | null) ?? null,
};
}
export async function listTrash(d: FmDeps, q: FilesTrashQuery): Promise<FilesTrashList> {
let qb = d.ctx.db.selectFrom("trash_item").selectAll();
if (q.root) qb = qb.where("root_id", "=", q.root);
if (q.cursor) {
const [at, id] = Buffer.from(q.cursor, "base64url").toString("utf8").split("|");
if (!at || !id || Number.isNaN(Date.parse(at))) throw new FilesError("bad_request", "cursor 无效");
qb = qb.where(sql<boolean>`(deleted_at, id) < (${at}::timestamptz, ${id}::uuid)`);
}
const rows = await qb
.orderBy("deleted_at", "desc")
.orderBy("id", "desc")
.limit(q.limit + 1)
.execute();
const page = rows.slice(0, q.limit);
const last = page.at(-1);
return {
items: page.map(toTrashItem),
nextCursor:
rows.length > q.limit && last
? Buffer.from(`${last.deleted_at.toISOString()}|${last.id}`, "utf8").toString("base64url")
: null,
};
}
/** 回收站路径必须在某个根的 .umt-trash 或 DATA_DIR/trash 下(清除前的最后一道闸) */
function trashPathSafe(d: FmDeps, row: TrashItemRow): boolean {
const inRoot = within(row.trash_path, join(row.root_host_path, TRASH_DIR));
const inData = within(row.trash_path, join(resolve(d.options.dataDir), "trash"));
return (inRoot || inData) && !row.trash_path.split("/").includes("..");
}
export async function restoreTrash(
d: FmDeps,
principal: Principal | undefined,
body: FilesTrashRestoreRequest,
): Promise<FilesTrashRestoreResult> {
const restored: FilesTrashRestoreResult["restored"] = [];
const failed: FilesTrashRestoreResult["failed"] = [];
const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null;
if (target && (!target.lst?.isDirectory() || target.isSymlink))
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
let rootId = row.root_id;
let dst: string;
let rootReal: string;
if (target) {
rootId = body.target!.root;
rootReal = target.rootReal;
dst = join(target.abs, basename(row.original_path));
} else {
const def = await rootDef(d.ctx.db, row.root_id);
rootReal = await onlineRoot(d, def);
const parent = await resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal }).catch(
async (e) => {
// 父目录已不存在:在根内重建后再解析
if (e instanceof FilesError && e.reason === "not_found") {
const p = join(rootReal, dirname(row.original_path));
if (!within(resolve(p), rootReal)) throw e;
await mkdir(p, { recursive: true });
return resolveIn(d, row.root_id, row.original_path, { mustExist: false, def, rootReal });
}
throw e;
},
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择“还原到…”");
const r = await moveSafe(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
const { files } = await sizeOf(row.trash_path);
await copyTree(row.trash_path, dst, files, d.ao.ports.options.verifyHash);
await rm(row.trash_path, { recursive: true });
}
await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute();
const rel = dst === rootReal ? "" : dst.slice(rootReal.length + 1);
restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel });
filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]);
} catch (e) {
failed.push({
id,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e),
});
}
}
await audit(d, principal, "trash_restore", { restored, failed });
return { restored, failed };
}
/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */
export async function purgeTrash(
d: FmDeps,
principal: Principal | undefined,
body: Pick<FilesTrashPurgeRequest, "ids">,
): Promise<FilesTrashPurgeResult> {
let q = d.ctx.db.selectFrom("trash_item").selectAll();
q = body.ids ? q.where("id", "in", body.ids) : q.where("expires_at", "<=", d.ao.ports.now());
const rows = await q.limit(1000).execute();
const purged: string[] = [];
const failed: FilesTrashPurgeResult["failed"] = [];
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (!gone) continue;
await rm(row.trash_path, { recursive: true, force: true });
// 顺带清掉空的 <uuid>/<yyyymmdd> 目录
const uuidDir = row.trash_path.slice(0, row.trash_path.length - row.original_path.length - 1);
await rm(uuidDir, { recursive: true, force: true }).catch(() => undefined);
purged.push(row.id);
} catch (e) {
failed.push({ id: row.id, code: e instanceof FilesError ? e.reason : "io_error", message: errMessage(e) });
}
}
if (purged.length || failed.length)
await audit(d, principal, "trash_purge", { purged, failed, expiredOnly: !body.ids });
return { purged, failed };
}
+2
View File
@@ -1,6 +1,7 @@
import { acquisitionOrganizeModule } from "./acquisition-organize/index.ts";
import { agentModule } from "./agent/index.ts";
import { authSettingsModule } from "./auth-settings/index.ts";
import { fileManagerModule } from "./file-manager/index.ts";
import { sourcesIdentityModule } from "./sources-identity/index.ts";
import type { ServerModule } from "./types.ts";
@@ -15,4 +16,5 @@ export const SERVER_MODULES: readonly ServerModule[] = [
sourcesIdentityModule,
acquisitionOrganizeModule,
agentModule,
fileManagerModule,
];
@@ -0,0 +1,174 @@
import { mkdir, rename, symlink, utimes, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
type FmHarness,
pgAvailable,
READ_KEY,
seedAsset,
seedDownload,
seedEntity,
} from "./harness.ts";
// 读接口 + 路径守卫:只列登记根、路径逃逸(.. / URL 编码 / 符号链接 / 根外绝对路径)、离线 503、截断与分页、行内状态。
const skip = !(await pgAvailable());
const q = (o: Record<string, string>) => new URLSearchParams(o).toString();
describe.skipIf(skip)("files 浏览与守卫", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness("browse", { options: { listMaxEntries: 300 } });
await mkdir(join(h.lib, "Movie (2020)"));
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv"), "video");
await writeFile(join(h.lib, "Movie (2020)", "Other.2021.mkv"), "video2");
await writeFile(join(h.lib, "Movie (2020)", ".hidden"), "x");
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.mkv.uhd-partial"), "x");
await symlink(h.outside, join(h.lib, "escape-dir"));
await symlink(join(h.outside, "secret.txt"), join(h.lib, "escape-file.txt"));
await symlink(join(h.lib, "Movie (2020)"), join(h.lib, "inner-link"));
await mkdir(join(h.dl, "Show.S01.1080p"));
await writeFile(join(h.dl, "Show.S01.1080p", "Show.S01E01.mkv"), "ep1");
const e = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
await seedAsset(h.db, h, { entityId: e, rel: "Movie (2020)/Movie.2020.1080p.mkv" });
await seedDownload(h.db, h, { name: "Show.S01.1080p", status: "downloading" });
// 根为 "/" 的配置永不出现
await h.db
.insertInto("library_root")
.values({ name: "危险", media_kind: "mixed", local_path: "/", management_mode: "managed" })
.execute();
});
afterAll(async () => h?.close());
it("GET /files/roots 只列登记根,带健康与可写", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const items = r.body.data.items;
expect(items.map((x: { id: string }) => x.id).sort()).toEqual([h.dlRoot, h.libRoot].sort());
const lib = items.find((x: { id: string }) => x.id === h.libRoot);
expect(lib).toMatchObject({ kind: "library", health: "healthy", writable: true });
const dl = items.find((x: { id: string }) => x.id === h.dlRoot);
expect(dl).toMatchObject({ kind: "download", writable: false, mediaKind: null });
// API Key 只读 scope 可读
expect((await h.api("GET", "/files/roots", undefined, READ_KEY)).status).toBe(200);
});
it("列目录:行内业务状态、隐藏文件、符号链接逃逸标记", async () => {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)" })}`);
expect(r.status).toBe(200);
const byName = Object.fromEntries(r.body.data.entries.map((e: { name: string }) => [e.name, e]));
expect(Object.keys(byName).sort()).toEqual(["Movie.2020.1080p.mkv", "Other.2021.mkv"]);
expect(byName["Movie.2020.1080p.mkv"]).toMatchObject({ status: "registered", fileKind: "video" });
expect(byName["Movie.2020.1080p.mkv"].asset).toMatchObject({ title: "Movie", year: 2020 });
expect(byName["Other.2021.mkv"].status).toBe("unidentified");
const all = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", hidden: "true" })}`);
const names = all.body.data.entries.map((e: { name: string }) => e.name);
expect(names).toContain(".hidden");
const partial = all.body.data.entries.find((e: { name: string }) => e.name.endsWith(".uhd-partial"));
expect(partial.status).toBe("temp");
const root = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
const esc = root.body.data.entries.find((e: { name: string }) => e.name === "escape-dir");
expect(esc).toMatchObject({ type: "symlink", escape: true });
const inner = root.body.data.entries.find((e: { name: string }) => e.name === "inner-link");
expect(inner.escape).toBe(false);
const dir = root.body.data.entries.find((e: { name: string }) => e.name === "Movie (2020)");
expect(dir.dirSummary).toMatchObject({ videoCount: 2, registeredCount: 1 });
});
it("下载根:纳管下载中的内容标 downloading", async () => {
const r = await h.api("GET", `/files?${q({ root: h.dlRoot })}`);
const e = r.body.data.entries.find((x: { name: string }) => x.name === "Show.S01.1080p");
expect(e.status).toBe("downloading");
expect(e.download.status).toBe("downloading");
const inner = await h.api("GET", `/files?${q({ root: h.dlRoot, path: "Show.S01.1080p" })}`);
expect(inner.body.data.entries[0].status).toBe("downloading");
});
it("路径逃逸一律拒绝:..、URL 编码、根外绝对路径、符号链接(目录与文件)、未登记根", async () => {
const bad = [
`/files?${q({ root: h.libRoot, path: ".." })}`,
`/files?${q({ root: h.libRoot, path: "Movie (2020)/../.." })}`,
`/files?root=${h.libRoot}&path=..%2F..%2Fetc`,
`/files?${q({ root: h.libRoot, path: "/etc" })}`,
`/files?${q({ root: h.libRoot, path: "escape-dir" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-dir/secret.txt" })}`,
`/files/stat?${q({ root: h.libRoot, path: "escape-file.txt" })}`,
];
for (const p of bad) {
const r = await h.api("GET", p);
expect([400, 422], p).toContain(r.status);
expect(JSON.stringify(r.body)).not.toContain("top secret");
}
const unknown = await h.api("GET", `/files?${q({ root: "lib:00000000-0000-4000-8000-000000000000" })}`);
expect(unknown.status).toBe(404);
// 根内符号链接(目标仍在根内)可以浏览
const ok = await h.api("GET", `/files?${q({ root: h.libRoot, path: "inner-link" })}`);
expect(ok.status).toBe(200);
// 写操作同样受守卫
const mk = await h.api("POST", "/files/mkdir", {
idempotencyKey: "k-escape-0001",
root: h.libRoot,
path: "escape-dir",
name: "x",
});
expect(mk.status).toBe(400);
});
it("根离线(哨兵缺失)→ 503 root_unavailable,不返回空列表", async () => {
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
try {
const r = await h.api("GET", `/files?${q({ root: h.libRoot })}`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
const roots = await h.api("GET", "/files/roots");
expect(roots.body.data.items.find((x: { id: string }) => x.id === h.libRoot).health).toBe("offline");
} finally {
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
}
});
it("大目录截断 + 分页稳定 + 目录变化 stale + 过滤", async () => {
const big = join(h.lib, "big");
await mkdir(big);
for (let i = 0; i < 400; i++) await writeFile(join(big, `f${String(i).padStart(4, "0")}.txt`), "");
const r1 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100" })}`);
expect(r1.body.data).toMatchObject({ truncated: true, total: 300, stale: false });
expect(r1.body.data.entries).toHaveLength(100);
const seen = new Set(r1.body.data.entries.map((e: { name: string }) => e.name));
let cursor = r1.body.data.nextCursor;
while (cursor) {
const r = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", limit: "100", cursor })}`);
for (const e of r.body.data.entries) {
expect(seen.has(e.name)).toBe(false);
seen.add(e.name);
}
cursor = r.body.data.nextCursor;
}
expect(seen.size).toBe(300);
// 自然排序(截断取的是目录读取顺序的前 N 项,排序在其内进行)
const page1 = r1.body.data.entries.map((e: { name: string }) => e.name);
expect(page1).toEqual([...page1].sort());
// 目录 mtime 变化 → stale
await writeFile(join(big, "zz-new.txt"), "");
await utimes(big, new Date(), new Date(Date.now() + 5000));
const r2 = await h.api("GET", `/files?${q({ root: h.libRoot, path: "big", cursor: r1.body.data.nextCursor })}`);
expect(r2.body.data.stale).toBe(true);
// 过滤 + glob
const g = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", q: "movie*.mkv" })}`);
expect(g.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Movie.2020.1080p.mkv"]);
const f = await h.api("GET", `/files?${q({ root: h.libRoot, path: "Movie (2020)", filter: "unidentified" })}`);
expect(f.body.data.entries.map((e: { name: string }) => e.name)).toEqual(["Other.2021.mkv"]);
});
it("GET /files/stat:inode/nlink/sidecars", async () => {
await writeFile(join(h.lib, "Movie (2020)", "Movie.2020.1080p.chs.srt"), "sub");
const r = await h.api("GET", `/files/stat?${q({ root: h.libRoot, path: "Movie (2020)/Movie.2020.1080p.mkv" })}`);
expect(r.status).toBe(200);
expect(r.body.data.sidecars).toEqual(["Movie (2020)/Movie.2020.1080p.chs.srt"]);
expect(r.body.data.nlink).toBe(1);
expect(r.body.data.sharedInode).toBe(false);
});
});
+298
View File
@@ -0,0 +1,298 @@
import { randomUUID } from "node:crypto";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { capabilitySpec, DEFAULT_SETTINGS, GlobalSettings, type SettingsKey } from "@umt/contracts";
import { type DB, migrateToLatest } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { Kysely, PostgresDialect, sql } from "kysely";
import pg from "pg";
import { ConfigSchema } from "../../src/config.ts";
import { createOutboxPort } from "../../src/context.ts";
import { createApp } from "../../src/http/app.ts";
import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
// 文件管理测试装配:真实 PG(独立 schema)+ 临时目录(库根 / 下载根 / 根外目录)+ 记录入队的假引擎。
export const DB_URL = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
export const DEV_KEY = "dev-admin-fm-test-key";
export const READ_KEY = "fm-organize-read-key";
export const EXEC_KEY = "fm-organize-exec-key";
const KEYS = [
{ key: READ_KEY, scopes: ["organize.read"] },
{ key: EXEC_KEY, scopes: ["organize.execute"] },
];
export async function pgAvailable() {
if (process.env.UMT_SKIP_DB_TESTS === "1") return false;
const c = new pg.Client({ connectionString: DB_URL, connectionTimeoutMillis: 2000 });
try {
await c.connect();
await c.end();
return true;
} catch {
return false;
}
}
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读响应 JSON
type Loose = any;
export interface FmHarness {
db: Kysely<DB>;
engine: ReturnType<typeof fakeEngine>;
events: { type: string; payload: Loose }[];
base: string;
lib: string;
dl: string;
outside: string;
libRoot: string;
dlRoot: string;
libraryRootId: string;
instanceId: string;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
close(): Promise<void>;
}
export async function createFmHarness(
name: string,
overrides: FmOverrides = {},
opts: { catalog?: boolean } = {},
): Promise<FmHarness> {
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL });
await admin.connect();
await admin.query(`create schema ${schema}`);
await admin.end();
const db = new Kysely<DB>({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: DB_URL, max: 6, options: `-c search_path=${schema},public` }),
}),
});
await migrateToLatest(db, { migrationTableSchema: schema });
const base = await mkdtemp(join(tmpdir(), `fm-${name}-`));
const lib = join(base, "lib");
const dl = join(base, "dl");
const outside = join(base, "outside");
for (const p of [lib, dl, outside]) await mkdir(p, { recursive: true });
await writeFile(join(lib, ".uhd-root"), "sentinel\n");
await writeFile(join(outside, "secret.txt"), "top secret\n");
const logger = createLogger({ level: "silent" });
const engine = fakeEngine();
const config = ConfigSchema.parse({
DATABASE_URL: DB_URL,
SECRET_MASTER_KEY: "test-master-key-0123456789",
DEV_ADMIN_KEY: DEV_KEY,
DATA_DIR: join(base, "data"),
});
const bus = new EventBus(50);
const events: { type: string; payload: Loose }[] = [];
const busProxy = {
publish: async <P>(type: string, payload: P) => {
events.push({ type, payload });
return bus.publish(type, payload);
},
since: bus.since.bind(bus),
subscribe: bus.subscribe.bind(bus),
};
const capabilities = createCapabilityRegistry({ logger });
if (opts.catalog)
capabilities.register(capabilitySpec("catalog.lookup"), async (input) => ({
local: [],
remote: String(input.query).includes("Remote")
? [{ provider: "tmdb", entityType: "movie" as const, externalId: "999", title: "Remote Film", year: 2019 }]
: [],
}));
const ctx: ServerContext = {
role: "all",
db,
config,
logger,
events: busProxy,
engine,
outbox: createOutboxPort(),
secrets: { resolve: async () => "x", create: async () => "x", replace: async () => {}, remove: async () => {} },
settings: {
async all() {
const rows = await db.selectFrom("settings").select(["key", "value"]).execute();
const stored = new Map(rows.map((r) => [r.key, r.value]));
const out: Record<string, unknown> = {};
for (const k of Object.keys(DEFAULT_SETTINGS) as SettingsKey[]) {
const v = stored.get(k);
out[k] = GlobalSettings.shape[k].parse({ ...DEFAULT_SETTINGS[k], ...((v as object) ?? {}) });
}
return out as GlobalSettings;
},
async get(k) {
return (await this.all())[k];
},
},
capabilities,
};
const module = createFileManagerModule({
...overrides,
ao: {
probe: async () => ({ status: "unknown", reason: "ffprobe_unavailable" }),
...overrides.ao,
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
},
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options },
});
const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger,
bus,
role: "all",
version: "test",
db,
modules: [module],
ctx,
apiKeyStore: {
async findByHash(hash) {
const k = KEYS.find((x) => hashApiKey(x.key) === hash);
return k ? { id: k.key, name: k.key, scopes: k.scopes, revokedAt: null } : null;
},
async touch() {},
},
});
const root = await db
.insertInto("library_root")
.values({ name: "电影库", media_kind: "mixed", local_path: lib, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const inst = await db
.insertInto("downloader_instance")
.values({ name: "qb", base_url: "http://127.0.0.1:1", settings: JSON.stringify({ savePath: dl }) })
.returning("id")
.executeTakeFirstOrThrow();
return {
db,
engine,
events,
base,
lib,
dl,
outside,
libRoot: `lib:${root.id}`,
dlRoot: `dl:${inst.id}:0`,
libraryRootId: root.id,
instanceId: inst.id,
async api(method, path, body, key = DEV_KEY) {
const res = await app.request(`/api/v1${path}`, {
method,
headers: {
authorization: `Bearer ${key}`,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { status: res.status, body: await res.json() };
},
async close() {
await db.destroy();
const c = new pg.Client({ connectionString: DB_URL });
await c.connect();
await c.query(`drop schema if exists ${schema} cascade`);
await c.end();
await rm(base, { recursive: true, force: true });
},
};
}
export const key = () => randomUUID();
export async function seedEntity(db: Kysely<DB>, e: { kind: "movie" | "series"; title: string; year: number }) {
const id = (
await db
.insertInto("media_entity")
.values({ kind: e.kind, title: e.title, year: e.year, status: "active" })
.returning("id")
.executeTakeFirstOrThrow()
).id;
await db
.insertInto("title_alias")
.values({ media_entity_id: id, alias: e.title, normalized: normalizeTitle(e.title), source: "user" })
.execute();
return id;
}
/** 已登记资产 + 库内位置(relative_path 相对库根) */
export async function seedAsset(
db: Kysely<DB>,
h: Pick<FmHarness, "libraryRootId">,
a: { entityId: string; rel: string; kind?: "video" | "subtitle"; sidecarOf?: string },
) {
const asset = await db
.insertInto("media_asset")
.values({ media_entity_id: a.entityId, kind: a.kind ?? "video", size_bytes: 10 })
.returning("id")
.executeTakeFirstOrThrow();
const loc = await db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: h.libraryRootId,
relative_path: a.rel,
status: "available",
is_primary: !a.sidecarOf,
sidecar_of_location_id: a.sidecarOf ?? null,
})
.returning("id")
.executeTakeFirstOrThrow();
return { assetId: asset.id, locationId: loc.id };
}
export async function seedBinding(
db: Kysely<DB>,
h: Pick<FmHarness, "libraryRootId">,
b: { entityId: string; rel: string },
) {
return (
await db
.insertInto("path_binding")
.values({
media_entity_id: b.entityId,
binding_kind: "entity",
library_root_id: h.libraryRootId,
relative_path: b.rel,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
/** 纳管下载任务(内容 = 下载根下的 name) */
export async function seedDownload(
db: Kysely<DB>,
h: Pick<FmHarness, "instanceId" | "dl">,
t: { name: string; status: "downloading" | "completed" },
) {
return (
await db
.insertInto("download_task")
.values({
instance_id: h.instanceId,
torrent_hash: randomUUID().replace(/-/g, "").padEnd(40, "0").slice(0, 40),
name: t.name,
save_path: h.dl,
content_path: join(h.dl, t.name),
managed: true,
status: t.status,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}
export { sql };
@@ -0,0 +1,160 @@
import { appendFile, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createFmHarness, type FmHarness, key, pgAvailable, READ_KEY, seedAsset, seedEntity } from "./harness.ts";
// identify(只读,本地 + 远端召回前 5,不落库)、organize(按作品分组走 planImport,dryRun→execute 一致与源变更)、
// probe(已登记写回 media_info)、scrape(仅已登记作品,入队 refreshMetadata;NFO 待 W5)。
const skip = !(await pgAvailable());
describe.skipIf(skip)("files 识别 / 整理 / 探测 / 刮削", () => {
let h: FmHarness;
let show: string;
let film: string;
beforeAll(async () => {
h = await createFmHarness(
"ident",
{
ao: {
probe: async () => ({
status: "ok",
container: "matroska",
durationSec: 60,
video: [{ codec: "hevc", width: 3840, height: 2160, hdr: "HDR10" }],
audio: [{ codec: "truehd" }],
subtitles: [],
}),
},
},
{ catalog: true },
);
show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 });
film = await seedEntity(h.db, { kind: "movie", title: "Blue Film", year: 2021 });
await mkdir(join(h.dl, "Some.Show.S01.1080p.WEB-DL"));
await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E02.1080p.WEB-DL-GRP.mkv"), "ep2");
await writeFile(join(h.dl, "Some.Show.S01.1080p.WEB-DL", "Some.Show.S01E03.1080p.WEB-DL-GRP.mkv"), "ep3");
await writeFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "film-bytes");
await writeFile(join(h.dl, "Remote.Film.2019.1080p.mkv"), "remote");
});
afterAll(async () => h?.close());
it("identify:目录展开为视频,本地候选命中,季集解析,不写库", async () => {
const before = await h.db.selectFrom("media_entity").select(["id"]).execute();
const r = await h.api(
"POST",
"/files/identify",
{ items: [{ root: h.dlRoot, path: "Some.Show.S01.1080p.WEB-DL" }] },
READ_KEY,
);
expect(r.status).toBe(200);
expect(r.body.data.items).toHaveLength(2);
const it0 = r.body.data.items[0];
expect(it0.parsed).toMatchObject({ season: 1, episodes: [2], resolution: "1080p" });
expect(it0.candidates[0]).toMatchObject({ mediaEntityId: show, provider: "local", title: "Some Show" });
expect(it0.candidates.length).toBeLessThanOrEqual(5);
expect(it0.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S01E02" });
const after = await h.db.selectFrom("media_entity").select(["id"]).execute();
expect(after).toHaveLength(before.length);
const pool = await h.db.selectFrom("identity_candidate").select(["id"]).execute();
expect(pool).toHaveLength(0);
});
it("identify:远端候选(catalog.lookup)", async () => {
const r = await h.api("POST", "/files/identify", {
items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }],
});
expect(r.status).toBe(200);
const c = r.body.data.items[0].candidates;
expect(c[0]).toMatchObject({ provider: "tmdb", externalId: "999", mediaEntityId: null, kind: "movie" });
});
it("organize:dryRun 返回 planId 与前后路径;源变更 → 该组 plan_stale;重新预览后执行入队 verifyAndImport", async () => {
const items = [{ root: h.dlRoot, path: "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv", mediaEntityId: film }];
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true });
expect(dry.status).toBe(200);
const g = dry.body.data.groups[0];
expect(g.error).toBeNull();
expect(g.planId).toBeTruthy();
expect(g.files[0].source).toBe(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"));
expect(g.files[0].target).toContain(join(h.lib, "Blue Film (2021)"));
await appendFile(join(h.dl, "Blue.Film.2021.2160p.BluRay.x265-GRP.mkv"), "+");
const stale = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
dryRun: false,
planIds: [g.planId],
});
expect(stale.status).toBe(200);
expect(stale.body.data.groups[0].error.code).toBe("plan_stale");
expect(stale.body.data.recordIds).toEqual([]);
const dry2 = await h.api("POST", "/files/organize", { idempotencyKey: key(), items, action: "copy", dryRun: true });
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
dryRun: false,
planIds: [dry2.body.data.groups[0].planId],
});
expect(exec.status).toBe(200);
expect(exec.body.data.groups[0].error).toBeNull();
expect(exec.body.data.recordIds).toHaveLength(1);
expect(exec.body.data.groups[0].files.map((f: { target: string }) => f.target)).toEqual(
dry2.body.data.groups[0].files.map((f: { target: string }) => f.target),
);
expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true);
const rec = await h.db
.selectFrom("organization_record")
.select(["trigger", "action", "media_entity_id"])
.where("id", "=", exec.body.data.recordIds[0])
.executeTakeFirstOrThrow();
expect(rec).toEqual({ trigger: "manual", action: "copy", media_entity_id: film });
});
it("organize:路径逃逸同样拒绝", async () => {
const r = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items: [{ root: h.dlRoot, path: "../outside/secret.txt", mediaEntityId: film }],
action: "copy",
dryRun: true,
});
expect(r.status).toBeGreaterThanOrEqual(400);
expect(r.status).toBeLessThan(500);
});
it("probe:已登记资产写回 media_info;scrape 仅已登记作品", async () => {
await mkdir(join(h.lib, "Blue Film probe"));
await writeFile(join(h.lib, "Blue Film probe", "Blue.Film.2021.mkv"), "x");
const a = await seedAsset(h.db, h, { entityId: film, rel: "Blue Film probe/Blue.Film.2021.mkv" });
const r = await h.api("POST", "/files/probe", {
items: [{ root: h.libRoot, path: "Blue Film probe/Blue.Film.2021.mkv" }],
});
expect(r.status).toBe(200);
expect(r.body.data.items[0]).toMatchObject({
status: "ok",
savedToAsset: true,
summary: { resolution: "3840x2160", hdr: "HDR10", videoCodec: "hevc", audioTracks: 1 },
});
const row = await h.db
.selectFrom("media_asset")
.select(["media_info"])
.where("id", "=", a.assetId)
.executeTakeFirstOrThrow();
expect((row.media_info as { probe?: { status: string } }).probe?.status).toBe("ok");
// 未登记:只缓存
const u = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] });
expect(u.body.data.items[0]).toMatchObject({ status: "ok", savedToAsset: false, cached: false });
const u2 = await h.api("POST", "/files/probe", { items: [{ root: h.dlRoot, path: "Remote.Film.2019.1080p.mkv" }] });
expect(u2.body.data.items[0].cached).toBe(true);
const s = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: film, writeNfo: true });
expect(s.status).toBe(200);
expect(s.body.data).toMatchObject({ mediaEntityId: film, nfo: "not_implemented", images: "not_requested" });
expect(h.engine.enqueued.some((e) => e.task === "refreshMetadata")).toBe(true);
const none = await h.api("POST", "/files/scrape", { idempotencyKey: key(), mediaEntityId: show });
expect(none.status).toBe(400);
});
});
+214
View File
@@ -0,0 +1,214 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
seedAsset,
seedBinding,
seedDownload,
seedEntity,
} from "./harness.ts";
// 删除保护与回收站:引用检查(做种 / 资产 / 绑定 / 根本身)、退役联动、回收站往返、原位被占 409、purge 只清过期、
// 永久删除开关与 admin 会话、API Key 需一次性审批。
const skip = !(await pgAvailable());
const exists = (p: string) =>
access(p).then(
() => true,
() => false,
);
describe.skipIf(skip)("files 删除与回收站", () => {
let h: FmHarness;
let entity: string;
beforeAll(async () => {
h = await createFmHarness("trash");
entity = await seedEntity(h.db, { kind: "movie", title: "Gone", year: 2001 });
});
afterAll(async () => h?.close());
const del = (body: Record<string, unknown>, k?: string) =>
h.api("POST", "/files/delete", { idempotencyKey: key(), ...body }, k);
it("纳管做种文件 409;根本身拒绝;dryRun 列出引用", async () => {
await writeFile(join(h.dl, "Seed.2020.mkv"), "seed");
await seedDownload(h.db, h, { name: "Seed.2020.mkv", status: "completed" });
const dry = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.checks[0]).toMatchObject({ blocked: "download_active" });
expect(dry.body.data.checks[0].downloads[0].hnr).toBe("unknown");
const exec = await del({ items: [{ root: h.dlRoot, path: "Seed.2020.mkv" }], dryRun: false });
expect(exec.status).toBe(409);
expect(await exists(join(h.dl, "Seed.2020.mkv"))).toBe(true);
const root = await del({ items: [{ root: h.libRoot, path: "" }], dryRun: false });
expect(root.status).toBe(400);
});
it("已登记资产:未勾选退役 409;勾选后进回收站、location=removed、asset=retired、Slot→attention + 待处理", async () => {
await mkdir(join(h.lib, "Gone (2001)"));
await writeFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "gone");
const a = await seedAsset(h.db, h, { entityId: entity, rel: "Gone (2001)/Gone.2001.mkv" });
const sub = await h.db
.insertInto("subscription")
.values({ media_entity_id: entity, media_kind: "movie", mode: "continuous" })
.returning("id")
.executeTakeFirstOrThrow();
const slot = await h.db
.insertInto("acquisition_slot")
.values({
media_entity_id: entity,
subscription_id: sub.id,
name: "main",
status: "satisfied",
current_asset_id: a.assetId,
})
.returning("id")
.executeTakeFirstOrThrow();
const items = [{ root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }];
const no = await del({ items, dryRun: false });
expect(no.status).toBe(409);
expect(no.body.error.message).toMatch(/^needs_retire:/);
const ok = await del({ items, dryRun: false, retireAssets: true });
expect(ok.status).toBe(200);
expect(ok.body.data.trashed).toHaveLength(1);
const t = ok.body.data.trashed[0];
expect(t.trashPath).toContain(join(h.lib, ".umt-trash"));
expect(await exists(join(h.lib, "Gone (2001)", "Gone.2001.mkv"))).toBe(false);
expect(await readFile(t.trashPath, "utf8")).toBe("gone");
const loc = await h.db
.selectFrom("asset_location")
.select(["status"])
.where("id", "=", a.locationId)
.executeTakeFirstOrThrow();
expect(loc.status).toBe("removed");
const asset = await h.db
.selectFrom("media_asset")
.select(["status"])
.where("id", "=", a.assetId)
.executeTakeFirstOrThrow();
expect(asset.status).toBe("retired");
const s = await h.db
.selectFrom("acquisition_slot")
.select(["status"])
.where("id", "=", slot.id)
.executeTakeFirstOrThrow();
expect(s.status).toBe("attention");
const dec = await h.db.selectFrom("decision").select(["subject_id"]).where("subject_id", "=", slot.id).execute();
expect(dec).toHaveLength(1);
// 回收站列表 → 还原到原位
const list = await h.api("GET", "/files/trash");
expect(list.body.data.items.map((x: { id: string }) => x.id)).toContain(t.id);
const restore = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [t.id] });
expect(restore.status).toBe(200);
expect(restore.body.data.restored).toEqual([{ id: t.id, root: h.libRoot, path: "Gone (2001)/Gone.2001.mkv" }]);
expect(await readFile(join(h.lib, "Gone (2001)", "Gone.2001.mkv"), "utf8")).toBe("gone");
});
it("还原时原位被占 → 该项 conflict;可还原到其他目录", async () => {
await writeFile(join(h.lib, "dup.txt"), "v1");
const d1 = await del({ items: [{ root: h.libRoot, path: "dup.txt" }], dryRun: false });
const id = d1.body.data.trashed[0].id;
await writeFile(join(h.lib, "dup.txt"), "v2");
const r = await h.api("POST", "/files/trash/restore", { idempotencyKey: key(), ids: [id] });
expect(r.body.data.failed).toEqual([expect.objectContaining({ id, code: "exists" })]);
expect(await readFile(join(h.lib, "dup.txt"), "utf8")).toBe("v2");
await mkdir(join(h.lib, "restored"));
const r2 = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [id],
target: { root: h.libRoot, path: "restored" },
});
expect(r2.body.data.restored[0].path).toBe("restored/dup.txt");
expect(await readFile(join(h.lib, "restored", "dup.txt"), "utf8")).toBe("v1");
});
it("绑定目录需勾选解除绑定", async () => {
await mkdir(join(h.lib, "Bound"));
const b = await seedBinding(h.db, h, { entityId: entity, rel: "Bound" });
const no = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false });
expect(no.status).toBe(409);
expect(no.body.error.message).toMatch(/^needs_unbind:/);
const ok = await del({ items: [{ root: h.libRoot, path: "Bound" }], dryRun: false, unbind: true });
expect(ok.status).toBe(200);
const row = await h.db.selectFrom("path_binding").select(["status"]).where("id", "=", b).executeTakeFirstOrThrow();
expect(row.status).toBe("superseded");
});
it("purge 只清过期项;API Key 不能 purge", async () => {
await writeFile(join(h.lib, "old.txt"), "o");
await writeFile(join(h.lib, "new.txt"), "n");
const a = await del({ items: [{ root: h.libRoot, path: "old.txt" }], dryRun: false });
const b = await del({ items: [{ root: h.libRoot, path: "new.txt" }], dryRun: false });
const oldId = a.body.data.trashed[0].id;
const newId = b.body.data.trashed[0].id;
await h.db
.updateTable("trash_item")
.set({ expires_at: new Date(Date.now() - 1000) })
.where("id", "=", oldId)
.execute();
const denied = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() }, EXEC_KEY);
expect(denied.status).toBe(403);
const r = await h.api("POST", "/files/trash/purge", { idempotencyKey: key() });
expect(r.status).toBe(200);
expect(r.body.data.purged).toEqual([oldId]);
expect(await exists(a.body.data.trashed[0].trashPath)).toBe(false);
expect(await exists(b.body.data.trashed[0].trashPath)).toBe(true);
const left = await h.db.selectFrom("trash_item").select(["id"]).execute();
expect(left.map((x) => x.id)).toContain(newId);
expect(left.map((x) => x.id)).not.toContain(oldId);
});
it("永久删除默认关闭;API Key 删除需一次性审批", async () => {
await writeFile(join(h.lib, "perm.txt"), "p");
const p = await del({ items: [{ root: h.libRoot, path: "perm.txt" }], mode: "permanent", dryRun: false });
expect(p.status).toBe(403);
expect(p.body.error.message).toMatch(/^permanent_disabled:/);
const items = [{ root: h.libRoot, path: "perm.txt" }];
const dry = await del({ items, dryRun: true }, EXEC_KEY);
expect(dry.status).toBe(200);
const noApproval = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY);
expect(noApproval.status).toBe(403);
expect(noApproval.body.error.message).toMatch(/^approval_required:/);
// 管理员批准该计划(绑定内容哈希)后可执行一次
const art = await h.db
.selectFrom("artifact")
.select(["content_hash"])
.where("id", "=", dry.body.data.planId)
.executeTakeFirstOrThrow();
await h.db
.insertInto("approval")
.values({
plan_type: "files_plan",
plan_id: dry.body.data.planId,
payload_hash: art.content_hash,
expected_revision: 0,
status: "approved",
requested_by: "api_key:x",
})
.execute();
const ok = await del({ items, dryRun: false, planId: dry.body.data.planId }, EXEC_KEY);
expect(ok.status).toBe(200);
expect(await exists(join(h.lib, "perm.txt"))).toBe(false);
});
it("离线根:删除检查返回 root_unavailable,不当作已删除", async () => {
const { rename } = await import("node:fs/promises");
await writeFile(join(h.lib, "keep.txt"), "k");
await rename(join(h.lib, ".uhd-root"), join(h.lib, ".uhd-root.bak"));
try {
const r = await del({ items: [{ root: h.libRoot, path: "keep.txt" }], dryRun: false });
expect(r.status).toBe(503);
} finally {
await rename(join(h.lib, ".uhd-root.bak"), join(h.lib, ".uhd-root"));
}
expect(await exists(join(h.lib, "keep.txt"))).toBe(true);
});
});
+348
View File
@@ -0,0 +1,348 @@
import { access, appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
READ_KEY,
seedAsset,
seedBinding,
seedDownload,
seedEntity,
} from "./harness.ts";
// 写接口:mkdir / rename(不覆盖、同步 asset_location + sidecar、目录改名写 path_binding 修订、做种 409)、
// move/copy 的 dryRun→execute 一致性与源变更拒绝、planId 与请求不符、幂等重放。
const skip = !(await pgAvailable());
const exists = (p: string) =>
access(p).then(
() => true,
() => false,
);
describe.skipIf(skip)("files 写操作", () => {
let h: FmHarness;
let entity: string;
beforeAll(async () => {
h = await createFmHarness("write");
entity = await seedEntity(h.db, { kind: "movie", title: "Movie", year: 2020 });
});
afterAll(async () => h?.close());
it("mkdir:非递归、已存在 409、非法名 400、只读 scope 403、下载根不可写", async () => {
const ok = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "新目录",
});
expect(ok.status).toBe(200);
expect(await exists(join(h.lib, "新目录"))).toBe(true);
const dup = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "新目录",
});
expect(dup.status).toBe(409);
const bad = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: h.libRoot, path: "", name: "a/b" });
expect(bad.status).toBeGreaterThanOrEqual(400);
const trailing = await h.api("POST", "/files/mkdir", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
name: "x.",
});
expect(trailing.status).toBe(400);
const ro = await h.api(
"POST",
"/files/mkdir",
{ idempotencyKey: key(), root: h.libRoot, path: "", name: "y" },
READ_KEY,
);
expect(ro.status).toBe(403);
const dl = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: h.dlRoot, path: "", name: "y" });
expect(dl.status).toBe(403);
const audit = await h.db.selectFrom("audit_event").select(["action"]).where("action", "=", "files.mkdir").execute();
expect(audit).toHaveLength(1);
});
it("rename 文件:sidecar 一起改,asset_location 同步,同名存在 409 且原文件不动", async () => {
const dir = join(h.lib, "Movie (2020)");
await mkdir(dir);
await writeFile(join(dir, "Movie.2020.mkv"), "video");
await writeFile(join(dir, "Movie.2020.chs.srt"), "sub");
await writeFile(join(dir, "Taken.mkv"), "other");
const main = await seedAsset(h.db, h, { entityId: entity, rel: "Movie (2020)/Movie.2020.mkv" });
const sub = await seedAsset(h.db, h, {
entityId: entity,
rel: "Movie (2020)/Movie.2020.chs.srt",
kind: "subtitle",
sidecarOf: main.locationId,
});
const clash = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Taken.mkv",
});
expect(clash.status).toBe(409);
expect(await readFile(join(dir, "Movie.2020.mkv"), "utf8")).toBe("video");
expect(await readFile(join(dir, "Taken.mkv"), "utf8")).toBe("other");
const preview = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Movie (2020) 1080p.mkv",
dryRun: true,
});
expect(preview.status).toBe(200);
expect(preview.body.data.renames).toEqual([
{ from: "Movie (2020)/Movie.2020.mkv", to: "Movie (2020)/Movie (2020) 1080p.mkv", role: "main" },
{ from: "Movie (2020)/Movie.2020.chs.srt", to: "Movie (2020)/Movie (2020) 1080p.chs.srt", role: "sidecar" },
]);
expect(preview.body.data.assetLocations).toBe(2);
expect(await exists(join(dir, "Movie.2020.mkv"))).toBe(true);
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)/Movie.2020.mkv",
newName: "Movie (2020) 1080p.mkv",
});
expect(r.status).toBe(200);
expect(r.body.data.assetLocations).toBe(2);
expect(r.body.data.recordId).toBeTruthy();
expect(await exists(join(dir, "Movie (2020) 1080p.mkv"))).toBe(true);
expect(await exists(join(dir, "Movie (2020) 1080p.chs.srt"))).toBe(true);
const locs = await h.db
.selectFrom("asset_location")
.select(["id", "relative_path"])
.where("id", "in", [main.locationId, sub.locationId])
.execute();
expect(Object.fromEntries(locs.map((l) => [l.id, l.relative_path]))).toEqual({
[main.locationId]: "Movie (2020)/Movie (2020) 1080p.mkv",
[sub.locationId]: "Movie (2020)/Movie (2020) 1080p.chs.srt",
});
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status", "media_entity_id"])
.where("id", "=", r.body.data.recordId)
.executeTakeFirstOrThrow();
expect(rec).toMatchObject({ action: "rename", status: "succeeded", media_entity_id: entity });
const ops = await h.db.selectFrom("asset_operation").select(["kind"]).where("kind", "=", "rename").execute();
expect(ops.length).toBe(2);
expect(h.events.some((e) => e.type === "files.changed")).toBe(true);
});
it("rename 目录命中绑定:旧绑定 superseded 且链接到新绑定,目录下位置前缀替换", async () => {
const bindingId = await seedBinding(h.db, h, { entityId: entity, rel: "Movie (2020)" });
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "Movie (2020)",
newName: "Movie 2020 Collection",
});
expect(r.status).toBe(200);
expect(r.body.data.bindings).toHaveLength(1);
const old = await h.db.selectFrom("path_binding").selectAll().where("id", "=", bindingId).executeTakeFirstOrThrow();
expect(old.status).toBe("superseded");
const nb = await h.db
.selectFrom("path_binding")
.selectAll()
.where("id", "=", old.superseded_by_id!)
.executeTakeFirstOrThrow();
expect(nb).toMatchObject({ status: "active", relative_path: "Movie 2020 Collection", media_entity_id: entity });
expect(nb.bound_by).toContain("dev_admin");
const locs = await h.db
.selectFrom("asset_location")
.select(["relative_path"])
.where("library_root_id", "=", h.libraryRootId)
.execute();
expect(locs.map((l) => l.relative_path).sort()).toEqual([
"Movie 2020 Collection/Movie (2020) 1080p.chs.srt",
"Movie 2020 Collection/Movie (2020) 1080p.mkv",
]);
});
it("rename:纳管做种目录 409;根本身 400", async () => {
await mkdir(join(h.dl, "Seeding.Pack"));
await writeFile(join(h.dl, "Seeding.Pack", "a.mkv"), "a");
await seedDownload(h.db, h, { name: "Seeding.Pack", status: "completed" });
const r = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.dlRoot,
path: "Seeding.Pack",
newName: "Renamed.Pack",
});
expect(r.status).toBe(409);
expect(r.body.error.message).toMatch(/^download_active:/);
expect(await exists(join(h.dl, "Seeding.Pack"))).toBe(true);
const root = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: h.libRoot,
path: "",
newName: "x",
});
expect(root.status).toBe(400);
});
it("move:dryRun → execute 一致;源变更 → plan_stale;请求与 planId 不符 → plan_stale;幂等重放不重复执行", async () => {
await mkdir(join(h.lib, "inbox"));
await writeFile(join(h.dl, "Loose.File.2019.mkv"), "loose-v1");
const body = {
items: [{ root: h.dlRoot, path: "Loose.File.2019.mkv" }],
target: { root: h.libRoot, path: "inbox" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.pairs).toEqual([
{
source: { root: h.dlRoot, path: "Loose.File.2019.mkv" },
target: { root: h.libRoot, path: "inbox/Loose.File.2019.mkv" },
size: 8,
conflict: false,
registered: false,
},
]);
const planId = dry.body.data.planId;
// 执行必须带 planId
const noPlan = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false });
expect(noPlan.status).toBe(400);
// planId 与请求不符(换了目标)
await mkdir(join(h.lib, "other"));
const mismatch = await h.api("POST", "/files/move", {
...body,
target: { root: h.libRoot, path: "other" },
idempotencyKey: key(),
dryRun: false,
planId,
});
expect(mismatch.status).toBe(409);
expect(mismatch.body.error.message).toMatch(/^plan_stale:/);
// 源在预览后变化
await appendFile(join(h.dl, "Loose.File.2019.mkv"), "-changed");
const stale = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: false, planId });
expect(stale.status).toBe(409);
expect(stale.body.error.message).toMatch(/^plan_stale:/);
expect(await exists(join(h.dl, "Loose.File.2019.mkv"))).toBe(true);
// 重新预览后执行;同一幂等键重放返回首次结果
const dry2 = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
const k = key();
const exec = await h.api("POST", "/files/move", {
...body,
idempotencyKey: k,
dryRun: false,
planId: dry2.body.data.planId,
});
expect(exec.status).toBe(200);
expect(exec.body.data.executed.succeeded).toEqual(["Loose.File.2019.mkv"]);
expect(exec.body.data.pairs).toEqual(dry2.body.data.pairs);
expect(await readFile(join(h.lib, "inbox", "Loose.File.2019.mkv"), "utf8")).toBe("loose-v1-changed");
expect(await exists(join(h.dl, "Loose.File.2019.mkv"))).toBe(false);
const replay = await h.api("POST", "/files/move", {
...body,
idempotencyKey: k,
dryRun: false,
planId: dry2.body.data.planId,
});
expect(replay.status).toBe(200);
expect(replay.body.data).toEqual(exec.body.data);
// 用过的 planId 不能再执行
const again = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry2.body.data.planId,
});
expect(again.status).toBeGreaterThanOrEqual(400);
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status"])
.where("id", "=", exec.body.data.executed.recordId)
.executeTakeFirstOrThrow();
expect(rec).toEqual({ action: "relocate", status: "succeeded" });
});
it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => {
const { rm, symlink } = await import("node:fs/promises");
await writeFile(join(h.dl, "Swap.mkv"), "swap");
const body = {
items: [{ root: h.dlRoot, path: "Swap.mkv" }],
target: { root: h.libRoot, path: "inbox" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
await rm(join(h.dl, "Swap.mkv"));
await symlink(join(h.outside, "secret.txt"), join(h.dl, "Swap.mkv"));
const r = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry.body.data.planId,
});
expect(r.status).toBe(400);
expect(r.body.error.message).toMatch(/^path_escape:/);
expect(await readFile(join(h.outside, "secret.txt"), "utf8")).toBe("top secret\n");
expect(await exists(join(h.lib, "inbox", "Swap.mkv"))).toBe(false);
});
it("move 已登记资产:asset_location 跟随;API Key 无授权 → 403;copy 不删源", async () => {
await mkdir(join(h.lib, "A"));
await mkdir(join(h.lib, "B"));
await writeFile(join(h.lib, "A", "Reg.2018.mkv"), "reg");
const a = await seedAsset(h.db, h, { entityId: entity, rel: "A/Reg.2018.mkv" });
const body = {
items: [{ root: h.libRoot, path: "A/Reg.2018.mkv" }],
target: { root: h.libRoot, path: "B" },
mode: "move",
};
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(dry.body.data.pairs[0].registered).toBe(true);
const denied = await h.api(
"POST",
"/files/move",
{ ...body, idempotencyKey: key(), dryRun: false, planId: dry.body.data.planId },
EXEC_KEY,
);
expect(denied.status).toBe(403);
const dry2 = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
const ok = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry2.body.data.planId,
});
expect(ok.status).toBe(200);
const loc = await h.db
.selectFrom("asset_location")
.select(["relative_path"])
.where("id", "=", a.locationId)
.executeTakeFirstOrThrow();
expect(loc.relative_path).toBe("B/Reg.2018.mkv");
await writeFile(join(h.dl, "CopyMe.mkv"), "copy");
const cbody = { items: [{ root: h.dlRoot, path: "CopyMe.mkv" }], target: { root: h.libRoot, path: "A" } };
const cdry = await h.api("POST", "/files/copy", { ...cbody, idempotencyKey: key(), dryRun: true }, EXEC_KEY);
expect(cdry.status).toBe(200);
const cexec = await h.api(
"POST",
"/files/copy",
{ ...cbody, idempotencyKey: key(), dryRun: false, planId: cdry.body.data.planId },
EXEC_KEY,
);
expect(cexec.status).toBe(200);
expect(await readFile(join(h.lib, "A", "CopyMe.mkv"), "utf8")).toBe("copy");
expect(await exists(join(h.dl, "CopyMe.mkv"))).toBe(true);
});
});
+2 -1
View File
@@ -43,12 +43,13 @@ const pingModule: ServerModule = {
};
describe("ServerModule 骨架", () => {
it("清单里是四个 W2 模块,名字唯一", () => {
it("清单里是四个 W2 模块 + 文件管理,名字唯一", () => {
expect(SERVER_MODULES.map((m) => m.name)).toEqual([
"auth-settings",
"sources-identity",
"acquisition-organize",
"agent",
"file-manager",
]);
});
+57
View File
@@ -0,0 +1,57 @@
# 文件管理(`/files`,W5-A)
实现:`apps/server/src/modules/file-manager/`(路由 `routes.ts`,服务按操作分文件),契约 `packages/contracts/src/files.ts`(端点登记在 `api.ts` 的 `Api.files`),迁移 `0024_trash`。规格见 `docs/plan/06-file-manager-spec.md`。
复用 acquisition-organize 的服务导出(`planImport`、`sourceGuard`、`authorizeMove`、`hnrBlockForMove`、`managedTasksUnder`、`emitRecord`、H&R 判定),不改它的文件。
## 根与路径
- 根只来自登记:启用的 `library_root`(`lib:<id>`)+ 下载器路径映射的本机前缀(`dl:<instanceId>:<序号>`;实例没配映射时取保存路径)。`/` 永不出现。
- 所有路径参数都是 `{root, path}`,`path` 为根内相对路径(`""` = 根)。服务端:拒绝 `..`/`.` 段与绝对路径 → 父目录 realpath 必须在根 realpath 之下 → 最后一段是符号链接时,目标也必须在根内(否则 `path_escape`,列表里该行 `escape:true` 且禁用操作)。执行阶段再 realpath 一次(TOCTOU)。
- 根健康实时探测(≤2 s):库根看哨兵 `.uhd-root`(或 `sentinel_file`),下载根看目录可 stat。离线/超时 → **503**,`error.message` 以 `root_unavailable:` 开头,**不返回空列表**。
- 可写:库根 `managed` 且进程 W_OK 才能作为 mkdir / 移动 / 复制的目标;`register_only` 库根只读(不能改名/删除/移出);下载根可以改名/删除/作为移动源,不能作为目标。
## 错误约定
业务错误的 `error.message` 以 `<reason>: ` 开头,前端按前缀分支:`root_unavailable`(503)、`path_escape`(400)、`not_found`(404)、`exists` / `plan_stale` / `download_active` / `needs_retire` / `needs_unbind`(409)、`not_writable` / `permanent_disabled` / `approval_required` / `forbidden`(403)。
## 两段式写操作
- move / copy / organize / delete:先 `dryRun:true` 拿 `planId`(存 `artifact kind=plan, title=files_plan`,默认 30 分钟有效,`FILES_PLAN_TTL_SEC`),执行时回传。
- 执行校验:planId 存在、未过期、未用过,且与本次请求体一致(否则 409 `plan_stale`);源文件事实未变——文件看 size/mtime/inode,目录看递归文件清单(路径+size+mtime)指纹;organize 另外重算一次 dryRun,前后路径必须与冻结计划一致。
- 幂等:写请求带 `idempotencyKey`,按「调用者 + 操作 + 键」唯一(`artifact title=files_idem`,唯一索引 `artifact_files_idem_key`)。同键重放返回首次结果;同键不同请求体或首个请求仍在执行 → 409。执行失败不占键。dryRun 不占键。
- 所有执行都写 `audit_event`(`files.<op>`)并发 SSE `files.changed {rootId, dirs[]}`;路径变更同时写 `organization_record`(rename → `rename`,移动 → `relocate`,复制 → `copy`,整理 → planImport 原记录),外部程序按 changeSeq 增量感知。
## 各操作要点
| 操作 | 要点 |
|---|---|
| rename | 同目录;`sanitizeSegment` 不一致 400;目标存在 409 且原文件不动(moveSafe:link+unlink / lstat+rename,事后 inode 比对);视频同主干 sidecar 一起改;已登记位置同事务改 `asset_location.relative_path` + `asset_operation` 账本;目录命中 `path_binding`(含子绑定):旧行 `superseded` 并 `superseded_by_id` 指向新 active 行;纳管下载内容 409;DB 失败把文件改回原名 |
| move/copy | 目标为 writable 库根下已存在目录;源≠目标、目标不在源内;不覆盖;跨卷文件 Copy→核验→删源(`crossDeviceMove`),跨卷目录移动暂不支持(该项失败,W5);move 触及纳管下载任务 → 409(请用整理入库,Move 会按 H&R 移交);API Key 的 move 需 `authorizeMove`(持续授权或一次性审批,plan_type `organize_plan`/`files_plan` 绑定 planId+内容哈希);已登记资产位置跟随(跨库根会改 `library_root_id`),绑定目录写修订 |
| organize | 按 `(mediaEntityId, contentUnitId)` 分组,每组一次 `planImport`,与 `POST /organization-records` 同实现;执行需 `planIds`(按请求匹配各组);单组失败只影响该组(`groups[].error`);成功组入队 `verifyAndImport`,返回 `recordIds` |
| delete | 默认回收站:`<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(同卷 rename);根不可写/跨卷 → `DATA_DIR/trash/...` 复制(上限 `FILES_TRASH_CROSS_MAX_BYTES`,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 `retireAssets`(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 `unbind`(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务 |
| 永久删除 | `FILES_ALLOW_PERMANENT_DELETE=true` 才可用,且仅管理员会话;单项需 `confirmName` = 文件名 |
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记);`purge` 仅管理员会话,缺省只清过期项。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 |
| probe | ≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo/images` 目前返回 `not_implemented` |
## 授权矩阵(实现)
| 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key |
|---|---|---|
| roots / list / stat / identify / probe / trash 列表 | ✓ | `organize.read` |
| mkdir / rename / copy / organize(copy) / restore / scrape | ✓ | `organize.execute` |
| move / organize(move) | ✓ | `organize.execute` + 持续授权或一次性审批 |
| delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) |
| delete(永久)/ trash purge | ✓(仅会话) | ✗ 403 |
## 环境变量
`FILES_LIST_MAX_ENTRIES`(20000)、`FILES_TRASH_RETENTION_DAYS`(7)、`FILES_TRASH_CROSS_MAX_BYTES`(2147483648)、`FILES_PLAN_TTL_SEC`(1800)、`FILES_ALLOW_PERMANENT_DELETE`(false)。
## 已知缺口(W5)
- `GET /files/download`(小文件下载/图片预览)未实现。
- organize 的逐项 `season/episodes/slotId` 覆写暂不生效(返回 `groups[].warnings`),需 `planImport` 支持 `fileOverrides`;移动/复制未走 `planImport` 的"原样搬运"变体(`targetDir/keepNames`),而是用同一批 adapters 搬运原语 + 本模块冻结计划。
- 删除不写 `organization_record`(`OrganizationAction` 无 `delete`),只写审计;跨卷目录移动、NFO/图片投影、硬链接/空目录清理均为 W5。
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。