From 29cd10b27e2e8d88b8e04c0c08270ed3c75fa6fd Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 23:02:20 +0800 Subject: [PATCH] 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 --- .../server/src/modules/file-manager/common.ts | 322 ++++++++++ .../src/modules/file-manager/identify.ts | 273 +++++++++ apps/server/src/modules/file-manager/index.ts | 88 +++ .../server/src/modules/file-manager/ledger.ts | 159 +++++ apps/server/src/modules/file-manager/list.ts | 572 ++++++++++++++++++ .../server/src/modules/file-manager/mutate.ts | 195 ++++++ .../src/modules/file-manager/organize.ts | 244 ++++++++ apps/server/src/modules/file-manager/probe.ts | 143 +++++ .../src/modules/file-manager/records.ts | 215 +++++++ .../server/src/modules/file-manager/routes.ts | 101 ++++ .../src/modules/file-manager/transfer.ts | 303 ++++++++++ apps/server/src/modules/file-manager/trash.ts | 510 ++++++++++++++++ apps/server/src/modules/index.ts | 2 + apps/server/test/file-manager/browse.test.ts | 174 ++++++ apps/server/test/file-manager/harness.ts | 298 +++++++++ .../file-manager/identify-organize.test.ts | 160 +++++ apps/server/test/file-manager/trash.test.ts | 214 +++++++ apps/server/test/file-manager/write.test.ts | 348 +++++++++++ apps/server/test/modules.test.ts | 3 +- docs/runbook/files.md | 57 ++ 20 files changed, 4380 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/modules/file-manager/common.ts create mode 100644 apps/server/src/modules/file-manager/identify.ts create mode 100644 apps/server/src/modules/file-manager/index.ts create mode 100644 apps/server/src/modules/file-manager/ledger.ts create mode 100644 apps/server/src/modules/file-manager/list.ts create mode 100644 apps/server/src/modules/file-manager/mutate.ts create mode 100644 apps/server/src/modules/file-manager/organize.ts create mode 100644 apps/server/src/modules/file-manager/probe.ts create mode 100644 apps/server/src/modules/file-manager/records.ts create mode 100644 apps/server/src/modules/file-manager/routes.ts create mode 100644 apps/server/src/modules/file-manager/transfer.ts create mode 100644 apps/server/src/modules/file-manager/trash.ts create mode 100644 apps/server/test/file-manager/browse.test.ts create mode 100644 apps/server/test/file-manager/harness.ts create mode 100644 apps/server/test/file-manager/identify-organize.test.ts create mode 100644 apps/server/test/file-manager/trash.test.ts create mode 100644 apps/server/test/file-manager/write.test.ts create mode 100644 docs/runbook/files.md diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts new file mode 100644 index 00000000..360210e4 --- /dev/null +++ b/apps/server/src/modules/file-manager/common.ts @@ -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 = { + 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 以 `: ` 开头便于前端分支 */ +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 { + 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 { + 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 = (p: Promise, ms: number, fallback: T): Promise => + Promise.race([p, new Promise((res) => setTimeout(() => res(fallback), ms).unref?.())]); + +/** 实时探测:库根按哨兵(checkRoot),下载根按目录可 stat;≤ timeout,超时 = unknown(按不可用处理) */ +export async function probeRoot(def: RootDef, timeoutMs: number): Promise { + const checkedAt = new Date(); + const run = async (): Promise => { + 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 { + 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> | 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 { + 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> | 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 { + 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; + 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); diff --git a/apps/server/src/modules/file-manager/identify.ts b/apps/server/src/modules/file-manager/identify.ts new file mode 100644 index 00000000..3aa32b16 --- /dev/null +++ b/apps/server/src/modules/file-manager/identify.ts @@ -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 = { + 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 { + const db = d.ctx.db; + const { videos, truncated } = await collectVideos(d, body); + const localCache = new Map(); + const remoteCache = new Map(); + const entityKinds = new Map(); + + const localFor = async (p: ParsedRelease): Promise => { + const key = `${queryTitles(p).join("|")}|${body.hints?.tmdbId ?? ""}`; + const hit = localCache.get(key); + if (hit) return hit; + const ids = new Set(); + 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 }; +} diff --git a/apps/server/src/modules/file-manager/index.ts b/apps/server/src/modules/file-manager/index.ts new file mode 100644 index 00000000..0129aa41 --- /dev/null +++ b/apps/server/src/modules/file-manager/index.ts @@ -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; +} + +export function createFileManagerModule(overrides: FmOverrides = {}): ServerModule & { deps(): FmDeps } { + let deps: FmDeps | null = null; + let timer: ReturnType | 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(); diff --git a/apps/server/src/modules/file-manager/ledger.ts b/apps/server/src/modules/file-manager/ledger.ts new file mode 100644 index 00000000..2a32760f --- /dev/null +++ b/apps/server/src/modules/file-manager/ledger.ts @@ -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 { + 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( + 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(d: FmDeps, planId: string, op: string, requestHash: string): Promise> { + 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["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 { + 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 { + 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( + d: FmDeps, + principal: Principal | undefined, + op: string, + key: string, + request: unknown, + fn: () => Promise, +): Promise { + const db = d.ctx.db; + const k = `${op}:${actorOfP(principal)}:${key}`; + const reqHash = hashJson(request); + const replay = async (): Promise => { + const row = await db + .selectFrom("artifact") + .select(["content"]) + .where("title", "=", "files_idem") + .where(sql`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, +): Promise { + 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)] }); +} diff --git a/apps/server/src/modules/file-manager/list.ts b/apps/server/src/modules/file-manager/list.ts new file mode 100644 index 00000000..a512924d --- /dev/null +++ b/apps/server/src/modules/file-manager/list.ts @@ -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 => { + 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 { + return access(p, constants.W_OK).then( + () => true, + () => false, + ); +} + +export async function isWritableTarget(def: RootDef, rootReal: string): Promise { + 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 { + let st: Awaited>; + 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 { + 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(); + if (def.kind !== "library" || rels.length === 0) return out; + const db = d.ctx.db; + const rows = ( + await sql` + 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 = { + 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 { + 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 { + 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 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(); + 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 { + 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 { + 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 { + 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 }; diff --git a/apps/server/src/modules/file-manager/mutate.ts b/apps/server/src/modules/file-manager/mutate.ts new file mode 100644 index 00000000..243cc48a --- /dev/null +++ b/apps/server/src/modules/file-manager/mutate.ts @@ -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 { + 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 { + 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(); + 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 }; +} diff --git a/apps/server/src/modules/file-manager/organize.ts b/apps/server/src/modules/file-manager/organize.ts new file mode 100644 index 00000000..e9322e20 --- /dev/null +++ b/apps/server/src/modules/file-manager/organize.ts @@ -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>) => + (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 { + const db = d.ctx.db; + const actorType = principal?.kind === "api_key" ? "api_key" : "user"; + const actor = actorOfP(principal); + + // 分组(保持首次出现顺序) + const groups = new Map(); + const roots = new Set(); + 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( + 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(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 { + 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; +} diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts new file mode 100644 index 00000000..d2ca922f --- /dev/null +++ b/apps/server/src/modules/file-manager/probe.ts @@ -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 { + 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 { + 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; +} diff --git a/apps/server/src/modules/file-manager/records.ts b/apps/server/src/modules/file-manager/records.ts new file mode 100644 index 00000000..7ae5d74c --- /dev/null +++ b/apps/server/src/modules/file-manager/records.ts @@ -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 { + 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(); + 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().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; + files: RecordFile[]; + reason?: string | null; + }, +): Promise { + 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; +} diff --git a/apps/server/src/modules/file-manager/routes.ts b/apps/server/src/modules/file-manager/routes.ts new file mode 100644 index 00000000..6def4126 --- /dev/null +++ b/apps/server/src/modules/file-manager/routes.ts @@ -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 以 `: ` 开头)。 + +// biome-ignore lint/suspicious/noExplicitAny: 入参已按 contracts schema 校验 +type Input = { query: any; body: any }; +type Handler = (c: Context, input: Input) => Promise; + +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) => { + 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 = ( + d: FmDeps, + c: Context, + op: string, + body: B, + fn: () => Promise, +) => { + 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)); +} diff --git a/apps/server/src/modules/file-manager/transfer.ts b/apps/server/src/modules/file-manager/transfer.ts new file mode 100644 index 00000000..4cd0f958 --- /dev/null +++ b/apps/server/src/modules/file-manager/transfer.ts @@ -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 { + 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 => { + 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 { + 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(); + 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 }, + }; +} diff --git a/apps/server/src/modules/file-manager/trash.ts b/apps/server/src/modules/file-manager/trash.ts new file mode 100644 index 00000000..b3c7f2bc --- /dev/null +++ b/apps/server/src/modules/file-manager/trash.ts @@ -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///<原相对路径>,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>, +): 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>; + 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, + [ConstructorParameters[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 { + 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 { + 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 | null) ?? null, + }; +} + +export async function listTrash(d: FmDeps, q: FilesTrashQuery): Promise { + 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`(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 { + 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, +): Promise { + 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 }); + // 顺带清掉空的 / 目录 + 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 }; +} diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index c07e33b3..bc20367d 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -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, ]; diff --git a/apps/server/test/file-manager/browse.test.ts b/apps/server/test/file-manager/browse.test.ts new file mode 100644 index 00000000..4dea049a --- /dev/null +++ b/apps/server/test/file-manager/browse.test.ts @@ -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) => 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); + }); +}); diff --git a/apps/server/test/file-manager/harness.ts b/apps/server/test/file-manager/harness.ts new file mode 100644 index 00000000..772bf5bd --- /dev/null +++ b/apps/server/test/file-manager/harness.ts @@ -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; + engine: ReturnType; + 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; +} + +export async function createFmHarness( + name: string, + overrides: FmOverrides = {}, + opts: { catalog?: boolean } = {}, +): Promise { + 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({ + 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

(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 = {}; + 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, 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, + h: Pick, + 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, + h: Pick, + 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, + h: Pick, + 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 }; diff --git a/apps/server/test/file-manager/identify-organize.test.ts b/apps/server/test/file-manager/identify-organize.test.ts new file mode 100644 index 00000000..3c73faaa --- /dev/null +++ b/apps/server/test/file-manager/identify-organize.test.ts @@ -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); + }); +}); diff --git a/apps/server/test/file-manager/trash.test.ts b/apps/server/test/file-manager/trash.test.ts new file mode 100644 index 00000000..7a6f3945 --- /dev/null +++ b/apps/server/test/file-manager/trash.test.ts @@ -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, 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); + }); +}); diff --git a/apps/server/test/file-manager/write.test.ts b/apps/server/test/file-manager/write.test.ts new file mode 100644 index 00000000..2f69d38f --- /dev/null +++ b/apps/server/test/file-manager/write.test.ts @@ -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); + }); +}); diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index c762893b..ad60a43e 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -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", ]); }); diff --git a/docs/runbook/files.md b/docs/runbook/files.md new file mode 100644 index 00000000..0a9b2d65 --- /dev/null +++ b/docs/runbook/files.md @@ -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:`)+ 下载器路径映射的本机前缀(`dl::<序号>`;实例没配映射时取保存路径)。`/` 永不出现。 +- 所有路径参数都是 `{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` 以 `: ` 开头,前端按前缀分支:`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.`)并发 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///<原相对路径>`(同卷 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`)。