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:
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,
|
||||
type NewBinding,
|
||||
type OrganizePlan,
|
||||
overrideFor,
|
||||
type ParsedRelease,
|
||||
parseReleasePath,
|
||||
planOrganize,
|
||||
@@ -42,6 +43,7 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
||||
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
||||
import { applySlotEvent } from "./downloads.ts";
|
||||
import { PathGuardError } from "./organize-guard.ts";
|
||||
import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts";
|
||||
import { hnrSatisfied } from "./retention.ts";
|
||||
|
||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||
@@ -67,6 +69,24 @@ export interface ImportRequest {
|
||||
onExisting?: ExistingPolicy | 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 的冻结内容 */
|
||||
@@ -84,9 +104,20 @@ export interface FrozenPlan {
|
||||
/** 主视频 src → 解析出的质量特征与单元键 */
|
||||
facts: Record<
|
||||
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;
|
||||
/** W5-A2:手动整理指定的 Slot */
|
||||
slotId?: string;
|
||||
/** W5-A2:原样搬运计划(finalize 走 finalizeTransfer) */
|
||||
transfer?: FrozenTransfer;
|
||||
}
|
||||
|
||||
export interface PlanResult {
|
||||
@@ -171,6 +202,7 @@ async function chooseRoot(
|
||||
|
||||
/** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */
|
||||
export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanResult> {
|
||||
if (req.transfer) return planTransfer(d, req, req.transfer);
|
||||
const db = d.ctx.db;
|
||||
let dt: DownloadTaskRow | undefined;
|
||||
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 root = await chooseRoot(db, entity, bindings, req.targetLibraryRootId);
|
||||
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 health = await checkRoot(root.local_path, sentinel);
|
||||
if (health.kind === "offline") {
|
||||
@@ -361,6 +408,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
for (const f of files) {
|
||||
const parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot));
|
||||
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);
|
||||
if (mediaType === "tv" && season !== undefined) seasons.add(season);
|
||||
facts[f.path] = {
|
||||
@@ -368,6 +418,7 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
||||
unitKeys: unitKeysOf({ ...parsed, ...(season !== undefined ? { season } : {}) }),
|
||||
...(season !== undefined ? { season } : {}),
|
||||
...(parsed.episodes ? { episodes: parsed.episodes } : {}),
|
||||
...(ov?.contentUnitIds?.length ? { unitIds: ov.contentUnitIds } : {}),
|
||||
};
|
||||
}
|
||||
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 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());
|
||||
if (typeof r !== "string") return 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 } : {}) },
|
||||
retention: { hnrSatisfied: hnr },
|
||||
minVideoBytes: d.ports.options.minVideoBytes,
|
||||
...(req.fileOverrides ? { overrides: req.fileOverrides } : {}),
|
||||
...(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,
|
||||
facts,
|
||||
...(downgradedFromMove ? { downgradedFromMove } : {}),
|
||||
...(req.slotId ? { slotId: req.slotId } : {}),
|
||||
};
|
||||
const drafts = organizePlanToFileResults(plan, { targetLibraryRootId: root.id });
|
||||
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)
|
||||
.executeTakeFirstOrThrow();
|
||||
const frozenPre = pre.plan as unknown as FrozenPlan;
|
||||
// 原样搬运(文件管理 移动/复制):不登记新资产,已登记位置/绑定跟随
|
||||
if (frozenPre.transfer) return finalizeTransfer(d, recordId);
|
||||
// ffprobe 在事务外做(可能较慢);未知不阻塞
|
||||
const probes = new Map<string, unknown>();
|
||||
const filesPre = await db
|
||||
@@ -1327,6 +1386,19 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise<Execu
|
||||
.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 });
|
||||
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);
|
||||
return { recordId, status, succeeded: counts.file_succeeded, failed: counts.file_failed, move, dt: dt ?? null };
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user