feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

@@ -0,0 +1,418 @@
import { randomUUID } from "node:crypto";
import { readdir, rmdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { checkRoot, DEFAULT_ROOT_SENTINEL, statSafe } from "@umt/adapters";
import { fileKindOf, type OrganizeFileKind, type OrganizePlan, type PlannedFile } from "@umt/domain";
import { organizationStatusFromFiles, organizePlanToFileResults } from "@umt/mapping";
import { hashJson, jsonb } from "../util.ts";
import type { AoDeps, Exec } from "./common.ts";
import { type ExecuteResult, emitRecord, type FrozenPlan, type ImportRequest, type PlanResult } from "./organize.ts";
// W5-A2 原样搬运(文件管理 移动/复制到目录):与整理共用 planImport 入口、冻结计划、organization_record、
// verifyAndImport 任务与 executeRecord(逐文件账本可恢复、fsproxy 挂死隔离、跨卷 Copy→核验→删源、EXDEV 不降级)。
// 与作品整理的差别:保持文件名与目录结构;不识别作品、不登记新资产;move 成功后已登记的 asset_location 跟随、
// 整个目录搬完时命中的 path_binding 写修订,并删掉源侧留下的空目录。
export interface TransferItem {
/** 源(绝对路径,文件或目录) */
src: string;
/** 目标(绝对路径,不存在) */
dst: string;
/** 源所在库根与根内相对路径(源为下载根时 null,不做位置跟随) */
srcLibraryRootId: string | null;
srcRel: string | null;
/** 目标在目标库根内的相对路径 */
dstRel: string;
}
export interface TransferSpec {
items: TransferItem[];
targetLibraryRootId: string;
/** 目标库根 realpath(相对路径以它为基准) */
targetRootPath: string;
/** 遍历到的每个文件都经它复核(realpath 在允许的根下) */
guard?: ((p: string) => Promise<string>) | undefined;
}
export interface FrozenTransfer {
items: (TransferItem & { isDir: boolean; files: number })[];
targetLibraryRootId: string;
}
const KIND_OF = (p: string): OrganizeFileKind => {
const k = fileKindOf(p);
return k === "video" || k === "subtitle" || k === "audio" || k === "nfo" ? k : "other";
};
/** 递归列出目录下的普通文件与子目录(不跟随符号链接;符号链接列为 skipped) */
async function walkTree(
dir: string,
out: { files: string[]; dirs: string[]; links: string[] },
limit: number,
): Promise<boolean> {
const names = (await readdir(dir)).sort();
for (const n of names) {
if (out.files.length >= limit) return true;
const p = join(dir, n);
const st = await statSafe(p);
if (st.kind !== "ok") throw new Error(`源不可访问: ${p} (${st.kind})`);
if (st.facts.isSymbolicLink) out.links.push(p);
else if (st.facts.isDirectory) {
out.dirs.push(p);
if (await walkTree(p, out, limit)) return true;
} else if (st.facts.isFile) out.files.push(p);
}
return false;
}
/** 原样搬运计划;dryRun 只返回冻结计划,否则写 organization_record(status=running)等 verifyAndImport 执行 */
export async function planTransfer(d: AoDeps, req: ImportRequest, t: TransferSpec): Promise<PlanResult> {
const db = d.ctx.db;
const dry = req.dryRun === true;
const action = req.action ?? "copy";
const failed = (reason: string): PlanResult => ({ recordId: null, status: "failed", reason });
const root = await db
.selectFrom("library_root")
.selectAll()
.where("id", "=", t.targetLibraryRootId)
.executeTakeFirst();
if (root?.management_mode !== "managed") return failed("target_root_not_managed");
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL;
const health = await checkRoot(t.targetRootPath, sentinel);
if (health.kind === "offline") return failed(`library_root_offline:${health.reason}`);
const max = d.ports.options.walkMaxFiles;
const files: PlannedFile[] = [];
const excluded: OrganizePlan["excluded"] = [];
const dirs = new Set<string>();
const items: FrozenTransfer["items"] = [];
let truncated = false;
for (const it of t.items) {
const st = await statSafe(it.src);
if (st.kind !== "ok") return failed(`source_missing:${it.src}`);
const add = async (src: string, dst: string) => {
if (t.guard) await t.guard(src);
const s = await statSafe(src);
if (s.kind !== "ok") throw new Error(`源不可访问: ${src} (${s.kind})`);
files.push({
src,
dst,
size: s.facts.size,
kind: KIND_OF(src),
role: "main",
action: "transfer",
crossDevice: s.facts.dev !== health.dev,
});
};
if (!st.facts.isDirectory) {
await add(it.src, it.dst);
items.push({ ...it, isDir: false, files: 1 });
continue;
}
const tree = { files: [] as string[], dirs: [] as string[], links: [] as string[] };
if (await walkTree(it.src, tree, max - files.length + 1)) truncated = true;
const map = (p: string) => join(it.dst, relative(it.src, p));
dirs.add(it.dst);
for (const sub of tree.dirs) dirs.add(map(sub));
for (const f of tree.files) await add(f, map(f));
for (const l of tree.links)
excluded.push({ src: l, kind: KIND_OF(l), reason: "not_imported_kind", detail: "symlink" });
items.push({ ...it, isDir: true, files: tree.files.length });
}
if (files.length > max) {
files.length = max;
truncated = true;
}
if (truncated && !dry) return failed("too_many_files");
// 目标已存在(或状态读不出):不覆盖,该文件执行前即记失败
const conflicts: OrganizePlan["conflicts"] = [];
for (const f of files) {
const st = await statSafe(f.dst);
if (st.kind === "missing") continue;
f.action = "hold";
conflicts.push({ kind: "target_exists", blocking: false, src: f.src, dst: f.dst });
}
for (const f of files) dirs.add(dirname(f.dst));
const plan: OrganizePlan = {
operation: action,
crossDevice: files.some((f) => f.crossDevice === true),
files,
excluded,
preconditions: [],
conflicts,
requiresApproval: false,
};
const frozen: FrozenPlan = {
version: 1,
organize: plan,
entityId: req.mediaEntityId ?? "",
mediaType: "movie",
libraryRootId: root.id,
rootPath: t.targetRootPath,
sentinel,
targetDirs: [...dirs].sort(),
newBindings: [],
sourceRoot: null,
facts: {},
transfer: {
items,
targetLibraryRootId: t.targetLibraryRootId,
},
};
if (dry) return { recordId: null, status: "queued", reason: null, plan: frozen, ...(truncated ? { truncated } : {}) };
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
const now = d.ports.now();
const rel = (abs: string | null | undefined) => (abs ? relative(t.targetRootPath, abs) : null);
const heldSrc = new Set(files.filter((f) => f.action === "hold").map((f) => f.src));
return db.transaction().execute(async (trx) => {
const rec = await trx
.insertInto("organization_record")
.values({
task_id: req.taskId ?? null,
trigger: req.trigger,
actor_type: req.actorType,
actor_id: req.actorId ?? null,
media_entity_id: req.mediaEntityId ?? null,
source_path_view: "local",
source_path: items.length === 1 ? items[0]!.src : dirname(items[0]!.src),
target_library_root_id: root.id,
target_path: items.length === 1 ? items[0]!.dstRel : rel(dirname(items[0]!.dst)),
action,
status: "running",
plan: jsonb(frozen),
plan_fingerprint: hashJson(frozen),
file_total: drafts.length,
file_skipped: drafts.filter((f) => f.status === "skipped" && !heldSrc.has(f.sourcePath)).length,
file_failed: heldSrc.size,
started_at: now,
})
.returning(["id"])
.executeTakeFirstOrThrow();
for (const f of drafts) {
const held = heldSrc.has(f.sourcePath);
await trx
.insertInto("organization_file_result")
.values({
organization_record_id: rec.id,
ordinal: f.ordinal ?? 0,
role: f.role ?? "main",
mode: f.mode,
source_path: f.sourcePath,
source_size_bytes: f.sourceSizeBytes ?? null,
target_library_root_id: f.targetPath ? root.id : null,
target_path: rel(f.targetPath),
status: held ? "failed" : f.status,
skip_reason: held ? null : f.skipReason,
...(held ? { error: jsonb({ code: "EEXIST", message: "目标已存在,不覆盖" }), finished_at: now } : {}),
})
.execute();
}
await emitRecord(d, trx, rec.id);
return { recordId: rec.id, status: "running" as const, reason: null };
});
}
// ── 位置跟随 ──
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
export interface RelocateInput {
fromLib: string;
fromRel: string;
toLib: string;
toRel: string;
isDir: boolean;
actor: string;
opKind: "rename" | "move";
}
export interface RelocateResult {
locations: { id: string; assetId: string; from: string; to: string }[];
bindings: { oldBindingId: string; newBindingId: string }[];
mediaEntityIds: string[];
}
/** 同事务:把 fromLib/fromRel(目录含其下全部)的已登记位置改到 toLib/toRel;目录命中的路径绑定写修订 */
export async function relocate(trx: Exec, input: RelocateInput): Promise<RelocateResult> {
const { fromLib, fromRel, toLib, toRel, isDir } = input;
const mapRel = (p: string) => (p === fromRel ? toRel : `${toRel}${p.slice(fromRel.length)}`);
const locs = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "l.media_asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", fromLib)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", fromRel), eb("l.relative_path", "like", likePrefix(fromRel))])
: eb("l.relative_path", "=", fromRel),
)
.execute();
const out: RelocateResult = { locations: [], bindings: [], mediaEntityIds: [] };
const entities = new Set<string>();
for (const l of locs) {
const to = mapRel(l.relative_path);
await trx
.updateTable("asset_location")
.set({ library_root_id: toLib, relative_path: to, last_seen_at: new Date() })
.where("id", "=", l.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: `files:${randomUUID()}`,
phase: "commit",
kind: input.opKind,
status: "succeeded",
intent: jsonb({
fromLibraryRootId: fromLib,
from: l.relative_path,
toLibraryRootId: toLib,
to,
by: input.actor,
}),
result: jsonb({ locationId: l.id }),
media_asset_id: l.media_asset_id,
started_at: new Date(),
finished_at: new Date(),
})
.execute();
out.locations.push({ id: l.id, assetId: l.media_asset_id, from: l.relative_path, to });
if (l.media_entity_id) entities.add(l.media_entity_id);
}
if (isDir) {
const binds = await trx
.selectFrom("path_binding")
.selectAll()
.where("library_root_id", "=", fromLib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", fromRel), eb("relative_path", "like", likePrefix(fromRel))]))
.execute();
for (const b of binds) {
// 显式路径变更(REQ §7.1.1):旧绑定 superseded,新绑定 active 并互相链接;仅译名刷新永不走到这里
await trx.updateTable("path_binding").set({ status: "superseded" }).where("id", "=", b.id).execute();
const nb = await trx
.insertInto("path_binding")
.values({
media_entity_id: b.media_entity_id,
content_unit_id: b.content_unit_id,
season_number: b.season_number,
binding_kind: b.binding_kind,
library_root_id: toLib,
relative_path: mapRel(b.relative_path),
template_version: b.template_version,
status: "active",
bound_by: input.actor,
note: `files.${input.opKind}: ${b.relative_path} → ${mapRel(b.relative_path)}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx.updateTable("path_binding").set({ superseded_by_id: nb.id }).where("id", "=", b.id).execute();
out.bindings.push({ oldBindingId: b.id, newBindingId: nb.id });
entities.add(b.media_entity_id);
}
}
out.mediaEntityIds = [...entities];
return out;
}
/** 目录下的子目录,深的在前(删空目录用) */
async function dirsDeepFirst(dir: string): Promise<string[]> {
const out: string[] = [];
const walk = async (p: string) => {
const ents = await readdir(p, { withFileTypes: true }).catch(() => []);
for (const e of ents) if (e.isDirectory()) await walk(join(p, e.name));
out.push(p);
};
await walk(dir);
return out;
}
/** 原样搬运的终态:记录状态 + move 的位置跟随/绑定修订 + 删源侧空目录;行锁 fencing,重复调用幂等 */
export async function finalizeTransfer(d: AoDeps, recordId: string): Promise<ExecuteResult> {
const db = d.ctx.db;
const pre = await db
.selectFrom("organization_record")
.select(["action", "plan", "status"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const frozenPre = pre.plan as unknown as FrozenPlan;
const tr = frozenPre.transfer!;
const filesPre = await db
.selectFrom("organization_file_result")
.select(["source_path", "status"])
.where("organization_record_id", "=", recordId)
.execute();
const under = (item: { src: string }, p: string) => p === item.src || p.startsWith(`${item.src}/`);
const allOk = (item: { src: string }) =>
filesPre.filter((f) => under(item, f.source_path)).every((f) => f.status === "succeeded");
// move:整个目录都搬完才删源侧空目录(先于终态提交;失败/中断重跑幂等)
if (pre.action === "move" && (pre.status === "running" || pre.status === "queued"))
for (const it of tr.items.filter((x) => x.isDir && allOk(x)))
for (const dir of await dirsDeepFirst(it.src)) await rmdir(dir).catch(() => undefined);
return db.transaction().execute(async (trx) => {
const rec = await trx
.selectFrom("organization_record")
.selectAll()
.where("id", "=", recordId)
.forUpdate()
.executeTakeFirstOrThrow();
if (rec.status !== "running" && rec.status !== "queued")
return { recordId, status: rec.status, succeeded: rec.file_succeeded, failed: rec.file_failed };
const frozen = rec.plan as unknown as FrozenPlan;
const t = frozen.transfer!;
const files = await trx
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.execute();
const entities = new Set<string>();
if (rec.action === "move") {
const actor = `organize:${recordId}`;
for (const it of t.items) {
if (it.srcRel === null || it.srcLibraryRootId === null) continue;
const mine = files.filter((f) => under(it, f.source_path));
const base = { fromLib: it.srcLibraryRootId, toLib: t.targetLibraryRootId, actor, opKind: "move" as const };
if (mine.length > 0 && mine.every((f) => f.status === "succeeded")) {
// 整项搬完:按前缀跟随(目录含路径绑定修订)
const r = await relocate(trx, { ...base, fromRel: it.srcRel, toRel: it.dstRel, isDir: it.isDir });
for (const e of r.mediaEntityIds) entities.add(e);
continue;
}
for (const f of mine.filter((x) => x.status === "succeeded")) {
const sub = f.source_path.slice(it.src.length);
const r = await relocate(trx, {
...base,
fromRel: `${it.srcRel}${sub}`,
toRel: `${it.dstRel}${sub}`,
isDir: false,
});
for (const e of r.mediaEntityIds) entities.add(e);
}
}
}
const statuses = files.map((f) => f.status);
const status = organizationStatusFromFiles(statuses);
const counts = {
file_succeeded: statuses.filter((s) => s === "succeeded").length,
file_failed: statuses.filter((s) => s === "failed").length,
file_skipped: statuses.filter((s) => s === "skipped").length,
};
await trx
.updateTable("organization_record")
.set({
status,
...counts,
finished_at: d.ports.now(),
...(status === "failed" && !rec.reason ? { reason: "transfer_failed" } : {}),
...(!rec.media_entity_id && entities.size === 1 ? { media_entity_id: [...entities][0]! } : {}),
})
.where("id", "=", recordId)
.execute();
await emitRecord(d, trx, recordId);
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed };
});
}
@@ -27,6 +27,7 @@ import {
movieCompletion, movieCompletion,
type NewBinding, type NewBinding,
type OrganizePlan, type OrganizePlan,
overrideFor,
type ParsedRelease, type ParsedRelease,
parseReleasePath, parseReleasePath,
planOrganize, planOrganize,
@@ -42,6 +43,7 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts"; import { applySlotEvent } from "./downloads.ts";
import { PathGuardError } from "./organize-guard.ts"; import { PathGuardError } from "./organize-guard.ts";
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
import { hnrSatisfied } from "./retention.ts"; import { hnrSatisfied } from "./retention.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
@@ -67,6 +69,24 @@ export interface ImportRequest {
onExisting?: ExistingPolicy | undefined; onExisting?: ExistingPolicy | undefined;
/** 已获替换授权(审批/手动确认) */ /** 已获替换授权(审批/手动确认) */
grantReplace?: boolean | undefined; grantReplace?: boolean | undefined;
/**
* W5-A2 逐文件覆写(文件管理整理向导的人工确认,优先于文件名解析):键为源文件或目录的绝对路径
* (目录对其下文件生效,最长前缀优先)。season/episodes 决定剧集季目录与命名以及集单元覆盖;
* contentUnitIds 为核验后额外登记的内容单元覆盖。
*/
fileOverrides?: Record<string, FileOverrideInput> | undefined;
/** W5-A2 目标目录覆写(绝对路径,须在目标库根下):按模板命名放入该目录,不使用也不新建路径绑定 */
targetDir?: string | undefined;
/** W5-A2 手动整理核验通过后推进的 Slot(须属于该作品) */
slotId?: string | undefined;
/** W5-A2 原样搬运(文件管理 移动/复制):保持文件名与目录结构,不识别作品、不登记新资产,已登记位置跟随 */
transfer?: TransferSpec | undefined;
}
export interface FileOverrideInput {
season?: number;
episodes?: { start: number; end: number };
contentUnitIds?: string[];
} }
/** organization_record.plan 的冻结内容 */ /** organization_record.plan 的冻结内容 */
@@ -84,9 +104,20 @@ export interface FrozenPlan {
/** 主视频 src → 解析出的质量特征与单元键 */ /** 主视频 src → 解析出的质量特征与单元键 */
facts: Record< facts: Record<
string, string,
{ quality: QualityFacts; unitKeys: string[]; season?: number; episodes?: { start: number; end: number } } {
quality: QualityFacts;
unitKeys: string[];
season?: number;
episodes?: { start: number; end: number };
/** 人工指定的内容单元(W5-A2 覆写) */
unitIds?: string[];
}
>; >;
downgradedFromMove?: string; downgradedFromMove?: string;
/** W5-A2:手动整理指定的 Slot */
slotId?: string;
/** W5-A2:原样搬运计划(finalize 走 finalizeTransfer) */
transfer?: FrozenTransfer;
} }
export interface PlanResult { export interface PlanResult {
@@ -171,6 +202,7 @@ async function chooseRoot(
/** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */ /** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */
export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanResult> { export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanResult> {
if (req.transfer) return planTransfer(d, req, req.transfer);
const db = d.ctx.db; const db = d.ctx.db;
let dt: DownloadTaskRow | undefined; let dt: DownloadTaskRow | undefined;
if (req.downloadTaskId) { if (req.downloadTaskId) {
@@ -334,6 +366,21 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r))); const bindings = bindingRows.map((r) => toDomainPathBinding(toApi<PathBinding>(r)));
const root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId); const root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId);
if (!root) return fail("no_library_root", {}, entity.id); if (!root) return fail("no_library_root", {}, entity.id);
const targetDir = req.targetDir?.replace(/\/+$/, "");
if (targetDir !== undefined) {
const rp = root.local_path.replace(/\/+$/, "");
if (!(targetDir === rp || targetDir.startsWith(`${rp}/`)) || targetDir.split("/").includes(".."))
return fail("target_dir_outside_root", { targetDir, libraryRootId: root.id }, entity.id);
}
if (req.slotId) {
const slot = await db
.selectFrom("acquisition_slot")
.select(["id", "media_entity_id"])
.where("id", "=", req.slotId)
.executeTakeFirst();
if (!slot || slot.media_entity_id !== entity.id)
return fail("slot_entity_mismatch", { slotId: req.slotId }, entity.id);
}
const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL; const sentinel = root.sentinel_file ?? DEFAULT_ROOT_SENTINEL;
const health = await checkRoot(root.local_path, sentinel); const health = await checkRoot(root.local_path, sentinel);
if (health.kind === "offline") { if (health.kind === "offline") {
@@ -361,6 +408,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
for (const f of files) { for (const f of files) {
const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot)); const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
if (parsed.fileKind !== "video") continue; if (parsed.fileKind !== "video") continue;
const ov = overrideFor(req.fileOverrides, f.path);
if (mediaType === "tv" && ov?.season !== undefined) parsed.season = ov.season;
if (mediaType === "tv" && ov?.episodes) parsed.episodes = ov.episodes;
const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined); const season = parsed.season ?? (parsed.episodes || parsed.airDate ? 1 : undefined);
if (mediaType === "tv" && season !== undefined) seasons.add(season); if (mediaType === "tv" && season !== undefined) seasons.add(season);
facts[f.path] = { facts[f.path] = {
@@ -368,6 +418,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
unitKeys: unitKeysOf({ ...parsed, ...(season !== undefined ? { season } : {}) }), unitKeys: unitKeysOf({ ...parsed, ...(season !== undefined ? { season } : {}) }),
...(season !== undefined ? { season } : {}), ...(season !== undefined ? { season } : {}),
...(parsed.episodes ? { episodes: parsed.episodes } : {}), ...(parsed.episodes ? { episodes: parsed.episodes } : {}),
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
}; };
} }
const libRoot = { id: root.id, hostPath: root.local_path }; const libRoot = { id: root.id, hostPath: root.local_path };
@@ -424,7 +475,11 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
return fail("binding_on_other_root", { libraryRootId: dec.libraryRootId }, entity.id); return fail("binding_on_other_root", { libraryRootId: dec.libraryRootId }, entity.id);
return join(root.local_path, dec.relativePath); return join(root.local_path, dec.relativePath);
}; };
if (mediaType === "movie") { if (targetDir !== undefined) {
// 目标目录覆写:显式的一次性位置,不改路径绑定
if (mediaType === "movie") target.movieDir = targetDir;
else target.seasonDirs = Object.fromEntries([...seasons].map((s) => [s, targetDir]));
} else if (mediaType === "movie") {
const r = await handle(decide()); const r = await handle(decide());
if (typeof r !== "string") return r; if (typeof r !== "string") return r;
target.movieDir = r; target.movieDir = r;
@@ -451,6 +506,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) }, target: { ...target, ...(health.kind === "online" ? { device: health.dev } : {}) },
retention: { hnrSatisfied: hnr }, retention: { hnrSatisfied: hnr },
minVideoBytes: d.ports.options.minVideoBytes, minVideoBytes: d.ports.options.minVideoBytes,
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG ...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG
? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG } ? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG }
: {}), : {}),
@@ -490,6 +546,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
sourceRoot, sourceRoot,
facts, facts,
...(downgradedFromMove ? { downgradedFromMove } : {}), ...(downgradedFromMove ? { downgradedFromMove } : {}),
...(req.slotId ? { slotId: req.slotId } : {}),
}; };
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id }); const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
const blocking = plan.conflicts.filter((c) => c.blocking); const blocking = plan.conflicts.filter((c) => c.blocking);
@@ -1137,6 +1194,8 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.where("id", "=", recordId) .where("id", "=", recordId)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const frozenPre = pre.plan as unknown as FrozenPlan; const frozenPre = pre.plan as unknown as FrozenPlan;
// 原样搬运(文件管理 移动/复制):不登记新资产,已登记位置/绑定跟随
if (frozenPre.transfer) return finalizeTransfer(d, recordId);
// ffprobe 在事务外做(可能较慢);未知不阻塞 // ffprobe 在事务外做(可能较慢);未知不阻塞
const probes = new Map<string, unknown>(); const probes = new Map<string, unknown>();
const filesPre = await db const filesPre = await db
@@ -1327,6 +1386,19 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
.execute(); .execute();
} }
} }
// 人工指定的内容单元(文件管理整理向导覆写)
for (const unitId of isMain ? (fx?.unitIds ?? []) : []) {
await trx
.insertInto("asset_unit_coverage")
.values({
media_asset_id: assetId,
content_unit_id: unitId,
coverage: "full",
evidence: jsonb({ from: "manual_override", recordId }),
})
.onConflict((oc) => oc.doNothing())
.execute();
}
} }
assetBySrc.set(f.source_path, { assetId, locationId }); assetBySrc.set(f.source_path, { assetId, locationId });
if (isMain) mainAssets.push(assetId); if (isMain) mainAssets.push(assetId);
@@ -1424,6 +1496,28 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
} }
} }
} }
// 手动整理指定的 Slot(文件管理整理向导):核验通过后以新资产满足该 Slot(获取中的 Slot 不动,由其计划推进)
if (!dt && frozen.slotId && okAny) {
const slot = await trx
.selectFrom("acquisition_slot")
.selectAll()
.where("id", "=", frozen.slotId)
.forUpdate()
.executeTakeFirst();
if (slot && !["reserved", "downloading", "verifying"].includes(slot.status)) {
const complete = await slotComplete(trx, slot, frozen, verifiedAssets);
await trx
.updateTable("acquisition_slot")
.set({
status: complete ? "satisfied" : "empty",
current_asset_id: verifiedAssets[0]!,
revision: slot.revision + 1,
status_reason: complete ? "manual_organize" : "manual_organize_partial",
})
.where("id", "=", slot.id)
.execute();
}
}
await emitRecord(d, trx, recordId); await emitRecord(d, trx, recordId);
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed, move, dt: dt ?? null }; return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed, move, dt: dt ?? null };
}); });
+26 -1
View File
@@ -32,6 +32,10 @@ export interface FmOptions {
backgroundLoops: boolean; backgroundLoops: boolean;
/** 数据目录(跨卷回收站 DATA_DIR/trash、probe 缓存 DATA_DIR/cache/probe) */ /** 数据目录(跨卷回收站 DATA_DIR/trash、probe 缓存 DATA_DIR/cache/probe) */
dataDir: string; dataDir: string;
/** 移动/复制执行后在请求内等待 verifyAndImport 完成的时长(超时返回 pending,后台继续) */
transferWaitMs: number;
/** GET /files/download 单文件上限 */
downloadMaxBytes: number;
} }
export interface FmDeps { export interface FmDeps {
@@ -59,12 +63,17 @@ export function defaultOptions(ctx: ServerContext): FmOptions {
purgeIntervalMs: 24 * 3600_000, purgeIntervalMs: 24 * 3600_000,
backgroundLoops: true, backgroundLoops: true,
dataDir: ctx.config?.DATA_DIR ?? "./data", dataDir: ctx.config?.DATA_DIR ?? "./data",
transferWaitMs: envNum("FILES_TRANSFER_WAIT_SEC", 20) * 1000,
downloadMaxBytes: envNum("FILES_DOWNLOAD_MAX_BYTES", 20 * 1024 ** 2),
}; };
} }
// ── 错误 ── // ── 错误 ──
const REASON_STATUS: Record<FilesErrorReason | "bad_request" | "forbidden", { status: number; code: ApiErrorCode }> = { const REASON_STATUS: Record<
FilesErrorReason | "bad_request" | "forbidden" | "too_large",
{ status: number; code: ApiErrorCode }
> = {
root_unavailable: { status: 503, code: "upstream_unavailable" }, root_unavailable: { status: 503, code: "upstream_unavailable" },
path_escape: { status: 400, code: "bad_request" }, path_escape: { status: 400, code: "bad_request" },
bad_request: { status: 400, code: "bad_request" }, bad_request: { status: 400, code: "bad_request" },
@@ -78,6 +87,7 @@ const REASON_STATUS: Record<FilesErrorReason | "bad_request" | "forbidden", { st
needs_unbind: { status: 409, code: "conflict" }, needs_unbind: { status: 409, code: "conflict" },
permanent_disabled: { status: 403, code: "forbidden" }, permanent_disabled: { status: 403, code: "forbidden" },
approval_required: { status: 403, code: "forbidden" }, approval_required: { status: 403, code: "forbidden" },
too_large: { status: 413, code: "bad_request" },
}; };
/** 业务错误:路由统一转成信封,message 以 `<reason>: ` 开头便于前端分支 */ /** 业务错误:路由统一转成信封,message 以 `<reason>: ` 开头便于前端分支 */
@@ -101,6 +111,21 @@ const OFFLINE_CODES = new Set(["EIO", "ENOTCONN", "ESTALE", "ETIMEDOUT", "EHOSTD
export const errnoOf = (e: unknown): string | undefined => (e as { code?: string } | null)?.code; export const errnoOf = (e: unknown): string | undefined => (e as { code?: string } | null)?.code;
export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? ""); export const isOfflineErr = (e: unknown) => OFFLINE_CODES.has(errnoOf(e) ?? "");
/**
* 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)→ 业务错误,而不是 500:
* 离线类 → 503 root_unavailable(失败 ≠ 空目录),ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400。
*/
export function fsErrorToFiles(e: unknown): FilesError | null {
if (e instanceof FilesError) return e;
const code = errnoOf(e);
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `无权限访问(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
}
// ── 根 ── // ── 根 ──
export interface RootDef { export interface RootDef {
@@ -0,0 +1,82 @@
import { basename, extname } from "node:path";
import type { FilesDownloadQuery } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts";
import { FilesError, type FmDeps, isAdmin, recheck, resolveIn } from "./common.ts";
import { audit } from "./ledger.ts";
import { kindOfName } from "./list.ts";
// GET /files/download(W5-A2):仅管理员会话;只下载 NFO/字幕/图片小文件(视频不经浏览器,规格 §1 边界);
// 路径守卫同其它接口(realpath 在根内、符号链接目标也须在根内,打开前再复核一次);
// 打开后按文件句柄再 stat,校验的就是要流出去的那个文件;流式返回,写审计。
const ALLOWED = new Set(["nfo", "subtitle", "image"]);
const MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
avif: "image/avif",
nfo: "text/plain; charset=utf-8",
xml: "text/plain; charset=utf-8",
srt: "text/plain; charset=utf-8",
ass: "text/plain; charset=utf-8",
ssa: "text/plain; charset=utf-8",
vtt: "text/plain; charset=utf-8",
sub: "text/plain; charset=utf-8",
sup: "application/octet-stream",
idx: "text/plain; charset=utf-8",
};
/** RFC 6266 / 5987:ASCII 兜底名 + UTF-8 原名 */
function disposition(kind: "inline" | "attachment", name: string): string {
const ascii = name.replace(/[^\x20-\x7e]|["\\]/g, "_");
return `${kind}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
}
export async function downloadFile(
d: FmDeps,
principal: Principal | undefined,
q: FilesDownloadQuery,
): Promise<Response> {
if (!isAdmin(principal)) throw new FilesError("forbidden", "下载只允许控制台管理员会话(API Key 不可用)");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind))
throw new FilesError("forbidden", `只允许下载 NFO/字幕/图片(${kind}),视频等大文件不经浏览器下载`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
try {
const st = await file.stat();
if (!st.isFile) throw new FilesError("bad_request", "不是普通文件");
size = st.size;
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
} catch (e) {
file.close();
throw e;
}
const ext = extname(name).replace(/^\./, "").toLowerCase();
// SVG 可带脚本:不内联、不给 image/svg+xml
const inline = q.inline && kind === "image" && ext !== "svg";
await audit(d, principal, "download", { root: q.root, path: r.rel, size, inline });
return new Response(file.readable, {
status: 200,
headers: {
"content-type": (ext !== "svg" && MIME[ext]) || "application/octet-stream",
"content-length": String(size),
"content-disposition": disposition(inline ? "inline" : "attachment", name),
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; sandbox",
"cache-control": "private, no-store",
},
});
}
+154 -14
View File
@@ -1,8 +1,9 @@
import { stat } from "node:fs/promises"; import { stat } from "node:fs/promises";
import { join } from "node:path";
import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts"; import type { FilesOrganizeGroup, FilesOrganizeRequest, FilesOrganizeResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts"; import { type FileOverrideInput, planImport } from "../acquisition-organize/services/organize.ts";
import { import {
authorizeMove, authorizeMove,
hnrBlockForMove, hnrBlockForMove,
@@ -13,9 +14,12 @@ import {
} from "../acquisition-organize/services/organize-guard.ts"; } from "../acquisition-organize/services/organize-guard.ts";
import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts"; import { actorOfP, errMessage, FilesError, type FmDeps, hashJson, isAdmin, resolveIn } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { isWritableTarget } from "./list.ts";
// 整理向导落地:按 (mediaEntityId, contentUnitId) 分组,每组走 planImport(与 POST /organization-records 同一实现, // 整理向导落地:按 (mediaEntityId, contentUnitId, 操作, 目标目录, Slot) 分组,每组走 planImport(与 POST /organization-records
// 同格式整理记录)。dryRun 冻结计划 + 源文件 size/mtime;执行校验 planId、源事实与重算的前后路径一致, // 同一实现,同格式整理记录)。逐项季/集/单元覆写以 fileOverrides 传入 planImport(计划前后路径即反映覆写),
// 目标目录覆写 = planImport.targetDir(不改路径绑定),Slot 覆写在核验通过后推进该 Slot;无法应用的覆写才进 warnings。
// dryRun 冻结计划 + 源文件 size/mtime;执行校验 planId、源事实与重算的前后路径一致,
// 任一组 plan_stale / 授权 / H&R 失败只影响该组。 // 任一组 plan_stale / 授权 / H&R 失败只影响该组。
type Req = { type Req = {
@@ -35,6 +39,18 @@ interface Frozen {
reason: string | null; reason: string | null;
} }
interface Group {
entity: string;
unit: string | null;
action: "copy" | "move";
targetDir: string | null;
targetLibraryRootId: string | null;
slotId: string | null;
paths: string[];
overrides: Record<string, FileOverrideInput>;
warnings: string[];
}
const pairsOf = (plan: Awaited<ReturnType<typeof planImport>>) => const pairsOf = (plan: Awaited<ReturnType<typeof planImport>>) =>
(plan.plan?.organize.files ?? []).map((f) => ({ src: f.src, dst: f.dst ?? null, kind: f.kind, size: f.size })); (plan.plan?.organize.files ?? []).map((f) => ({ src: f.src, dst: f.dst ?? null, kind: f.kind, size: f.size }));
@@ -47,6 +63,16 @@ async function factsOf(paths: string[]) {
); );
} }
/** 集号数组 → 连续区间;不连续返回 null */
function episodeRange(eps: number[]): { start: number; end: number } | null {
const u = [...new Set(eps)].sort((a, b) => a - b);
if (u.length === 0) return null;
for (let i = 1; i < u.length; i++) if (u[i] !== u[i - 1]! + 1) return null;
return { start: u[0]!, end: u[u.length - 1]! };
}
const BUSY_SLOT = new Set(["reserved", "downloading", "verifying"]);
export async function organizeFiles( export async function organizeFiles(
d: FmDeps, d: FmDeps,
principal: Principal | undefined, principal: Principal | undefined,
@@ -56,21 +82,119 @@ export async function organizeFiles(
const actorType = principal?.kind === "api_key" ? "api_key" : "user"; const actorType = principal?.kind === "api_key" ? "api_key" : "user";
const actor = actorOfP(principal); const actor = actorOfP(principal);
const entityIds = [...new Set(body.items.map((i) => i.mediaEntityId))];
const entities = new Map(
(await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", entityIds).execute()).map((e) => [
e.id,
e.kind,
]),
);
const unitIds = [...new Set(body.items.map((i) => i.contentUnitId).filter((x): x is string => !!x))];
const units = new Map(
unitIds.length
? (
await db
.selectFrom("content_unit")
.select(["id", "media_entity_id", "kind"])
.where("id", "in", unitIds)
.execute()
).map((u) => [u.id, u])
: [],
);
const slotIds = [...new Set(body.items.map((i) => i.slotId).filter((x): x is string => !!x))];
const slots = new Map(
slotIds.length
? (
await db
.selectFrom("acquisition_slot")
.select(["id", "media_entity_id", "status"])
.where("id", "in", slotIds)
.execute()
).map((s) => [s.id, s])
: [],
);
// 分组(保持首次出现顺序) // 分组(保持首次出现顺序)
const groups = new Map<string, { entity: string; unit: string | null; paths: string[]; warnings: string[] }>(); const groups = new Map<string, Group>();
const roots = new Set<string>(); const roots = new Set<string>();
for (const it of body.items) { for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path); const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根"); if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`); if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
if (body.action === "move" && !r.def.modifiable) const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`); throw new FilesError("not_writable", `源根 ${r.def.name} 为只读登记,不能 Move`);
roots.add(it.root); roots.add(it.root);
const key = `${it.mediaEntityId}|${it.contentUnitId ?? ""}`; const isDir = !!r.lst?.isDirectory();
const g = groups.get(key) ?? { entity: it.mediaEntityId, unit: it.contentUnitId ?? null, paths: [], warnings: [] }; const kind = entities.get(it.mediaEntityId);
const isTv = kind !== undefined && kind !== "movie";
const warnings: string[] = [];
const ov: FileOverrideInput = {};
// 目标目录覆写:writable 库根下已存在的目录
let targetDir: string | null = null;
let targetLib: string | null = body.targetLibraryRootId ?? null;
if (it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录: ${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
targetDir = join(t.def.hostPath, t.rel);
targetLib = t.def.libraryRootId;
}
// 季/集
if (it.season !== undefined || it.episodes !== undefined) {
if (!isTv) warnings.push(`${r.rel}: 电影不适用季/集覆写,已忽略`);
else {
if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) {
const range = episodeRange(it.episodes);
if (!range) warnings.push(`${r.rel}: 集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}: 目录项不能指定集号(会套到其下所有视频),未应用`);
else ov.episodes = range;
}
}
}
// 内容单元
let unit = it.contentUnitId ?? null;
if (unit) {
const u = units.get(unit);
if (!u || u.media_entity_id !== it.mediaEntityId) {
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
unit = null;
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
}
// Slot
let slotId = it.slotId ?? null;
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== it.mediaEntityId) {
warnings.push(`${r.rel}: Slot ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}: Slot 正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
const key = [it.mediaEntityId, unit ?? "", action, targetDir ?? "", targetLib ?? "", slotId ?? ""].join("|");
const g =
groups.get(key) ??
({
entity: it.mediaEntityId,
unit,
action,
targetDir,
targetLibraryRootId: targetLib,
slotId,
paths: [],
overrides: {},
warnings: [],
} satisfies Group);
g.paths.push(r.abs); g.paths.push(r.abs);
if (it.season !== undefined || it.episodes !== undefined || it.slotId !== undefined) if (Object.keys(ov).length > 0) g.overrides[r.abs] = ov;
g.warnings.push(`${r.rel}: 逐项季/集/Slot 覆写暂未生效(按文件名解析),待 planImport 支持 fileOverrides`); g.warnings.push(...warnings);
groups.set(key, g); groups.set(key, g);
} }
const guard = await sourceGuard(db); const guard = await sourceGuard(db);
@@ -82,13 +206,29 @@ export async function organizeFiles(
sourcePaths: g.paths, sourcePaths: g.paths,
mediaEntityId: g.entity, mediaEntityId: g.entity,
...(g.unit ? { contentUnitId: g.unit } : {}), ...(g.unit ? { contentUnitId: g.unit } : {}),
action: body.action, action: g.action,
...(body.targetLibraryRootId ? { targetLibraryRootId: body.targetLibraryRootId } : {}), ...(g.targetLibraryRootId ? { targetLibraryRootId: g.targetLibraryRootId } : {}),
};
const hasOverrides = Object.keys(g.overrides).length > 0;
const reqHash = hashJson({
key: requestKey(req),
...(hasOverrides ? { overrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
});
const base = {
mediaEntityId: g.entity,
contentUnitId: g.unit,
warnings: g.warnings,
action: g.action,
targetDir: g.targetDir,
slotId: g.slotId,
}; };
const reqHash = hashJson(requestKey(req));
const base = { mediaEntityId: g.entity, contentUnitId: g.unit, warnings: g.warnings };
const importReq = (dryRun: boolean, taskId?: string) => ({ const importReq = (dryRun: boolean, taskId?: string) => ({
...req, ...req,
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
...(g.targetDir ? { targetDir: g.targetDir } : {}),
...(g.slotId ? { slotId: g.slotId } : {}),
trigger: "manual" as const, trigger: "manual" as const,
actorType: actorType as "api_key" | "user", actorType: actorType as "api_key" | "user",
actorId: actor, actorId: actor,
@@ -140,7 +280,7 @@ export async function organizeFiles(
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览"); throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标目录或绑定已变化),请重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`); if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (body.action === "move") { if (g.action === "move") {
const managed = await managedTasksUnder(db, req.sourcePaths); const managed = await managedTasksUnder(db, req.sourcePaths);
if (!isAdmin(principal)) { if (!isAdmin(principal)) {
const auth = await authorizeMove(d.ao, principal, req, g.entity); const auth = await authorizeMove(d.ao, principal, req, g.entity);
+48 -102
View File
@@ -1,8 +1,8 @@
import { randomUUID } from "node:crypto"; import type { FilesRecordsQuery, FilesRecordsResult, OrganizationAction } from "@umt/contracts";
import type { OrganizationAction } from "@umt/contracts"; import { sql } from "@umt/db";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import type { Exec } from "../acquisition-organize/services/common.ts"; import type { Exec } from "../acquisition-organize/services/common.ts";
import { emitRecord } from "../acquisition-organize/services/organize.ts"; import { emitRecord, loadRecordsPublic } from "../acquisition-organize/services/organize.ts";
import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts"; import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts";
// 显式路径变更的数据库同步:asset_location 前缀替换、path_binding 修订(旧 superseded → 新 active)、 // 显式路径变更的数据库同步:asset_location 前缀替换、path_binding 修订(旧 superseded → 新 active)、
@@ -10,105 +10,12 @@ import { actorOfP, type FmDeps, hashJson, jsonb } from "./common.ts";
const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`; const likePrefix = (rel: string) => `${rel.replace(/[\\%_]/g, (m) => `\\${m}`)}/%`;
export interface RelocateInput { // 位置跟随/绑定修订与原样搬运(organize-transfer)共用同一实现
fromLib: string; export {
fromRel: string; type RelocateInput,
toLib: string; type RelocateResult,
toRel: string; relocate,
isDir: boolean; } from "../acquisition-organize/services/organize-transfer.ts";
actor: string;
opKind: "rename" | "move";
}
export interface RelocateResult {
locations: { id: string; assetId: string; from: string; to: string }[];
bindings: { oldBindingId: string; newBindingId: string }[];
mediaEntityIds: string[];
}
/** 同事务:把 fromLib/fromRel(目录含其下全部)改到 toLib/toRel */
export async function relocate(trx: Exec, input: RelocateInput): Promise<RelocateResult> {
const { fromLib, fromRel, toLib, toRel, isDir } = input;
const mapRel = (p: string) => (p === fromRel ? toRel : `${toRel}${p.slice(fromRel.length)}`);
const locs = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select(["l.id", "l.relative_path", "l.media_asset_id", "a.media_entity_id"])
.where("l.library_root_id", "=", fromLib)
.where("l.status", "<>", "removed")
.where((eb) =>
isDir
? eb.or([eb("l.relative_path", "=", fromRel), eb("l.relative_path", "like", likePrefix(fromRel))])
: eb("l.relative_path", "=", fromRel),
)
.execute();
const out: RelocateResult = { locations: [], bindings: [], mediaEntityIds: [] };
const entities = new Set<string>();
for (const l of locs) {
const to = mapRel(l.relative_path);
await trx
.updateTable("asset_location")
.set({ library_root_id: toLib, relative_path: to, last_seen_at: new Date() })
.where("id", "=", l.id)
.execute();
await trx
.insertInto("asset_operation")
.values({
operation_id: `files:${randomUUID()}`,
phase: "commit",
kind: input.opKind,
status: "succeeded",
intent: jsonb({
fromLibraryRootId: fromLib,
from: l.relative_path,
toLibraryRootId: toLib,
to,
by: input.actor,
}),
result: jsonb({ locationId: l.id }),
media_asset_id: l.media_asset_id,
started_at: new Date(),
finished_at: new Date(),
})
.execute();
out.locations.push({ id: l.id, assetId: l.media_asset_id, from: l.relative_path, to });
if (l.media_entity_id) entities.add(l.media_entity_id);
}
if (isDir) {
const binds = await trx
.selectFrom("path_binding")
.selectAll()
.where("library_root_id", "=", fromLib)
.where("status", "=", "active")
.where((eb) => eb.or([eb("relative_path", "=", fromRel), eb("relative_path", "like", likePrefix(fromRel))]))
.execute();
for (const b of binds) {
// 显式路径变更(REQ §7.1.1):旧绑定 superseded,新绑定 active 并互相链接;仅译名刷新永不走到这里
await trx.updateTable("path_binding").set({ status: "superseded" }).where("id", "=", b.id).execute();
const nb = await trx
.insertInto("path_binding")
.values({
media_entity_id: b.media_entity_id,
content_unit_id: b.content_unit_id,
season_number: b.season_number,
binding_kind: b.binding_kind,
library_root_id: toLib,
relative_path: mapRel(b.relative_path),
template_version: b.template_version,
status: "active",
bound_by: input.actor,
note: `files.${input.opKind}: ${b.relative_path} → ${mapRel(b.relative_path)}`,
})
.returning("id")
.executeTakeFirstOrThrow();
await trx.updateTable("path_binding").set({ superseded_by_id: nb.id }).where("id", "=", b.id).execute();
out.bindings.push({ oldBindingId: b.id, newBindingId: nb.id });
entities.add(b.media_entity_id);
}
}
out.mediaEntityIds = [...entities];
return out;
}
/** 预览:受影响的位置数与绑定(不写库) */ /** 预览:受影响的位置数与绑定(不写库) */
export async function relocatePreview(exec: Exec, lib: string, rel: string, isDir: boolean) { export async function relocatePreview(exec: Exec, lib: string, rel: string, isDir: boolean) {
@@ -213,3 +120,42 @@ export async function writeRecord(
await emitRecord(d.ao, trx, rec.id); await emitRecord(d.ao, trx, rec.id);
return rec.id; return rec.id;
} }
/**
* 整理记录回查(整理页 `/console/organize?ids=` 预填):按记录 id,或按 /files 写操作的 planId
* (move/copy/organize 经 task.idempotency_key = files:<planId>;删除/改名等直接写的记录看 plan.planId)。
*/
export async function findRecords(d: FmDeps, q: FilesRecordsQuery): Promise<FilesRecordsResult> {
const db = d.ctx.db;
const planRecords: Record<string, string> = {};
const planIds = q.planIds ?? [];
if (planIds.length > 0) {
const viaTask = await db
.selectFrom("organization_record as r")
.innerJoin("task as t", "t.id", "r.task_id")
.select(["r.id", "t.idempotency_key"])
.where(
"t.idempotency_key",
"in",
planIds.map((p) => `files:${p}`),
)
.orderBy("r.created_at", "desc")
.execute();
for (const r of viaTask) planRecords[r.idempotency_key.slice("files:".length)] ??= r.id;
const direct = await sql<{ id: string; plan_id: string }>`
select id, plan->>'planId' as plan_id from organization_record
where plan->>'planId' = any(${planIds}::text[]) order by created_at desc`.execute(db);
for (const r of direct.rows) planRecords[r.plan_id] ??= r.id;
}
const ids = [...new Set([...(q.ids ?? []), ...Object.values(planRecords)])];
const rows = ids.length
? await db
.selectFrom("organization_record")
.selectAll()
.where("id", "in", ids)
.orderBy("created_at", "desc")
.execute()
: [];
const items = (await loadRecordsPublic(db, rows, q.includeFiles)) as FilesRecordsResult["items"];
return { items, planRecords };
}
+14 -5
View File
@@ -4,14 +4,17 @@ import type { Context } from "hono";
import type { z } from "zod"; import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts"; import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { errorBody } from "../../http/errors.ts"; import { errorBody } from "../../http/errors.ts";
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
import type { ModuleApp } from "../types.ts"; import type { ModuleApp } from "../types.ts";
import { FilesError, type FmDeps, isAdmin } from "./common.ts"; import { FilesError, type FmDeps, fsErrorToFiles, isAdmin } from "./common.ts";
import { downloadFile } from "./download.ts";
import { identify } from "./identify.ts"; import { identify } from "./identify.ts";
import { idempotent } from "./ledger.ts"; import { idempotent } from "./ledger.ts";
import { listDir, listRoots, statEntry } from "./list.ts"; import { listDir, listRoots, statEntry } from "./list.ts";
import { mkdirIn, renameEntry } from "./mutate.ts"; import { mkdirIn, renameEntry } from "./mutate.ts";
import { organizeFiles } from "./organize.ts"; import { organizeFiles } from "./organize.ts";
import { probeFiles, scrape } from "./probe.ts"; import { probeFiles, scrape } from "./probe.ts";
import { findRecords } from "./records.ts";
import { transfer } from "./transfer.ts"; import { transfer } from "./transfer.ts";
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts"; import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
@@ -22,7 +25,7 @@ import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
type Input = { query: any; body: any }; type Input = { query: any; body: any };
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>; type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
function mount(app: ModuleApp, def: EndpointDef, handler: Handler) { function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false) {
const route = createRoute({ const route = createRoute({
method: def.method, method: def.method,
path: def.path, path: def.path,
@@ -34,7 +37,9 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}), ...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
}, },
responses: { responses: {
200: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } }, 200: raw
? { description: "文件流", content: { "application/octet-stream": { schema: def.response } } }
: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
}, },
}); });
app.openapi(route, (async (c: Context<AuthEnv>) => { app.openapi(route, (async (c: Context<AuthEnv>) => {
@@ -44,10 +49,12 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
query: def.query ? v.valid("query") : {}, query: def.query ? v.valid("query") : {},
body: def.body ? v.valid("json") : undefined, body: def.body ? v.valid("json") : undefined,
}); });
if (raw) return data as Response;
return c.json({ ok: true as const, data }, 200); return c.json({ ok: true as const, data }, 200);
} catch (err) { } catch (err) {
if (err instanceof FilesError) // 检查与使用之间的文件系统竞态(目录被删、挂载掉线)也按业务错误回,不给 500
return c.json(errorBody(err.code, `${err.reason}: ${err.message}`), err.status as 400); const fe = err instanceof PathGuardError ? new FilesError("path_escape", err.message) : fsErrorToFiles(err);
if (fe) return c.json(errorBody(fe.code, `${fe.reason}: ${fe.message}`), fe.status as 400);
throw err; throw err;
} }
}) as never); }) as never);
@@ -98,4 +105,6 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
mount(app, F.identify, async (c, { body }) => identify(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.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)); mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
} }
+149 -114
View File
@@ -1,29 +1,32 @@
import { lstat, mkdir, readdir, stat } from "node:fs/promises"; import { lstat, readdir, stat } from "node:fs/promises";
import { basename, dirname, join } from "node:path"; import { basename, dirname, join } from "node:path";
import { copyWithVerify, crossDeviceMove, moveSafe } from "@umt/adapters"; import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts";
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult } from "@umt/contracts";
import type { Principal } from "../../http/auth.ts"; import type { Principal } from "../../http/auth.ts";
import { authorizeMove } from "../acquisition-organize/services/organize-guard.ts"; import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
import { planImport } from "../acquisition-organize/services/organize.ts";
import { authorizeMove, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts";
import type { TransferSpec } from "../acquisition-organize/services/organize-transfer.ts";
import { import {
actorOfP, actorOfP,
errMessage,
FilesError, FilesError,
type FmDeps, type FmDeps,
hashJson, hashJson,
isAdmin, isAdmin,
type Resolved, type Resolved,
recheck,
relOf, relOf,
resolveIn, resolveIn,
within, within,
} from "./common.ts"; } from "./common.ts";
import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { audit, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts"; import { downloadsFor, isWritableTarget, managedDownloads } from "./list.ts";
import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts"; import { relocatePreview } from "./records.ts";
// 移动/复制到目录:dryRun 冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)→ planId; // 移动/复制到目录(W5-A2):与整理共用 planImport(原样搬运变体 transfer)、冻结计划存储与 plan_stale 校验。
// 执行校验 planId(未过期、请求一致)与源事实未变,否则 409 plan_stale。 // dryRun:冻结源事实(文件 size/mtime/inode;目录递归文件清单指纹)+ planImport 逐文件前后路径 → planId;
// 搬运原语与整理流程相同(adapters moveSafe / crossDeviceMove / copyWithVerify:no-clobber、跨卷 Copy→核验→删源、EXDEV 不降级)。 // 执行:校验 planId(未过期、请求一致)、源事实与重算的逐文件路径未变,否则 409 plan_stale;
// 然后写 organization_record 并入队 verifyAndImport(executeRecord:逐文件账本可恢复、fsproxy 挂死隔离、
// 跨卷 Copy→核验→删源、EXDEV 不降级),请求内最多等 FILES_TRANSFER_WAIT_SEC,超时返回 pending。
// move 完成后已登记位置跟随、整目录搬完时路径绑定写修订、源侧空目录删除(finalizeTransfer)。
interface SourceSnap { interface SourceSnap {
root: string; root: string;
@@ -81,6 +84,8 @@ async function snapshot(d: FmDeps, r: Resolved, rootId: string): Promise<SourceS
}; };
} }
const TERMINAL = new Set<OrganizationStatus>(["succeeded", "partial", "failed", "cancelled"]);
export async function transfer( export async function transfer(
d: FmDeps, d: FmDeps,
principal: Principal | undefined, principal: Principal | undefined,
@@ -89,7 +94,7 @@ export async function transfer(
const mode = body.mode; const mode = body.mode;
const t = await resolveIn(d, body.target.root, body.target.path); const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录"); if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!(await isWritableTarget(t.def, t.rootReal))) if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`); throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写(仅 managed 库根可作为目标)`);
const sources: Resolved[] = []; const sources: Resolved[] = [];
@@ -115,60 +120,94 @@ export async function transfer(
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status }); hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
const targetDev = Number((await stat(t.abs)).dev); const targetDev = Number((await stat(t.abs)).dev);
const spec: TransferSpec = {
items: sources.map((s, i) => {
const dst = join(t.abs, names[i]!);
return {
src: s.abs,
dst,
srcLibraryRootId: s.def.libraryRootId,
srcRel: s.def.libraryRootId ? s.rel : null,
dstRel: relOf(t.rootReal, dst),
};
}),
targetLibraryRootId: t.def.libraryRootId,
targetRootPath: t.rootReal,
};
const actorType = principal?.kind === "api_key" ? ("api_key" as const) : ("user" as const);
const importReq = (dryRun: boolean, extra: { taskId?: string; guard?: (p: string) => Promise<string> } = {}) => ({
transfer: { ...spec, ...(extra.guard ? { guard: extra.guard } : {}) },
action: mode,
trigger: "manual" as const,
actorType,
actorId: actorOfP(principal),
dryRun,
...(extra.taskId ? { taskId: extra.taskId } : {}),
});
const planned = await planImport(d.ao, importReq(true));
if (!planned.plan) {
const reason = planned.reason ?? "plan_failed";
throw new FilesError(
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
`无法生成搬运计划:${reason}`,
);
}
const planFiles = planned.plan.organize.files;
const filePairs = planFiles.map((f) => ({ src: f.src, dst: f.dst }));
const conflictSrc = new Set(planFiles.filter((f) => f.action === "hold").map((f) => f.src));
const under = (item: string, p: string) => p === item || p.startsWith(`${item}/`);
const pairs: FilePlanPair[] = []; const pairs: FilePlanPair[] = [];
let needBytes = 0; let needBytes = 0;
for (const [i, s] of sources.entries()) { for (const [i, s] of sources.entries()) {
const dst = join(t.abs, names[i]!); const dst = spec.items[i]!.dst;
const conflict = !!(await lstat(dst).catch(() => null));
const registered = s.def.libraryRootId const registered = s.def.libraryRootId
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0 ? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
: false; : false;
const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev;
pairs.push({ pairs.push({
source: { root: body.items[i]!.root, path: s.rel }, source: { root: body.items[i]!.root, path: s.rel },
target: { root: body.target.root, path: relOf(t.rootReal, dst) }, target: { root: body.target.root, path: relOf(t.rootReal, dst) },
size: snaps[i]!.size, size: snaps[i]!.size,
conflict, conflict: !!(await lstat(dst).catch(() => null)) || [...conflictSrc].some((p) => under(s.abs, p)),
registered, registered,
files: snaps[i]!.files.length,
crossDevice,
}); });
const sameDev = Number((await lstat(s.abs)).dev) === targetDev; if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size;
if (!(mode === "move" && sameDev)) needBytes += snaps[i]!.size;
} }
const du = await d.ao.ports.diskUsage(t.abs).catch(() => null); const du = await d.ao.ports.diskUsage(t.abs).catch(() => null);
const freeBytes = du?.status === "ok" ? du.availableBytes : null; const freeBytes = du?.status === "ok" ? du.availableBytes : null;
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes; const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
const truncated = snaps.some((s) => s.truncated); const truncated = snaps.some((s) => s.truncated) || planned.truncated === true;
const totalBytes = snaps.reduce((a, s) => a + s.size, 0); const totalBytes = snaps.reduce((a, s) => a + s.size, 0);
const requestHash = hashJson({ items: body.items, target: body.target, mode }); const requestHash = hashJson({ items: body.items, target: body.target, mode });
const request = { const request = {
sourcePaths: sources.map((s) => s.abs), sourcePaths: sources.map((s) => s.abs),
action: mode, action: mode,
targetLibraryRootId: t.def.libraryRootId ?? undefined, targetLibraryRootId: t.def.libraryRootId,
}; };
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
const warnings: string[] = [];
if (crossBlocked) warnings.push("跨卷移动未开启(设置 → 整理 → 允许跨卷 Move),执行会被拒绝;可改用复制");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,请分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
if (body.dryRun) { if (body.dryRun) {
const plan = await savePlan(d, "transfer", requestHash, principal, { const plan = await savePlan(d, "transfer", requestHash, principal, {
request, request,
sources: snaps.map((s) => ({ abs: s.abs, fingerprint: s.fingerprint })), sources: snaps.map((s) => ({ abs: s.abs, fingerprint: s.fingerprint })),
files: filePairs,
pairs, pairs,
}); });
return { return { dryRun: true, planId: plan.id, planExpiresAt: plan.expiresAt.toISOString(), ...base, executed: null };
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"); if (!body.planId) throw new FilesError("bad_request", "执行需要 dryRun 返回的 planId");
const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[] }>( const plan = await loadPlan<{ sources: { abs: string; fingerprint: string }[]; files?: typeof filePairs }>(
d, d,
body.planId, body.planId,
"transfer", "transfer",
@@ -179,8 +218,15 @@ export async function transfer(
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint) if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`); throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
} }
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
throw new FilesError("plan_stale", "重新计算的前后路径与预览不一致(目标已变化),请重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作"); if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,请分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`); if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (crossBlocked)
throw new FilesError(
"bad_request",
"跨卷移动未开启(settings.organize.allowCrossVolumeMove),请改用复制或开启后重试",
);
if (mode === "move") { if (mode === "move") {
if (hnrBlocks.length > 0) if (hnrBlocks.length > 0)
throw new FilesError( throw new FilesError(
@@ -204,100 +250,89 @@ export async function transfer(
} }
await retirePlan(d, plan.id); await retirePlan(d, plan.id);
const hash = d.ao.ports.options.verifyHash; const db = d.ctx.db;
const taskId = await ensureTaskRow(db, {
kind: "verifyAndImport",
idempotencyKey: `files:${plan.id}`,
input: { planId: plan.id, op: "transfer", mode, target: body.target },
subjectType: "organize_request",
triggeredBy: actorOfP(principal),
});
const guard = await sourceGuard(db);
let rec: Awaited<ReturnType<typeof planImport>>;
try {
rec = await planImport(d.ao, importReq(false, { taskId, guard: guard.check }));
} catch (e) {
await markTask(db, taskId, "failed", { error: e instanceof Error ? e.message : String(e) });
if (e instanceof PathGuardError) throw new FilesError("path_escape", e.message);
throw e;
}
if (!rec.recordId || rec.status === "failed") {
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
throw new FilesError("bad_request", `搬运计划不可执行:${rec.reason ?? rec.status}`);
}
const recordId = rec.recordId;
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
const { runId } = await d.ctx.engine.enqueue(
"verifyAndImport",
{ recordId, taskId },
{ idempotencyKey: `verifyAndImport:files:${plan.id}`, traceId: taskId },
);
await db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
// 请求内等待执行完成(小操作即时出结果);超时返回 pending,后台继续,可按 recordId 追踪
const deadline = Date.now() + d.options.transferWaitMs;
let status: OrganizationStatus = "running";
for (;;) {
const r = await db
.selectFrom("organization_record")
.select("status")
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
status = r.status;
if (TERMINAL.has(status) || Date.now() >= deadline) break;
await new Promise((res) => setTimeout(res, 150));
}
const pending = !TERMINAL.has(status);
const succeeded: string[] = []; const succeeded: string[] = [];
const failed: { path: string; code: string; message: string }[] = []; const failed: { path: string; code: string; message: string }[] = [];
const recFiles: RecordFile[] = []; if (!pending) {
const moved: { s: Resolved; dstRel: string; isDir: boolean }[] = []; const results = await db
for (const [i, s] of sources.entries()) { .selectFrom("organization_file_result")
const pair = pairs[i]!; .select(["source_path", "status", "error"])
const dst = join(t.abs, names[i]!); .where("organization_record_id", "=", recordId)
const fail = (code: string, message: string) => { .execute();
failed.push({ path: s.rel, code, message }); for (const s of sources) {
recFiles.push({ src: s.abs, dst: pair.target.path, size: pair.size, status: "failed", error: message }); const mine = results.filter((f) => under(s.abs, f.source_path) && f.status !== "skipped");
}; const bad = mine.find((f) => f.status !== "succeeded");
try { if (!bad) {
await recheck(s.abs, s.rootReal); succeeded.push(s.rel);
if (await lstat(dst).catch(() => null)) {
fail("exists", "目标已存在,不覆盖");
continue; continue;
} }
const isDir = snaps[i]!.isDir; const err = (bad.error ?? {}) as { code?: string; message?: string };
if (mode === "move") { failed.push({
const r = await moveSafe(s.abs, dst); path: s.rel,
if (r.status === "exists") { code: err.code === "EEXIST" ? "exists" : (err.code ?? bad.status),
fail("exists", "目标已存在,不覆盖"); message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
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));
} }
} }
await audit(d, principal, mode, {
const recordId = await d.ctx.db.transaction().execute(async (trx) => { planId: plan.id,
const entities = new Set<string>(); succeeded,
if (mode === "move" && t.def.libraryRootId) { failed,
for (const m of moved) { recordId,
if (!m.s.def.libraryRootId) continue; taskId,
const rr = await relocate(trx, { pending,
fromLib: m.s.def.libraryRootId, target: body.target,
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) : ""]); for (const it of body.items) filesChanged(d, it.root, [it.path.includes("/") ? dirname(it.path) : ""]);
filesChanged(d, body.target.root, [t.rel]); filesChanged(d, body.target.root, [t.rel]);
return { return {
dryRun: false, dryRun: false,
planId: plan.id, planId: plan.id,
planExpiresAt: plan.expiresAt.toISOString(), planExpiresAt: plan.expiresAt.toISOString(),
mode, ...base,
pairs, executed: { succeeded, failed, recordId, taskId, status, pending },
totalBytes,
freeBytes,
spaceOk,
hnrBlocks,
truncated,
executed: { succeeded, failed, recordId },
}; };
} }
+81 -13
View File
@@ -34,6 +34,7 @@ import {
} from "./common.ts"; } from "./common.ts";
import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts"; import { audit, consumeApproval, filesChanged, loadPlan, retirePlan, savePlan } from "./ledger.ts";
import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts"; import { bindingsAt, downloadsFor, isDownloading, managedDownloads } from "./list.ts";
import { type RecordFile, writeRecord } from "./records.ts";
// 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename; // 删除(原则 5):默认进回收站(同卷 <根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>,O(1) rename;
// 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。 // 不可写或跨卷时复制到 DATA_DIR/trash,超过上限拒绝)。引用检查:纳管做种 / 已登记资产 / 路径绑定 / 根本身。
@@ -172,7 +173,7 @@ export async function deleteFiles(
if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话"); if (!isAdmin(principal)) throw new FilesError("forbidden", "永久删除只允许控制台管理员会话");
} }
const downloads = await managedDownloads(d); const downloads = await managedDownloads(d);
const results = []; const results: Awaited<ReturnType<typeof checkItem>>[] = [];
for (const it of body.items) results.push(await checkItem(d, body, it, downloads)); for (const it of body.items) results.push(await checkItem(d, body, it, downloads));
const checks = results.map((r) => r.check); const checks = results.map((r) => r.check);
const requestHash = hashJson({ const requestHash = hashJson({
@@ -221,30 +222,38 @@ export async function deleteFiles(
const deleted: string[] = []; const deleted: string[] = [];
const failed: FilesDeleteResult["failed"] = []; const failed: FilesDeleteResult["failed"] = [];
const done: typeof results = []; const done: typeof results = [];
// 整理记录的文件级结果(删除/回收站也进整理记录页可追溯)
const recFiles: RecordFile[] = [];
for (const res of results) { for (const res of results) {
const { check, ctx } = res; const { check, ctx } = res;
const abs = ctx.abs!; const abs = ctx.abs!;
const inner = check.isDir ? await filesUnder(abs) : null;
try { try {
await recheck(abs, ctx.rootReal!); await recheck(abs, ctx.rootReal!);
let where: string | null = null;
if (body.mode === "permanent") { if (body.mode === "permanent") {
await rm(abs, { recursive: true, force: false }); await rm(abs, { recursive: true, force: false });
deleted.push(check.path); deleted.push(check.path);
} else { } else {
const row = await moveToTrash(d, principal, check, ctx, expiresAt); const row = await moveToTrash(d, principal, check, ctx, expiresAt);
trashed.push(toTrashItem(row)); trashed.push(toTrashItem(row));
where = row.trash_path;
} }
recFiles.push(...recordFilesOf(abs, where, check.size, inner, "succeeded"));
done.push(res); done.push(res);
} catch (e) { } catch (e) {
failed.push({ const f = {
path: check.path, path: check.path,
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"),
message: errMessage(e), message: errMessage(e),
}); };
failed.push(f);
recFiles.push({ src: abs, dst: null, size: check.size, status: "failed", error: `${f.code}: ${f.message}` });
} }
} }
// 资产退役 / 解除绑定(只对已实际删除/移入回收站的项) // 资产退役 / 解除绑定(只对已实际删除/移入回收站的项)+ 整理记录
await d.ctx.db.transaction().execute(async (trx) => { const recordId = await d.ctx.db.transaction().execute(async (trx) => {
const locIds = done.flatMap((r) => r.check.assets.map((a) => a.locationId)); const locIds = done.flatMap((r) => r.check.assets.map((a) => a.locationId));
if (locIds.length > 0) { if (locIds.length > 0) {
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "in", locIds).execute(); await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "in", locIds).execute();
@@ -281,6 +290,28 @@ export async function deleteFiles(
.set({ status: "superseded", note: `files.delete by ${actorOfP(principal)}` }) .set({ status: "superseded", note: `files.delete by ${actorOfP(principal)}` })
.where("id", "in", bindIds) .where("id", "in", bindIds)
.execute(); .execute();
if (recFiles.length === 0) return null;
const libs = [...new Set(results.map((r) => r.ctx.libraryRootId))];
const entities = [...new Set(done.flatMap((r) => r.check.assets.map((a) => a.mediaEntityId)))];
return writeRecord(d, trx, {
principal,
action: body.mode === "permanent" ? "delete" : "trash",
mediaEntityId: entities.length === 1 ? entities[0]! : null,
sourcePath: results.length === 1 ? results[0]!.ctx.abs! : dirname(results[0]!.ctx.abs!),
targetLibraryRootId: null,
targetPath: null,
plan: {
op: "delete",
mode: body.mode,
planId,
libraryRootId: libs.length === 1 ? libs[0] : null,
items: checks.map((c) => ({ root: c.root, path: c.path, isDir: c.isDir, size: c.size })),
trashed: trashed.map((t) => ({ id: t.id, trashPath: t.trashPath })),
retiredLocations: done.flatMap((r) => r.check.assets.map((a) => a.locationId)),
unboundBindings: bindIds,
},
files: recFiles,
});
}); });
await audit(d, principal, body.mode === "permanent" ? "delete_permanent" : "delete", { await audit(d, principal, body.mode === "permanent" ? "delete_permanent" : "delete", {
@@ -289,6 +320,7 @@ export async function deleteFiles(
trashed: trashed.map((t) => t.id), trashed: trashed.map((t) => t.id),
deleted, deleted,
failed, failed,
recordId,
}); });
for (const c of checks) filesChanged(d, c.root, [c.path.includes("/") ? dirname(c.path) : ""]); for (const c of checks) filesChanged(d, c.root, [c.path.includes("/") ? dirname(c.path) : ""]);
return { return {
@@ -300,10 +332,28 @@ export async function deleteFiles(
trashed, trashed,
deleted, deleted,
failed, failed,
recordId: null, recordId,
}; };
} }
/** 目录下的文件(相对路径);超过上限返回 null(整理记录只记目录本身一行) */
const RECORD_FILES_MAX = 2000;
async function filesUnder(abs: string): Promise<string[] | null> {
const { files } = await sizeOf(abs, RECORD_FILES_MAX + 1).catch(() => ({ files: [] as string[] }));
return files.length > RECORD_FILES_MAX ? null : files;
}
function recordFilesOf(
abs: string,
where: string | null,
size: number,
inner: string[] | null,
status: RecordFile["status"],
): RecordFile[] {
if (!inner || inner.length === 0) return [{ src: abs, dst: where, size, status }];
return inner.map((f) => ({ src: join(abs, f), dst: where ? join(where, f) : null, size: 0, status }));
}
async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") { async function copyTree(src: string, dst: string, files: string[], hash: "size" | "sha256" | "sample") {
const st = await lstat(src); const st = await lstat(src);
if (!st.isDirectory()) { if (!st.isDirectory()) {
@@ -423,6 +473,8 @@ export async function restoreTrash(
): Promise<FilesTrashRestoreResult> { ): Promise<FilesTrashRestoreResult> {
const restored: FilesTrashRestoreResult["restored"] = []; const restored: FilesTrashRestoreResult["restored"] = [];
const failed: FilesTrashRestoreResult["failed"] = []; const failed: FilesTrashRestoreResult["failed"] = [];
const recFiles: RecordFile[] = [];
const libs = new Set<string | null>();
const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null; const target = body.target ? await resolveIn(d, body.target.root, body.target.path) : null;
if (target && (!target.lst?.isDirectory() || target.isSymlink)) if (target && (!target.lst?.isDirectory() || target.isSymlink))
throw new FilesError("bad_request", "还原目标必须是已存在的目录"); throw new FilesError("bad_request", "还原目标必须是已存在的目录");
@@ -465,18 +517,34 @@ export async function restoreTrash(
} }
await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute(); await d.ctx.db.deleteFrom("trash_item").where("id", "=", id).execute();
const rel = dst === rootReal ? "" : dst.slice(rootReal.length + 1); const rel = dst === rootReal ? "" : dst.slice(rootReal.length + 1);
libs.add(target ? target.def.libraryRootId : row.library_root_id);
recFiles.push({ src: row.trash_path, dst: rel, size: Number(row.size_bytes), status: "succeeded" });
restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel }); restored.push({ id, root: rootId as FilesTrashRestoreResult["restored"][number]["root"], path: rel });
filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]); filesChanged(d, rootId, [rel.includes("/") ? dirname(rel) : ""]);
} catch (e) { } catch (e) {
failed.push({ const f = { id, code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), message: errMessage(e) };
id, failed.push(f);
code: e instanceof FilesError ? e.reason : (errnoOf(e) ?? "io_error"), recFiles.push({ src: `trash:${id}`, dst: null, size: 0, status: "failed", error: `${f.code}: ${f.message}` });
message: errMessage(e),
});
} }
} }
await audit(d, principal, "trash_restore", { restored, failed }); const lib = libs.size === 1 ? [...libs][0]! : null;
return { restored, failed }; const recordId =
restored.length > 0
? await d.ctx.db.transaction().execute((trx) =>
writeRecord(d, trx, {
principal,
action: "restore",
mediaEntityId: null,
sourcePath: recFiles.find((f) => f.status === "succeeded")!.src,
targetLibraryRootId: lib,
targetPath: restored.length === 1 ? restored[0]!.path : null,
plan: { op: "trash_restore", ids: body.ids, target: body.target ?? null, restored },
files: recFiles,
}),
)
: null;
await audit(d, principal, "trash_restore", { restored, failed, recordId });
return { restored, failed, recordId };
} }
/** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */ /** 清除:先删行(复核仍存在)再删文件;ids 缺省 = 只清过期项 */
+33 -2
View File
@@ -14,6 +14,7 @@ import { hashApiKey } from "../../src/http/auth.ts";
import { EventBus } from "../../src/http/sse.ts"; import { EventBus } from "../../src/http/sse.ts";
import { createLogger } from "../../src/logger.ts"; import { createLogger } from "../../src/logger.ts";
import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts"; import { fakeEngine } from "../../src/modules/acquisition-organize/__tests__/harness.ts";
import { executeRecord, type FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts"; import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts"; import { createFileManagerModule, type FmOverrides } from "../../src/modules/file-manager/index.ts";
import type { ServerContext } from "../../src/modules/types.ts"; import type { ServerContext } from "../../src/modules/types.ts";
@@ -47,6 +48,7 @@ type Loose = any;
export interface FmHarness { export interface FmHarness {
db: Kysely<DB>; db: Kysely<DB>;
engine: ReturnType<typeof fakeEngine>; engine: ReturnType<typeof fakeEngine>;
module: ReturnType<typeof createFileManagerModule>;
events: { type: string; payload: Loose }[]; events: { type: string; payload: Loose }[];
base: string; base: string;
lib: string; lib: string;
@@ -57,13 +59,16 @@ export interface FmHarness {
libraryRootId: string; libraryRootId: string;
instanceId: string; instanceId: string;
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>; api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>;
close(): Promise<void>; close(): Promise<void>;
} }
export async function createFmHarness( export async function createFmHarness(
name: string, name: string,
overrides: FmOverrides = {}, overrides: FmOverrides = {},
opts: { catalog?: boolean } = {}, /** runTransfers(默认 true):假引擎收到 /files 移动/复制的 verifyAndImport 时就地执行(模拟 worker) */
opts: { catalog?: boolean; runTransfers?: boolean } = {},
): Promise<FmHarness> { ): Promise<FmHarness> {
const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`; const schema = `fm_test_${name}_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
const admin = new pg.Client({ connectionString: DB_URL }); const admin = new pg.Client({ connectionString: DB_URL });
@@ -144,8 +149,30 @@ export async function createFmHarness(
...overrides.ao, ...overrides.ao,
options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) }, options: { backgroundLoops: false, minVideoBytes: 10, verifyHash: "sha256", ...(overrides.ao?.options ?? {}) },
}, },
options: { backgroundLoops: false, dataDir: join(base, "data"), ...overrides.options }, options: { backgroundLoops: false, dataDir: join(base, "data"), transferWaitMs: 10_000, ...overrides.options },
}); });
if (opts.runTransfers !== false) {
const enqueue = engine.enqueue.bind(engine);
engine.enqueue = async (task, input, o) => {
const r = await enqueue(task, input, o);
const recordId = (input as { recordId?: string }).recordId;
if (task === "verifyAndImport" && recordId)
setTimeout(async () => {
try {
const rec = await db
.selectFrom("organization_record")
.select("plan")
.where("id", "=", recordId)
.executeTakeFirst();
if ((rec?.plan as unknown as FrozenPlan | undefined)?.transfer)
await executeRecord(module.deps().ao, recordId);
} catch {
// 模拟 worker:失败留给测试断言(记录保持 running)
}
}, 0);
return r;
};
}
const app = createApp({ const app = createApp({
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY }, config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
logger, logger,
@@ -178,6 +205,7 @@ export async function createFmHarness(
return { return {
db, db,
engine, engine,
module,
events, events,
base, base,
lib, lib,
@@ -198,6 +226,9 @@ export async function createFmHarness(
}); });
return { status: res.status, body: await res.json() }; return { status: res.status, body: await res.json() };
}, },
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: { authorization: `Bearer ${key}` } }));
},
async close() { async close() {
await db.destroy(); await db.destroy();
const c = new pg.Client({ connectionString: DB_URL }); const c = new pg.Client({ connectionString: DB_URL });
+414
View File
@@ -0,0 +1,414 @@
import { randomUUID } from "node:crypto";
import { access, copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { findCrossDeviceBase } from "@umt/testkit";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { fsErrorToFiles } from "../../src/modules/file-manager/common.ts";
import {
createFmHarness,
EXEC_KEY,
type FmHarness,
key,
pgAvailable,
READ_KEY,
seedAsset,
seedBinding,
seedEntity,
sql,
} from "./harness.ts";
// W5-A2:移动/复制走 planImport(目录跨卷逐文件可恢复)、整理逐项覆写生效、删除/还原写整理记录、
// 下载守卫、记录回查、文件系统竞态不 500。
const skip = !(await pgAvailable());
const xdev = findCrossDeviceBase();
const exists = (p: string) =>
access(p).then(
() => true,
() => false,
);
describe.skipIf(skip)("files W5-A2", () => {
let h: FmHarness;
beforeAll(async () => {
h = await createFmHarness(
"w5a2",
{ options: { allowPermanentDelete: true, downloadMaxBytes: 100, transferWaitMs: 200 } },
{ runTransfers: false },
);
});
afterAll(async () => h?.close());
it.skipIf(!xdev)(
"目录跨卷移动:逐文件 Copy→核验→删源;中途 kill 后续跑 resumed 对账;位置跟随、绑定修订、删空目录",
async () => {
// 第二个库根放在另一设备上
const xlib = await mkdtemp(join(xdev!, "fm-xlib-"));
try {
await writeFile(join(xlib, ".uhd-root"), "sentinel\n");
await mkdir(join(xlib, "tv"));
const x = await h.db
.insertInto("library_root")
.values({ name: "跨卷库", media_kind: "mixed", local_path: xlib, management_mode: "managed" })
.returning("id")
.executeTakeFirstOrThrow();
const show = await seedEntity(h.db, { kind: "series", title: "Show", year: 2020 });
await mkdir(join(h.lib, "Show", "extras"), { recursive: true });
await writeFile(join(h.lib, "Show", "Show.S01E01.mkv"), "episode-one");
await writeFile(join(h.lib, "Show", "Show.S01E02.mkv"), "episode-two");
await writeFile(join(h.lib, "Show", "extras", "bonus.nfo"), "<nfo/>");
const a = await seedAsset(h.db, h, { entityId: show, rel: "Show/Show.S01E01.mkv" });
const bind = await seedBinding(h.db, h, { entityId: show, rel: "Show" });
const body = {
items: [{ root: h.libRoot, path: "Show" }],
target: { root: `lib:${x.id}`, path: "tv" },
mode: "move",
};
// 跨卷 Move 未开启:预览给 warning,执行 400(不产生记录)
await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: false })}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(h.db);
const off = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
expect(off.body.data.warnings.join()).toMatch(/跨卷移动未开启/);
const offExec = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: off.body.data.planId,
});
expect(offExec.status).toBe(400);
await sql`insert into settings (key, value) values ('organize', ${JSON.stringify({ allowCrossVolumeMove: true })}::jsonb)
on conflict (key) do update set value = excluded.value`.execute(h.db);
const dry = await h.api("POST", "/files/move", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.pairs[0]).toMatchObject({
files: 3,
crossDevice: true,
registered: true,
conflict: false,
});
expect(dry.body.data.warnings).toEqual([]);
// 执行:假引擎不跑 worker → 等待窗口内未完成,返回 pending + recordId/taskId
const exec = await h.api("POST", "/files/move", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.status).toBe(200);
const ex = exec.body.data.executed;
expect(ex).toMatchObject({ pending: true, succeeded: [], failed: [], status: "running" });
expect(ex.taskId).toBeTruthy();
const recordId = ex.recordId as string;
expect(h.engine.enqueued.at(-1)).toMatchObject({ task: "verifyAndImport", input: { recordId } });
// 模拟上一次 attempt:第一个文件已复制到目标,删源前 worker 被 kill(账本停在 started)
const first = await h.db
.selectFrom("organization_file_result")
.select(["ordinal", "source_path", "target_path"])
.where("organization_record_id", "=", recordId)
.where("source_path", "=", join(h.lib, "Show", "Show.S01E01.mkv"))
.executeTakeFirstOrThrow();
expect(first.target_path).toBe("tv/Show/Show.S01E01.mkv");
await mkdir(join(xlib, "tv", "Show"), { recursive: true });
await copyFile(first.source_path, join(xlib, first.target_path!));
await sql`insert into tasks_op_ledger (op_key, attempt_token, attempts, status)
values (${`org:${recordId}:${first.ordinal}`}, ${randomUUID()}, 1, 'started')`.execute(h.db);
const r = await executeRecord(h.module.deps().ao, recordId);
expect(r.status).toBe("succeeded");
expect(await readFile(join(xlib, "tv", "Show", "Show.S01E01.mkv"), "utf8")).toBe("episode-one");
expect(await readFile(join(xlib, "tv", "Show", "Show.S01E02.mkv"), "utf8")).toBe("episode-two");
expect(await readFile(join(xlib, "tv", "Show", "extras", "bonus.nfo"), "utf8")).toBe("<nfo/>");
// 源目录(含空子目录)已删
expect(await exists(join(h.lib, "Show"))).toBe(false);
const op = await h.db
.selectFrom("asset_operation")
.select("result")
.where("operation_id", "=", `org:${recordId}:${first.ordinal}`)
.executeTakeFirstOrThrow();
const res = op.result as { resumed?: boolean; steps?: { step: string; status: string }[] };
expect(res.resumed).toBe(true);
expect(res.steps?.find((s) => s.step === "copy")?.status).toBe("skipped");
// 位置跟随到新库根;绑定写修订
const loc = await h.db
.selectFrom("asset_location")
.select(["library_root_id", "relative_path"])
.where("id", "=", a.locationId)
.executeTakeFirstOrThrow();
expect(loc).toEqual({ library_root_id: x.id, relative_path: "tv/Show/Show.S01E01.mkv" });
const old = await h.db
.selectFrom("path_binding")
.select(["status", "superseded_by_id"])
.where("id", "=", bind)
.executeTakeFirstOrThrow();
expect(old.status).toBe("superseded");
const nb = await h.db
.selectFrom("path_binding")
.select(["library_root_id", "relative_path", "status"])
.where("id", "=", old.superseded_by_id!)
.executeTakeFirstOrThrow();
expect(nb).toEqual({ library_root_id: x.id, relative_path: "tv/Show", status: "active" });
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status", "file_succeeded", "media_entity_id"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
expect(rec).toEqual({ action: "move", status: "succeeded", file_succeeded: 3, media_entity_id: show });
// 重复 finalize 幂等
expect((await executeRecord(h.module.deps().ao, recordId)).status).toBe("succeeded");
// planId 回查整理记录(整理页预填)
const q = await h.api(
"GET",
`/files/records?planIds=${dry.body.data.planId}&includeFiles=true`,
undefined,
READ_KEY,
);
expect(q.status).toBe(200);
expect(q.body.data.planRecords[dry.body.data.planId]).toBe(recordId);
expect(q.body.data.items[0].files).toHaveLength(3);
} finally {
await sql`delete from settings where key = 'organize'`.execute(h.db);
await rm(xlib, { recursive: true, force: true });
}
},
60_000,
);
it("复制目录走同一计划:目标已存在的文件不覆盖、记失败(partial),其余照常", async () => {
await mkdir(join(h.dl, "Pack", "sub"), { recursive: true });
await writeFile(join(h.dl, "Pack", "a.mkv"), "aaa");
await writeFile(join(h.dl, "Pack", "sub", "b.srt"), "bbb");
await mkdir(join(h.lib, "dest", "Pack"), { recursive: true });
await writeFile(join(h.lib, "dest", "Pack", "a.mkv"), "old");
const body = { items: [{ root: h.dlRoot, path: "Pack" }], target: { root: h.libRoot, path: "dest" } };
const dry = await h.api("POST", "/files/copy", { ...body, idempotencyKey: key(), dryRun: true });
expect(dry.status).toBe(200);
expect(dry.body.data.pairs[0]).toMatchObject({ files: 2, conflict: true });
expect(dry.body.data.warnings.join()).toMatch(/已存在/);
const exec = await h.api("POST", "/files/copy", {
...body,
idempotencyKey: key(),
dryRun: false,
planId: dry.body.data.planId,
});
const recordId = exec.body.data.executed.recordId;
const r = await executeRecord(h.module.deps().ao, recordId);
expect(r.status).toBe("partial");
expect(await readFile(join(h.lib, "dest", "Pack", "a.mkv"), "utf8")).toBe("old");
expect(await readFile(join(h.lib, "dest", "Pack", "sub", "b.srt"), "utf8")).toBe("bbb");
expect(await exists(join(h.dl, "Pack", "a.mkv"))).toBe(true);
});
it("整理逐项覆写:季/集改变计划路径;目标目录与操作覆写分组;无法应用的才进 warnings;Slot 覆写在核验后生效", async () => {
const series = await seedEntity(h.db, { kind: "series", title: "剧", year: 2020 });
const film = await seedEntity(h.db, { kind: "movie", title: "Film", year: 2019 });
await writeFile(join(h.dl, "weird-a.mkv"), "x".repeat(64));
await writeFile(join(h.dl, "weird-b.mkv"), "y".repeat(64));
await writeFile(join(h.dl, "Film.2019.1080p.mkv"), "z".repeat(64));
await mkdir(join(h.lib, "picked"), { recursive: true });
const slot = await h.db
.insertInto("acquisition_slot")
.values({ media_entity_id: film, name: "Film 主版本", status: "empty" } as never)
.returning("id")
.executeTakeFirstOrThrow();
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: true,
items: [
{ root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] },
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 7] },
{
root: h.dlRoot,
path: "Film.2019.1080p.mkv",
mediaEntityId: film,
season: 1,
slotId: slot.id,
targetDir: { root: h.libRoot, path: "picked" },
operation: "copy",
},
],
});
expect(dry.status).toBe(200);
const groups = dry.body.data.groups;
expect(groups).toHaveLength(2);
const tv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === series);
const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film);
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04.mkv"))).toBe(true);
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv: 集号 5,7 不连续/)]);
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
action: "copy",
dryRun: false,
planIds: groups.map((g: { planId: string }) => g.planId),
items: [
{
root: h.dlRoot,
path: "Film.2019.1080p.mkv",
mediaEntityId: film,
season: 1,
slotId: slot.id,
targetDir: { root: h.libRoot, path: "picked" },
operation: "copy",
},
],
});
expect(exec.status).toBe(200);
const g = exec.body.data.groups[0];
expect(g.error).toBeNull();
await executeRecord(h.module.deps().ao, g.recordId);
expect(await readFile(join(h.lib, "picked", "Film (2019) - 1080p.mkv"), "utf8")).toBe("z".repeat(64));
const s = await h.db
.selectFrom("acquisition_slot")
.select(["status", "current_asset_id"])
.where("id", "=", slot.id)
.executeTakeFirstOrThrow();
expect(s.current_asset_id).not.toBeNull();
expect(s.status).toBe("satisfied");
// 目标目录覆写不新建路径绑定
const binds = await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", film).execute();
expect(binds).toEqual([]);
});
it("删除/永久删除/还原都写整理记录(文件级结果)", async () => {
await mkdir(join(h.lib, "gone", "in"), { recursive: true });
await writeFile(join(h.lib, "gone", "x.srt"), "1");
await writeFile(join(h.lib, "gone", "in", "y.srt"), "2");
const del = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "gone" }],
dryRun: false,
});
expect(del.status).toBe(200);
const recId = del.body.data.recordId;
expect(recId).toBeTruthy();
const rec = await h.db
.selectFrom("organization_record")
.select(["action", "status", "file_total", "file_succeeded"])
.where("id", "=", recId)
.executeTakeFirstOrThrow();
expect(rec).toEqual({ action: "trash", status: "succeeded", file_total: 2, file_succeeded: 2 });
const files = await h.db
.selectFrom("organization_file_result")
.select(["source_path", "target_path", "mode"])
.where("organization_record_id", "=", recId)
.orderBy("ordinal")
.execute();
expect(files.map((f) => f.source_path).sort()).toEqual(
[join(h.lib, "gone", "in", "y.srt"), join(h.lib, "gone", "x.srt")].sort(),
);
expect(files.every((f) => f.mode === "trash" && f.target_path?.includes(".umt-trash"))).toBe(true);
const rs = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [del.body.data.trashed[0].id],
});
expect(rs.status).toBe(200);
expect(rs.body.data.restored).toHaveLength(1);
const rrec = await h.db
.selectFrom("organization_record")
.select(["action", "status", "target_library_root_id", "target_path"])
.where("id", "=", rs.body.data.recordId)
.executeTakeFirstOrThrow();
expect(rrec).toEqual({
action: "restore",
status: "succeeded",
target_library_root_id: h.libraryRootId,
target_path: "gone",
});
const perm = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "gone/x.srt" }],
mode: "permanent",
confirmName: "x.srt",
dryRun: false,
});
expect(perm.status).toBe(200);
const prec = await h.db
.selectFrom("organization_record")
.select(["action", "file_total"])
.where("id", "=", perm.body.data.recordId)
.executeTakeFirstOrThrow();
expect(prec).toEqual({ action: "delete", file_total: 1 });
// 按 planId/记录 id 回查
const q = await h.api("GET", `/files/records?ids=${recId},${perm.body.data.recordId}`);
expect(q.body.data.items.map((i: { action: string }) => i.action).sort()).toEqual(["delete", "trash"]);
});
it("下载:仅管理员会话;只允许 nfo/字幕/图片且不超上限;路径守卫;流式 + 审计", async () => {
await mkdir(join(h.lib, "dl-test"), { recursive: true });
await writeFile(join(h.lib, "dl-test", "海报.jpg"), "JPEGDATA");
await writeFile(join(h.lib, "dl-test", "movie.nfo"), "<movie/>");
await writeFile(join(h.lib, "dl-test", "big.srt"), "x".repeat(200));
await writeFile(join(h.lib, "dl-test", "v.mkv"), "video");
await symlink(join(h.outside, "secret.txt"), join(h.lib, "dl-test", "leak.nfo"));
const url = (path: string, extra = "") =>
`/files/download?root=${encodeURIComponent(h.libRoot)}&path=${encodeURIComponent(path)}${extra}`;
const ok = await h.raw("GET", url("dl-test/海报.jpg", "&inline=true"));
expect(ok.status).toBe(200);
expect(await ok.text()).toBe("JPEGDATA");
expect(ok.headers.get("content-type")).toBe("image/jpeg");
expect(ok.headers.get("content-disposition")).toMatch(/^inline; .*filename\*=UTF-8''%E6%B5%B7%E6%8A%A5\.jpg/);
expect(ok.headers.get("x-content-type-options")).toBe("nosniff");
const nfo = await h.raw("GET", url("dl-test/movie.nfo", "&inline=true"));
expect(nfo.headers.get("content-disposition")).toMatch(/^attachment;/);
expect(await nfo.text()).toBe("<movie/>");
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, READ_KEY)).status).toBe(403);
expect((await h.api("GET", url("dl-test/movie.nfo"), undefined, EXEC_KEY)).status).toBe(403);
const video = await h.api("GET", url("dl-test/v.mkv"));
expect(video.status).toBe(403);
const big = await h.api("GET", url("dl-test/big.srt"));
expect(big.status).toBe(413);
expect(big.body.error.message).toMatch(/^too_large:/);
const leak = await h.api("GET", url("dl-test/leak.nfo"));
expect(leak.status).toBe(400);
expect(leak.body.error.message).toMatch(/^path_escape:/);
expect((await h.api("GET", url("dl-test"))).status).toBe(400);
expect((await h.api("GET", url("dl-test/none.nfo"))).status).toBe(404);
expect((await h.api("GET", url("../outside/secret.txt"))).status).toBeGreaterThanOrEqual(400);
const audits = await h.db
.selectFrom("audit_event")
.select(["action"])
.where("action", "=", "files.download")
.execute();
expect(audits.length).toBe(2);
});
it("文件系统竞态/离线不 500:errno 映射;下载根目录整个不见 → 503", async () => {
const e = (code: string) => Object.assign(new Error(code), { code });
expect(fsErrorToFiles(e("ENOTCONN"))?.status).toBe(503);
expect(fsErrorToFiles(e("EIO"))?.reason).toBe("root_unavailable");
expect(fsErrorToFiles(e("ENOENT"))?.status).toBe(404);
expect(fsErrorToFiles(e("EACCES"))?.status).toBe(403);
expect(fsErrorToFiles(e("ELOOP"))?.status).toBe(400);
expect(fsErrorToFiles(new Error("x"))).toBeNull();
const hidden = `${h.dl}.away`;
await rm(hidden, { recursive: true, force: true });
await import("node:fs/promises").then((fs) => fs.rename(h.dl, hidden));
try {
const list = await h.api("GET", `/files?root=${encodeURIComponent(h.dlRoot)}&path=`);
expect(list.status).toBe(503);
expect(list.body.error.message).toMatch(/^root_unavailable:/);
const st = await h.api("GET", `/files/stat?root=${encodeURIComponent(h.dlRoot)}&path=x`);
expect(st.status).toBe(503);
} finally {
await import("node:fs/promises").then((fs) => fs.rename(hidden, h.dl));
}
});
});
+4 -1
View File
@@ -210,6 +210,8 @@ describe.skipIf(skip)("files 写操作", () => {
size: 8, size: 8,
conflict: false, conflict: false,
registered: false, registered: false,
files: 1,
crossDevice: false,
}, },
]); ]);
const planId = dry.body.data.planId; const planId = dry.body.data.planId;
@@ -271,7 +273,8 @@ describe.skipIf(skip)("files 写操作", () => {
.select(["action", "status"]) .select(["action", "status"])
.where("id", "=", exec.body.data.executed.recordId) .where("id", "=", exec.body.data.executed.recordId)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
expect(rec).toEqual({ action: "relocate", status: "succeeded" }); // W5-A2:搬运走 planImport → verifyAndImport,记录 action 与整理一致(move/copy)
expect(rec).toEqual({ action: "move", status: "succeeded" });
}); });
it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => { it("TOCTOU:预览后把源换成指向根外的符号链接,执行被拒绝且根外文件不动", async () => {
+3
View File
@@ -65,6 +65,9 @@ const ORG_ACTION: Record<string, string> = {
rename: "重命名", rename: "重命名",
relocate: "目录调整", relocate: "目录调整",
hardlink: "硬链接", hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
}; };
export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v; export const organizationActionLabel = (v: string) => ORG_ACTION[v] ?? v;
@@ -17,6 +17,9 @@ const ACTION_LABEL = {
rename: "重命名", rename: "重命名",
relocate: "目录调整", relocate: "目录调整",
hardlink: "硬链接", hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
} as const; } as const;
const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "库根相对" } as const; const VIEW_LABEL = { downloader: "下载器视角", local: "执行进程本地", library: "库根相对" } as const;
const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = { const FILE_STATUS: Record<PublicOrganizationFile["status"], { label: string; tone: StatusTone }> = {
+3
View File
@@ -20,6 +20,9 @@ const ACTION_LABEL = {
rename: "重命名", rename: "重命名",
relocate: "目录调整", relocate: "目录调整",
hardlink: "硬链接", hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
} as const; } as const;
function Counts({ r }: { r: PublicOrganizationRecord }) { function Counts({ r }: { r: PublicOrganizationRecord }) {
+18 -12
View File
@@ -1,7 +1,7 @@
# 文件管理(`/files`,W5-A) # 文件管理(`/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`。 实现:`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 判定),不改它的文件。 复用 acquisition-organize 的服务导出(`planImport`、`sourceGuard`、`authorizeMove`、`hnrBlockForMove`、`managedTasksUnder`、`emitRecord`、H&R 判定)。W5-A2 给 `planImport` 加了可选参数(`fileOverrides` / `targetDir` / `slotId` / `transfer`,老调用不变),原样搬运的计划与终态在 `acquisition-organize/services/organize-transfer.ts`(`planTransfer` / `finalizeTransfer` / `relocate`)。
## 根与路径 ## 根与路径
@@ -19,27 +19,30 @@
- move / copy / organize / delete:先 `dryRun:true` 拿 `planId`(存 `artifact kind=plan, title=files_plan`,默认 30 分钟有效,`FILES_PLAN_TTL_SEC`),执行时回传。 - 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,前后路径必须与冻结计划一致。 - 执行校验:planId 存在、未过期、未用过,且与本次请求体一致(否则 409 `plan_stale`);源文件事实未变——文件看 size/mtime/inode,目录看递归文件清单(路径+size+mtime)指纹;organize 另外重算一次 dryRun,前后路径必须与冻结计划一致。
- 幂等:写请求带 `idempotencyKey`,按「调用者 + 操作 + 键」唯一(`artifact title=files_idem`,唯一索引 `artifact_files_idem_key`)。同键重放返回首次结果;同键不同请求体或首个请求仍在执行 → 409。执行失败不占键。dryRun 不占键。 - 幂等:写请求带 `idempotencyKey`,按「调用者 + 操作 + 键」唯一(`artifact title=files_idem`,唯一索引 `artifact_files_idem_key`)。同键重放返回首次结果;同键不同请求体或首个请求仍在执行 → 409。执行失败不占键。dryRun 不占键。
- 所有执行都写 `audit_event`(`files.<op>`)并发 SSE `files.changed {rootId, dirs[]}`;路径变更同时写 `organization_record`(rename → `rename`,移动 → `relocate`,复制 → `copy`,整理 → planImport 原记录),外部程序按 changeSeq 增量感知。 - 所有执行都写 `audit_event`(`files.<op>`)并发 SSE `files.changed {rootId, dirs[]}`;路径变更同时写 `organization_record`(rename → `rename`,移动/复制 → planImport 记录 `move`/`copy`,整理 → planImport 原记录,删除 → `trash`/`delete`,回收站还原 → `restore`),外部程序按 changeSeq 增量感知。
- 整理页回查:`GET /files/records?ids=<recordId,…>` 或 `?planIds=<planId,…>`(move/copy/organize 经 `task.idempotency_key = files:<planId>`,删除等看 `plan.planId`),返回公开整理记录 + `planRecords{planId→recordId}`;`includeFiles=true` 带逐文件结果。
## 各操作要点 ## 各操作要点
| 操作 | 要点 | | 操作 | 要点 |
|---|---| |---|---|
| 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 失败把文件改回原名 | | 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`),绑定目录写修订 | | move/copy | 目标为 writable 库根下已存在目录;源≠目标、目标不在源内;不覆盖。**走 `planImport` 的原样搬运变体**(`transfer`):目录递归成逐文件计划(保持文件名与结构,空子目录照建,符号链接不搬),dryRun 冻结源指纹 + 逐文件前后路径,执行时两者任一变化 → 409 `plan_stale`;执行写 `organization_record(move/copy)` + 入队 `verifyAndImport`(`executeRecord`:逐文件账本 `org:<recordId>:<ordinal>` 可恢复、fsproxy 挂死隔离、跨卷 Copy→核验→删源、EXDEV 不降级)。跨卷 Move 需 `settings.organize.allowCrossVolumeMove`(关闭时预览 `warnings` 提示、执行 400)。目标已存在的文件记失败、不覆盖。请求内最多等 `FILES_TRANSFER_WAIT_SEC`(默认 20 s):完成则 `executed.succeeded/failed` 为最终结果,否则 `executed.pending=true`,后台继续,按 `executed.recordId/taskId` 追踪。move 完成后(`finalizeTransfer`,记录行锁):已登记 `asset_location` 逐文件跟随(跨库根改 `library_root_id`),整项搬完时目录命中的 `path_binding` 写修订,源侧空目录删除。move 触及纳管下载任务 → 409(请用整理入库);API Key 的 move 需 `authorizeMove` |
| organize | 按 `(mediaEntityId, contentUnitId)` 分组,每组一次 `planImport`,与 `POST /organization-records` 同实现;执行需 `planIds`(按请求匹配各组);单组失败只影响该组(`groups[].error`);成功组入队 `verifyAndImport`,返回 `recordIds` | | organize | 按 `(mediaEntityId, contentUnitId, operation, targetDir, slotId)` 分组,每组一次 `planImport`,与 `POST /organization-records` 同实现;执行需 `planIds`(按请求匹配各组);单组失败只影响该组(`groups[].error`);成功组入队 `verifyAndImport`,返回 `recordIds`。**逐项覆写生效**:`season`/`episodes`(连续区间)→ `planImport.fileOverrides`,决定季目录、文件命名与集单元覆盖;`contentUnitId`(集/分段单元)→ 额外覆盖;`targetDir`(writable 库根下已存在目录)→ 文件按模板命名直接放进去,不用也不建路径绑定;`operation` → 该项的 copy/move;`slotId` → 核验通过后以新资产满足该 Slot(获取中的 Slot 不动)。`groups[].warnings` 只列无法应用的覆写(电影的季/集、非连续集号、目录项指定集号、不属于该作品的单元/Slot、获取中的 Slot) |
| delete | 默认回收站:`<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(同卷 rename);根不可写/跨卷 → `DATA_DIR/trash/...` 复制(上限 `FILES_TRASH_CROSS_MAX_BYTES`,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 `retireAssets`(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 `unbind`(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务 | | delete | 默认回收站:`<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(同卷 rename);根不可写/跨卷 → `DATA_DIR/trash/...` 复制(上限 `FILES_TRASH_CROSS_MAX_BYTES`,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 `retireAssets`(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 `unbind`(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务。执行写 `organization_record(action=trash|delete)`,文件级结果(目录展开到 2000 个文件,超过只记目录一行),返回 `recordId` |
| 永久删除 | `FILES_ALLOW_PERMANENT_DELETE=true` 才可用,且仅管理员会话;单项需 `confirmName` = 文件名 | | 永久删除 | `FILES_ALLOW_PERMANENT_DELETE=true` 才可用,且仅管理员会话;单项需 `confirmName` = 文件名 |
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记);`purge` 仅管理员会话,缺省只清过期项。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 | | 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 `organization_record(action=restore)`,返回 `recordId`);`purge` 仅管理员会话,缺省只清过期项。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 | | identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 |
| probe | ≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) | | probe | ≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo/images` 目前返回 `not_implemented` | | scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo/images` 目前返回 `not_implemented` |
| download | `GET /files/download?root=&path=&inline=`:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ `FILES_DOWNLOAD_MAX_BYTES`(默认 20 MiB,超出 413 `too_large:`);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),`inline=true` 仅对图片生效(SVG 一律附件),带 `nosniff` + `CSP sandbox`;写审计 `files.download` |
## 授权矩阵(实现) ## 授权矩阵(实现)
| 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key | | 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key |
|---|---|---| |---|---|---|
| roots / list / stat / identify / probe / trash 列表 | ✓ | `organize.read` | | roots / list / stat / identify / probe / trash 列表 / records | ✓ | `organize.read` |
| download | ✓(仅会话) | ✗ 403 |
| mkdir / rename / copy / organize(copy) / restore / scrape | ✓ | `organize.execute` | | mkdir / rename / copy / organize(copy) / restore / scrape | ✓ | `organize.execute` |
| move / organize(move) | ✓ | `organize.execute` + 持续授权或一次性审批 | | move / organize(move) | ✓ | `organize.execute` + 持续授权或一次性审批 |
| delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) | | delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) |
@@ -47,11 +50,14 @@
## 环境变量 ## 环境变量
`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)。 `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)、`FILES_TRANSFER_WAIT_SEC`(20,移动/复制请求内等待执行完成的秒数)、`FILES_DOWNLOAD_MAX_BYTES`(20971520)。
## 已知缺口(W5) ## 错误兜底
- `GET /files/download`(小文件下载/图片预览)未实现。 处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)不再 500:离线类 errno(EIO/ENOTCONN/ESTALE/ETIMEDOUT/EHOSTDOWN/ENODEV)→ 503 `root_unavailable`,ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400(`fsErrorToFiles`)。
- organize 的逐项 `season/episodes/slotId` 覆写暂不生效(返回 `groups[].warnings`),需 `planImport` 支持 `fileOverrides`;移动/复制未走 `planImport` 的"原样搬运"变体(`targetDir/keepNames`),而是用同一批 adapters 搬运原语 + 本模块冻结计划。
- 删除不写 `organization_record`(`OrganizationAction` 无 `delete`),只写审计;跨卷目录移动、NFO/图片投影、硬链接/空目录清理均为 W5。 ## 已知缺口
- NFO/图片投影(scrape 的 `writeNfo/images`)、硬链接、蓝光原盘整目录操作仍未做。
- 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。 - 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。
+16
View File
@@ -1177,6 +1177,22 @@ export const Api = {
body: files.FilesProbeRequest, body: files.FilesProbeRequest,
response: files.FilesProbeResult, response: files.FilesProbeResult,
}), }),
download: ep({
method: "get",
path: "/files/download",
scope: "admin",
summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;服务端另要求 admin 控制台会话;审计)",
query: files.FilesDownloadQuery,
response: files.FilesDownloadResponse,
}),
records: ep({
method: "get",
path: "/files/records",
scope: "organize.read",
summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)",
query: files.FilesRecordsQuery,
response: files.FilesRecordsResult,
}),
}, },
// ── 能力 ── // ── 能力 ──
+66 -1
View File
@@ -3,6 +3,7 @@ import { Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { MediaKind } from "./content.ts"; import { MediaKind } from "./content.ts";
import { DownloadStatus } from "./execution.ts"; import { DownloadStatus } from "./execution.ts";
import { InstanceHealth, LibraryMediaKind } from "./instances.ts"; import { InstanceHealth, LibraryMediaKind } from "./instances.ts";
import { OrganizationStatus, PublicOrganizationRecord } from "./organization.ts";
import { SlotStatus } from "./policy.ts"; import { SlotStatus } from "./policy.ts";
// 文件管理(`/files`,docs/plan/06-file-manager-spec.md)。 // 文件管理(`/files`,docs/plan/06-file-manager-spec.md)。
@@ -285,6 +286,10 @@ export const FilePlanPair = z.object({
conflict: z.boolean(), conflict: z.boolean(),
/** 已登记资产(执行后同步 asset_location) */ /** 已登记资产(执行后同步 asset_location) */
registered: z.boolean(), registered: z.boolean(),
/** 该项包含的文件数(目录递归;文件为 1)。W5-A2 */
files: NonNegInt.optional(),
/** 与目标不在同一设备:move 逐文件 Copy→核验→删源(需 settings.organize.allowCrossVolumeMove)。W5-A2 */
crossDevice: z.boolean().optional(),
}); });
export type FilePlanPair = z.infer<typeof FilePlanPair>; export type FilePlanPair = z.infer<typeof FilePlanPair>;
@@ -310,11 +315,20 @@ export const FilesTransferResult = z.object({
/** 执行结果(dryRun=false) */ /** 执行结果(dryRun=false) */
executed: z executed: z
.object({ .object({
/** 全部文件已完成的项(源相对路径);pending=true 时为空 */
succeeded: z.array(z.string()), succeeded: z.array(z.string()),
failed: z.array(FileItemFailure), failed: z.array(FileItemFailure),
recordId: Id.nullable(), recordId: Id.nullable(),
/** W5-A2:搬运走 planImport → verifyAndImport 任务(逐文件可恢复);任务 id 供任务页追踪 */
taskId: Id.nullable().optional(),
/** 整理记录状态(等待窗口内的最新值) */
status: OrganizationStatus.optional(),
/** 等待窗口(FILES_TRANSFER_WAIT_SEC)内未完成:后台继续执行,按 recordId 查整理记录或等 SSE files.changed */
pending: z.boolean().optional(),
}) })
.nullable(), .nullable(),
/** W5-A2:预览提示(跨卷 Move 未开启、目标已存在的文件等) */
warnings: z.array(z.string()).optional(),
}); });
export type FilesTransferResult = z.infer<typeof FilesTransferResult>; export type FilesTransferResult = z.infer<typeof FilesTransferResult>;
@@ -326,6 +340,10 @@ export const FilesOrganizeItem = FileRef.extend({
season: z.number().int().min(0).max(9999).optional(), season: z.number().int().min(0).max(9999).optional(),
episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(), episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(),
slotId: Id.optional(), slotId: Id.optional(),
/** W5-A2:逐项操作方式(缺省取请求的 action);不同操作分到不同组 */
operation: FilesTransferMode.optional(),
/** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */
targetDir: FileRef.optional(),
}); });
export type FilesOrganizeItem = z.infer<typeof FilesOrganizeItem>; export type FilesOrganizeItem = z.infer<typeof FilesOrganizeItem>;
@@ -350,8 +368,14 @@ export const FilesOrganizeGroup = z.object({
truncated: z.boolean(), truncated: z.boolean(),
recordId: Id.nullable(), recordId: Id.nullable(),
taskId: Id.nullable(), taskId: Id.nullable(),
/** 暂未生效的逐项覆写(season/episodes/slotId 等待 planImport 扩 fileOverrides) */ /** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 Slot/单元);能应用的覆写已反映在 files 里 */
warnings: z.array(z.string()), warnings: z.array(z.string()),
/** W5-A2:该组的操作方式(逐项 operation 覆写后) */
action: FilesTransferMode.optional(),
/** W5-A2:该组的目标目录覆写(本机绝对路径) */
targetDir: z.string().nullable().optional(),
/** W5-A2:该组核验通过后推进的 Slot */
slotId: Id.nullable().optional(),
/** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */ /** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */
error: z.object({ code: z.string(), message: z.string() }).nullable(), error: z.object({ code: z.string(), message: z.string() }).nullable(),
}); });
@@ -469,6 +493,8 @@ export type FilesTrashRestoreRequest = z.infer<typeof FilesTrashRestoreRequest>;
export const FilesTrashRestoreResult = z.object({ export const FilesTrashRestoreResult = z.object({
restored: z.array(z.object({ id: Id, root: FileRootId, path: z.string() })), restored: z.array(z.object({ id: Id, root: FileRootId, path: z.string() })),
failed: z.array(z.object({ id: Id, code: z.string(), message: z.string() })), failed: z.array(z.object({ id: Id, code: z.string(), message: z.string() })),
/** W5-A2:还原写的整理记录(action=restore);无成功项为 null */
recordId: Id.nullable().optional(),
}); });
export type FilesTrashRestoreResult = z.infer<typeof FilesTrashRestoreResult>; export type FilesTrashRestoreResult = z.infer<typeof FilesTrashRestoreResult>;
@@ -583,6 +609,45 @@ export type FileProbeItem = z.infer<typeof FileProbeItem>;
export const FilesProbeResult = z.object({ items: z.array(FileProbeItem) }); export const FilesProbeResult = z.object({ items: z.array(FileProbeItem) });
export type FilesProbeResult = z.infer<typeof FilesProbeResult>; export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
// 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话;
// 仅 nfo/字幕/图片且 ≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB);inline=true 仅对图片生效(预览)。
export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) });
export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
/** OpenAPI 描述用:实际响应为二进制流 */
export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)");
// 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId
const csvIds = z
.string()
.max(8000)
.transform((s, ctx) => {
const parts = s
.split(",")
.map((x) => x.trim())
.filter(Boolean);
for (const p of parts)
if (!Id.safeParse(p).success) {
ctx.addIssue({ code: "custom", message: `非法 id: ${p}` });
return z.NEVER;
}
return parts.slice(0, 200);
});
export const FilesRecordsQuery = z.object({
/** 逗号分隔的整理记录 id */
ids: csvIds.optional(),
/** 逗号分隔的 planId(执行后映射到其整理记录) */
planIds: csvIds.optional(),
includeFiles: boolQuery.default(false),
});
export type FilesRecordsQuery = z.infer<typeof FilesRecordsQuery>;
export const FilesRecordsResult = z.object({
items: z.array(PublicOrganizationRecord),
/** planId → recordId(未执行或已过期的 planId 不在其中) */
planRecords: z.record(z.string(), z.string()),
});
export type FilesRecordsResult = z.infer<typeof FilesRecordsResult>;
/** SSE `files.changed` 负载 */ /** SSE `files.changed` 负载 */
export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) }); export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) });
export type FilesChangedEvent = z.infer<typeof FilesChangedEvent>; export type FilesChangedEvent = z.infer<typeof FilesChangedEvent>;
+14 -2
View File
@@ -14,8 +14,20 @@ export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>; export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]); export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>; export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */ /**
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]); * Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
*/
export const OrganizationAction = z.enum([
"copy",
"move",
"rename",
"relocate",
"hardlink",
"trash",
"delete",
"restore",
]);
export type OrganizationAction = z.infer<typeof OrganizationAction>; export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */ /** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]); export const SourcePathView = z.enum(["downloader", "local", "library"]);
@@ -0,0 +1,30 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// W5-A2 文件管理:删除 / 回收站 / 还原也写整理记录(organization_record + 逐文件结果),整理记录页可追溯。
// action / mode 取值新增 trash(移入回收站)、delete(永久删除)、restore(从回收站还原)。
const OLD = ["copy", "move", "rename", "relocate", "hardlink"] as const;
const NEW = [...OLD, "trash", "delete", "restore"] as const;
const swap = (values: readonly string[]) => `
alter table organization_record drop constraint organization_record_action_check;
alter table organization_record add constraint organization_record_action_check ${oneOf("action", values)};
alter table organization_file_result drop constraint organization_file_result_mode_check;
alter table organization_file_result add constraint organization_file_result_mode_check ${oneOf("mode", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, swap(NEW));
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`delete from organization_file_result where mode in ('trash', 'delete', 'restore');
delete from organization_record where action in ('trash', 'delete', 'restore');
${swap(OLD)}`,
);
}
+18 -2
View File
@@ -154,12 +154,28 @@ export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded"
export type MetadataFieldSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import"; export type MetadataFieldSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import";
export type ObservationStatus = "new" | "linked" | "ignored" | "error"; export type ObservationStatus = "new" | "linked" | "ignored" | "error";
export type OrganizationFileResultRole = "main" | "subtitle" | "audio" | "bluray_dir" | "nfo" | "image" | "other"; export type OrganizationFileResultRole = "main" | "subtitle" | "audio" | "bluray_dir" | "nfo" | "image" | "other";
export type OrganizationFileResultMode = "copy" | "move" | "rename" | "relocate" | "hardlink"; export type OrganizationFileResultMode =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled"; export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
export type OrganizationRecordTrigger = "auto" | "manual" | "agent"; export type OrganizationRecordTrigger = "auto" | "manual" | "agent";
export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key"; export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key";
export type OrganizationRecordSourcePathView = "downloader" | "local" | "library"; export type OrganizationRecordSourcePathView = "downloader" | "local" | "library";
export type OrganizationRecordAction = "copy" | "move" | "rename" | "relocate" | "hardlink"; export type OrganizationRecordAction =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled"; export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled";
export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead"; export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead";
export type PathBindingBindingKind = "entity" | "season"; export type PathBindingBindingKind = "entity" | "season";
+2
View File
@@ -2,11 +2,13 @@ export {
type ExcludedFile, type ExcludedFile,
type ExistingPolicy, type ExistingPolicy,
type ExistingTarget, type ExistingTarget,
type FileOverride,
type OrganizeConflict, type OrganizeConflict,
type OrganizeFileKind, type OrganizeFileKind,
type OrganizeInput, type OrganizeInput,
type OrganizeOperation, type OrganizeOperation,
type OrganizePlan, type OrganizePlan,
overrideFor,
type PlannedAction, type PlannedAction,
type PlannedFile, type PlannedFile,
type Precondition, type Precondition,
+32
View File
@@ -54,6 +54,32 @@ export interface OrganizeInput {
parse?: ParseOptions; parse?: ParseOptions;
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */ /** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
defaultSubtitleLang?: DefaultSubtitleLang; defaultSubtitleLang?: DefaultSubtitleLang;
/**
* 逐文件季/集覆写(人工确认,优先于文件名解析):键为源文件路径或其所在目录(最长前缀生效)。
* 只影响剧集的季目录选择与命名;电影忽略。
*/
overrides?: Readonly<Record<string, FileOverride>>;
}
export interface FileOverride {
season?: number;
episodes?: { start: number; end: number };
}
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
export function overrideFor<T extends FileOverride>(
overrides: Readonly<Record<string, T>> | undefined,
path: string,
): T | undefined {
if (!overrides) return undefined;
const exact = overrides[path];
if (exact) return exact;
let best: { len: number; o: T } | undefined;
for (const [k, o] of Object.entries(overrides)) {
const dir = k.replace(/\/+$/, "");
if (path.startsWith(`${dir}/`) && (!best || dir.length > best.len)) best = { len: dir.length, o };
}
return best?.o;
} }
export type PlannedAction = "transfer" | "replace" | "skip" | "hold"; export type PlannedAction = "transfer" | "replace" | "skip" | "hold";
@@ -195,6 +221,12 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
continue; continue;
} }
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {}); const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
if (ov?.season !== undefined) parsed.season = ov.season;
if (ov?.episodes) {
parsed.episodes = ov.episodes;
delete parsed.airDate;
}
if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) { if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) {
excluded.push({ src: f.path, kind, reason: "extra_content" }); excluded.push({ src: f.path, kind, reason: "extra_content" });
continue; continue;
+38
View File
@@ -270,6 +270,44 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", ()
expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true); expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true);
}); });
it("逐文件季/集覆写:优先于文件名解析,目录前缀对其下文件生效,电影忽略", () => {
const D = "/dl/Weird.Name";
const plan = planOrganize({
operation: "copy",
mediaType: "tv",
sourceRoot: D,
files: [
{ path: `${D}/weird-a.mkv`, size: GiB },
{ path: `${D}/weird-a.chs.srt`, size: 1000 },
{ path: `${D}/Show.S01E05.mkv`, size: GiB },
{ path: `${D}/sub/Show.S01E07.mkv`, size: GiB },
],
entity: { title: "剧", year: 2020 },
target: { seasonDirs: { 1: "/lib/剧/Season 1", 2: "/lib/剧/Season 2" } },
overrides: {
[`${D}/weird-a.mkv`]: { season: 2, episodes: { start: 3, end: 4 } },
[`${D}/Show.S01E05.mkv`]: { episodes: { start: 6, end: 6 } },
[`${D}/sub`]: { season: 2 },
},
});
expect(plan.files.map((f) => f.dst)).toEqual([
"/lib/剧/Season 2/剧 - S02E03-E04.mkv",
"/lib/剧/Season 1/剧 - S01E06.mkv",
"/lib/剧/Season 2/剧 - S02E07.mkv",
"/lib/剧/Season 2/剧 - S02E03-E04.chi.zh-cn.srt",
]);
expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } });
const movie = planOrganize({
operation: "copy",
mediaType: "movie",
files: [{ path: "/dl/M.2020.mkv", size: GiB }],
entity: { title: "M", year: 2020 },
target: { movieDir: "/lib/M (2020)" },
overrides: { "/dl/M.2020.mkv": { season: 3 } },
});
expect(movie.files[0]!.dst).toBe("/lib/M (2020)/M (2020).mkv");
});
it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => { it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => {
const sib = [ const sib = [
"/lib/S01/Show - S01E01 - 1080p.mkv", "/lib/S01/Show - S01E01 - 1080p.mkv",
+4
View File
@@ -29,6 +29,10 @@ const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
// domain 不生成目录调整/硬链接计划 // domain 不生成目录调整/硬链接计划
relocate: null, relocate: null,
hardlink: null, hardlink: null,
// 文件管理的删除/还原记录不对应整理计划
trash: null,
delete: null,
restore: null,
}; };
export const DOMAIN_ORGANIZE_OPERATIONS = Object.keys(OPERATION) as OrganizeOperation[]; export const DOMAIN_ORGANIZE_OPERATIONS = Object.keys(OPERATION) as OrganizeOperation[];