refactor: 拆分大文件(contracts 端点、整理服务、浏览器纯函数、整理弹窗来源)

- contracts/src/api.ts 2733 行 → api/*.ts 13 个领域文件 + shared.ts;api.ts 按原顺序合并 Api。
  新旧对比:354 个端点内容与顺序一致,分组键与对外导出一致
- acquisition-organize/services/organize.ts 2645 行 → organize-types / -plan / -exec / -finalize / -records,
  organize.ts 保留为汇总入口,外部引用不变
- browser/service.ts 类外的纯函数(视图映射、原因文案、坐标换算、图片尺寸、出口代理)移到 session-view.ts,
  service.ts 照旧转出
- organize-wizard:来源解析 useResolvedSource 移到 source.ts,FormSelect 移到 form-select.tsx

只挪位置不改逻辑;typecheck、lint、test:all、web build、server 编译与编译产物自检通过。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 17:40:43 +08:00
1 parent c9e8120fc6
commit 8bec4bad5d
26 files changed
+5906 -5696

No files matched your search

@@ -0,0 +1,709 @@
import { randomUUID } from "node:crypto";
import { mkdir, realpath, rmdir } from "node:fs/promises";
import { basename, dirname, join, relative } from "node:path";
import {
type CopyReceipt,
type CrossMoveStepLog,
FsError,
FsProxy,
fsErrorClass,
type ReplaceOldPolicy,
type StatResult,
} from "@umt/adapters";
import type { OrganizationRecord } from "@umt/contracts";
import { sql } from "@umt/db";
import { withAttemptToken } from "@umt/tasks";
import { errMessage, jsonb } from "../util.ts";
import { type AoDeps, upsertDecision } from "./common.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
import { finalizeRecord } from "./organize-finalize.ts";
import type { FrozenPlan } from "./organize-types.ts";
import { cleanupReplacement, settleSamePathReplacement, stagedReplacementBackup } from "./replacement.ts";
import { scrapeAfterImport } from "./scrape.ts";
// ── 执行 ──
export type StepLog = {
step: string;
status: string;
at: string;
detail?: string;
error?: { code: string; message: string };
};
export type FileOutcome = {
status: "succeeded" | "failed" | "attention";
bytes?: number;
checksum?: string;
steps?: StepLog[];
error?: { code: string; message: string };
resumed?: boolean;
/** 全量 sha256 的执行方式(sha256sum 子进程 / 进程内流式) */
hashVia?: string;
durationMs?: number;
/** 本次执行是第几次尝试(账本 attempts) */
attempt?: number;
/** 失败时的附加证据(如对账阶段比对失败) */
evidence?: { phase: string; src: string; dst: string };
/** replace:旧文件去向 */
replaced?: {
disposition: string;
size: number;
mtimeMs: number;
path?: string;
error?: string;
};
/** 写前清走的残留 partial */
sweptPartials?: string[];
};
export const stepsOf = (s: CrossMoveStepLog[]): StepLog[] => s.map((x) => ({ ...x }));
export interface ExecuteResult {
recordId: string;
status: OrganizationRecord["status"];
succeeded: number;
failed: number;
}
export async function executeRecord(
d: AoDeps,
recordId: string,
opts: { runId?: string; abortSignal?: AbortSignal } = {},
): Promise<ExecuteResult> {
const db = d.ctx.db;
const rec = await db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
if (rec.status !== "running" && rec.status !== "queued") {
await settleSamePathReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
await cleanupReplacement(d, rec.plan as unknown as FrozenPlan, recordId);
const frozen = rec.plan as unknown as FrozenPlan;
if (
(frozen.metadataMode === "local" || frozen.scrapeAfterImport !== undefined) &&
["succeeded", "partial"].includes(rec.status)
) {
await scrapeAfterImport(d, frozen.entityId, recordId, {
force: frozen.metadataMode === "local",
enabled: frozen.scrapeAfterImport,
});
}
return {
recordId,
status: rec.status,
succeeded: rec.file_succeeded,
failed: rec.file_failed,
};
}
const frozen = rec.plan as unknown as FrozenPlan;
const fsr = fsRuntime(d);
// 根检查也走执行子进程:NFS/FUSE 挂死 → offline(timeout),按根离线重试,绝不当成文件缺失
const health = await fsr.proxy.checkRoot(frozen.rootPath, frozen.sentinel);
if (health.kind === "offline") {
throw new Error(`媒体库目录离线(${health.reason}),稍后重试`);
}
if (rec.action !== "in_place") for (const dir of frozen.targetDirs) await mkdir(dir, { recursive: true });
// 写前顺带清本目录残留 partial(对照 MP:只清本目录、>24h;失败吞掉)。copy 路径在 copyWithVerify 内还会再清一次
if (rec.action !== "in_place" && fsr.partialMaxAgeMs > 0) {
for (const dir of frozen.targetDirs) {
await fsr.proxy.sweepStalePartials(dir, fsr.partialMaxAgeMs).catch(() => [] as string[]);
}
}
const orgSettings = await d.ctx.settings.get("organize");
const files = await db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.orderBy("ordinal")
.execute();
const planned = new Map(frozen.organize.files.map((f) => [f.src, f]));
const failedMains = new Set<string>();
/** 本轮 TIMEOUT/IO 类失败、留待任务重试的文件(结果不入账本,文件结果保持 running) */
const deferred: {
src: string;
code: string;
message: string;
attempt: number;
}[] = [];
for (const fr of files) {
if (fr.status !== "queued" && fr.status !== "running") continue;
const pf = planned.get(fr.source_path);
if (!pf) continue;
// 主文件等重试:随行文件本轮也不动
if (pf.role === "sidecar" && pf.mainSrc && deferred.some((x) => x.src === pf.mainSrc)) continue;
if (pf.role === "sidecar" && pf.mainSrc && failedMains.has(pf.mainSrc)) {
await db
.updateTable("organization_file_result")
.set({
status: "skipped",
skip_reason: "main_failed",
finished_at: d.ports.now(),
})
.where("id", "=", fr.id)
.execute();
continue;
}
opts.abortSignal?.throwIfAborted();
const opId = `org:${recordId}:${fr.ordinal}`;
const kind = rec.action === "in_place" ? "register" : rec.action === "move" ? "move" : "copy";
await db
.insertInto("asset_operation")
.values({
operation_id: opId,
task_id: rec.task_id,
organization_record_id: recordId,
plan_fingerprint: rec.plan_fingerprint,
ordinal: fr.ordinal,
phase: kind === "register" ? "verify" : "transfer",
kind,
status: "started",
intent: jsonb({
src: pf.src,
dst: pf.dst,
size: pf.size,
crossDevice: pf.crossDevice ?? null,
}),
download_task_id: rec.download_task_id,
started_at: d.ports.now(),
})
.onConflict((oc) =>
oc.column("operation_id").doUpdateSet({
attempts: sql`asset_operation.attempts + 1`,
status: "started",
}),
)
.execute();
await db
.updateTable("organization_file_result")
.set({ status: "running", started_at: fr.started_at ?? d.ports.now() })
.where("id", "=", fr.id)
.execute();
let outcome: FileOutcome;
try {
outcome = await withAttemptToken<FileOutcome>(
db,
opId,
async (info) => {
const t0 = performance.now();
const selectedOld =
pf.action === "replace"
? frozen.replacement?.assets.flatMap((a) => a.files).find((f) => join(f.rootPath, f.path) === pf.dst)
: undefined;
const stagedOld = selectedOld ? await stagedReplacementBackup(d, selectedOld) : null;
if (selectedOld && !stagedOld) {
const current = await fsr.proxy.statSafe(pf.dst, {
root: selectedOld.rootPath,
sentinel: selectedOld.sentinel,
});
if (
current.kind !== "ok" ||
current.facts.isSymbolicLink ||
Object.entries(selectedOld.facts).some(
([key, value]) => current.facts[key as keyof typeof selectedOld.facts] !== value,
)
) {
return {
status: "failed",
error: {
code: "STALE_REPLACEMENT",
message: "选定旧文件在计划后发生变化,保留待核对",
},
durationMs: 0,
};
}
}
const r: FileOutcome =
kind === "register"
? await verifyInPlace(d, fsr, frozen, pf.src, pf.size)
: await transferFile(d, fsr, {
kind,
src: pf.src,
dst: pf.dst,
size: pf.size,
crossDevice: pf.crossDevice,
resumed: info.resumed,
allowCrossVolume: orgSettings.allowCrossVolumeMove,
// 计划冻结的目标存在策略:replace = replace_with_verify 已获授权;其余目标此刻若已存在一律冲突
onExisting: pf.action === "replace" ? "replace" : "conflict",
replaceOld: {
mode: frozen.replacement ? "trash" : fsr.replaceOld,
trashDir: selectedOld
? join(selectedOld.rootPath, ".umt-trash", "replacement-staging", selectedOld.trashId)
: join(frozen.rootPath, ".umt-trash"),
},
forceFullVerify: !!frozen.replacement && pf.action === "replace",
...(opts.abortSignal ? { signal: opts.abortSignal } : {}),
});
if (r.status === "succeeded" && stagedOld && selectedOld && !r.replaced) {
r.replaced = {
disposition: "trash",
path: stagedOld,
size: selectedOld.facts.size,
mtimeMs: selectedOld.facts.mtimeMs,
};
}
r.durationMs = Math.round(performance.now() - t0);
r.attempt = info.attempt;
// TIMEOUT/IO 类:未到上限 → 不入账本、抛出等任务重试;到上限 → 按失败入账并建待处理
if (
r.status === "failed" &&
r.error &&
fsErrorClass(r.error.code) === "retry" &&
info.attempt < fsr.maxAttempts
) {
throw new RetryableTransfer(r.error, info.attempt);
}
// 打点:每个文件的耗时/字节/哈希方式,便于判断大文件入库是否拖慢 worker
d.ctx.logger.info(
{
recordId,
ordinal: fr.ordinal,
kind,
status: r.status,
bytes: r.bytes ?? pf.size,
durationMs: r.durationMs,
mbps: r.durationMs > 0 ? Math.round(((r.bytes ?? pf.size) / 1e6 / r.durationMs) * 1000) : null,
hashVia: r.hashVia ?? null,
resumed: info.resumed,
// 执行方式:subprocess(workerKind script=deno run / self=编译二进制 --role fsworker)或 inprocess 回退
fsproxy: fsr.proxy.stats(),
},
"organize.transfer",
);
return r;
},
{
...(opts.runId ? { runId: opts.runId } : {}),
commit: async (trx, r) => {
const now = d.ports.now();
const ok = r.status !== "failed";
await trx
.updateTable("asset_operation")
.set({
status: ok ? "succeeded" : "failed",
result: jsonb(r),
...(r.error ? { error: jsonb(r.error) } : {}),
finished_at: now,
})
.where("operation_id", "=", opId)
.execute();
const op = await trx
.selectFrom("asset_operation")
.select("id")
.where("operation_id", "=", opId)
.executeTakeFirstOrThrow();
await trx
.updateTable("organization_file_result")
.set({
status: ok ? "succeeded" : "failed",
asset_operation_id: op.id,
checksum: r.checksum ?? null,
error: r.error
? jsonb(r.error)
: r.status === "attention"
? jsonb({
code: "source_not_removed",
message: "目标已就绪,源文件未能删除",
})
: null,
finished_at: now,
})
.where("id", "=", fr.id)
.execute();
},
},
);
} catch (e) {
if (!(e instanceof RetryableTransfer)) throw e;
deferred.push({
src: pf.src,
code: e.error.code,
message: e.error.message,
attempt: e.attempt,
});
await db
.updateTable("asset_operation")
.set({
error: jsonb({ ...e.error, attempt: e.attempt, retrying: true }),
})
.where("operation_id", "=", opId)
.execute();
continue;
}
if (outcome.status === "failed" && pf.role === "main") {
failedMains.add(pf.src);
}
if (outcome.status === "failed" && outcome.error && fsErrorClass(outcome.error.code) === "retry") {
await upsertDecision(d, db, {
kind: "other",
title: outcome.error.code === "TIMEOUT" ? "文件操作挂死,已达重试上限" : "文件读写失败,已达重试上限",
summary: `${pf.src} → ${pf.dst}:${outcome.error.message}(已尝试 ${
outcome.attempt ?? fsr.maxAttempts
} 次;记录与下载已保留,不会重新下载)`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `fs_retry_exhausted:${opId}`,
evidence: {
src: pf.src,
dst: pf.dst,
error: outcome.error,
attempts: outcome.attempt ?? null,
},
options: [{ key: "retry", label: "检查挂载后重试" }],
});
}
// 被替换的旧文件登记进文件管理回收站(可在回收站视图还原/清除);登记失败只告警,旧文件留在 .umt-trash
if (outcome.replaced?.disposition === "trash" && outcome.replaced.path) {
await trashReplacedOld(d, fsr, frozen, pf.dst, { recordId, opId }, outcome.replaced).catch((err) =>
d.ctx.logger.warn({ err, recordId, dst: pf.dst }, "替换旧文件登记回收站失败"),
);
}
if (outcome.replaced?.disposition === "left") {
await upsertDecision(d, db, {
kind: "other",
title: "新版本已入库,旧文件未能处置",
summary: `${pf.dst} 已替换为新版本;旧文件留在 ${outcome.replaced.path}`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `replace_old_left:${opId}`,
evidence: { replaced: outcome.replaced },
});
}
if (outcome.status === "attention") {
await upsertDecision(d, db, {
kind: "other",
title: "跨卷移动后删除源文件失败",
summary: `${pf.src} 已复制到目标并校验,源文件未删除`,
subjectType: "organization_record",
subjectId: recordId,
dedupeKey: `move_source_left:${opId}`,
evidence: { steps: outcome.steps ?? [] },
});
}
}
if (deferred.length > 0) {
// 交给任务引擎退避重试(executeRecord 幂等:已完成文件直接跳过,账本无结果的文件重跑并先对账)
throw new Error(
`文件操作可重试失败,等待重试:${deferred.map((x) => `${x.code}@${x.src}(第 ${x.attempt} 次)`).join(";")}`,
);
}
return finalizeRecord(d, recordId);
}
/**
* replace 的旧文件(copy 已同卷移到 <根>/.umt-trash/replaced-*)改放成文件管理回收站的布局
* <根real>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径> 并写 trash_item(与 file-manager/trash.ts moveToTrash 同构,
* 还原/清除按 original_path 反推 uuid 目录,布局必须一致)。
*/
export async function trashReplacedOld(
d: AoDeps,
fsr: FsRuntime,
frozen: FrozenPlan,
dst: string,
ref: { recordId: string; opId: string },
old: { path?: string; size: number },
): Promise<void> {
const rootReal = await realpath(frozen.rootPath);
const rel = relative(rootReal, await realpath(dirname(dst)).then((p) => join(p, basename(dst))));
if (!rel || rel.startsWith("..") || !old.path) return;
const id = randomUUID();
const now = d.ports.now();
const to = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
await mkdir(dirname(to), { recursive: true });
const m = await fsr.proxy.moveSafe(old.path, to);
if (m.status !== "moved") {
throw new Error(`旧文件移入回收站失败(${m.status}): ${old.path}`);
}
await rmdir(dirname(old.path)).catch(() => undefined);
const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
await d.ctx.db
.insertInto("trash_item")
.values({
id,
root_id: `lib:${frozen.libraryRootId}`,
library_root_id: frozen.libraryRootId,
root_host_path: rootReal,
original_path: rel,
trash_path: to,
is_dir: false,
size_bytes: old.size,
actor: "system:organize",
deleted_at: now,
expires_at: new Date(now.getTime() + days * 86_400_000),
asset_snapshot: jsonb({
source: "organize_replace",
recordId: ref.recordId,
dst,
}),
})
.execute();
// 账本里的旧文件去向跟着改到回收站路径
await sql`update asset_operation
set result = jsonb_set(jsonb_set(result, '{replaced,path}', to_jsonb(${to}::text)),
'{replaced,trashItemId}', to_jsonb(${id}::text))
where operation_id = ${ref.opId} and result ? 'replaced'`.execute(d.ctx.db);
}
/** TIMEOUT/IO 类失败且未到尝试上限:从 withAttemptToken 内抛出,账本不落结果 */
export class RetryableTransfer extends Error {
constructor(
readonly error: { code: string; message: string },
readonly attempt: number,
) {
super(`${error.code}: ${error.message}`);
}
}
export interface FsRuntime {
proxy: FsProxy;
partialMaxAgeMs: number;
maxAttempts: number;
replaceOld: ReplaceOldPolicy["mode"];
}
export const fsRuntimes = new WeakMap<object, FsRuntime>();
/** 按 server 配置(FS_*)建一次 fsproxy,随 ctx.config 缓存 */
export function fsRuntime(d: AoDeps): FsRuntime {
const c = (d.ctx.config ?? {}) as Partial<import("../../../config.ts").Config>;
const hit = fsRuntimes.get(c);
if (hit) return hit;
const rt: FsRuntime = {
proxy: new FsProxy({
mode: c.FS_PROXY_MODE ?? "auto",
hangTimeoutMs: c.FS_HANG_TIMEOUT_MS ?? 120_000,
quickTimeoutMs: c.FS_QUICK_TIMEOUT_MS ?? 30_000,
progressIntervalMs: c.FS_PROGRESS_INTERVAL_MS ?? 1000,
killGraceMs: c.FS_KILL_GRACE_MS ?? 2000,
}),
partialMaxAgeMs: c.FS_PARTIAL_MAX_AGE_MS ?? 24 * 3600_000,
maxAttempts: c.FS_TRANSFER_MAX_ATTEMPTS ?? 3,
replaceOld: c.FS_REPLACE_OLD ?? "trash",
};
fsRuntimes.set(c, rt);
return rt;
}
/** statSafe 结果 → 失败码(失败 ≠ 不存在:只有 missing 才是 SOURCE_MISSING) */
export function statFailure(
st: StatResult,
path: string,
role: "源" | "目标",
): { code: string; message: string } | null {
switch (st.kind) {
case "ok":
return null;
case "missing":
return role === "源" ? { code: "SOURCE_MISSING", message: `源文件不存在:${path}` } : null;
case "permission_denied":
return { code: "EACCES", message: `${role}无权限访问:${path}` };
case "root_offline":
return {
code: "ROOT_OFFLINE",
message: `${role}所在根离线(${st.reason}): ${path}`,
};
case "error":
return {
code: st.code === "TIMEOUT" ? "TIMEOUT" : "IO",
message: `${role}状态读取失败(${st.code}): ${st.message}`,
};
}
}
export async function verifyInPlace(
d: AoDeps,
fsr: FsRuntime,
frozen: FrozenPlan,
path: string,
size: number,
): Promise<FileOutcome> {
const observed = await fsr.proxy.statSafe(path, { root: frozen.rootPath, sentinel: frozen.sentinel });
if (observed.kind !== "ok" || !observed.facts.isFile || observed.facts.isSymbolicLink) {
return { status: "failed", error: { code: "SOURCE_MISSING", message: "原地入库的文件不存在或不可访问" } };
}
const planned = frozen.sourceFacts?.[path];
if (observed.facts.size !== size || (planned && observed.facts.mtimeMs !== planned.mtimeMs)) {
return { status: "failed", error: { code: "STALE_SOURCE", message: "文件在计划后发生变化,请重新预览" } };
}
const location = await d.ctx.db
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select("a.media_entity_id")
.where("l.library_root_id", "=", frozen.libraryRootId)
.where("l.relative_path", "=", relative(frozen.rootPath, path))
.where("l.status", "!=", "removed")
.executeTakeFirst();
if (location && location.media_entity_id !== frozen.entityId) {
return {
status: "failed",
error: { code: "ASSET_ENTITY_CONFLICT", message: "该文件已登记为其它作品,请先核对作品归属" },
};
}
return { status: "succeeded", bytes: 0 };
}
export async function transferFile(
d: AoDeps,
fsr: FsRuntime,
x: {
kind: "copy" | "move";
src: string;
dst: string;
size: number;
crossDevice: boolean | undefined;
resumed: boolean;
allowCrossVolume: boolean;
onExisting: "conflict" | "replace";
replaceOld: ReplaceOldPolicy;
signal?: AbortSignal;
forceFullVerify?: boolean;
},
): Promise<FileOutcome> {
const { kind, src, dst, size, crossDevice, resumed } = x;
const px = fsr.proxy;
const call = x.signal ? { signal: x.signal } : {};
const fsErr = (e: unknown) => ({
code: e instanceof FsError ? e.code : "IO",
message: errMessage(e),
});
const copyReceipt = (r: CopyReceipt): FileOutcome => ({
status: "succeeded",
bytes: r.bytes,
...(r.sha256Via ? { hashVia: r.sha256Via } : {}),
...(r.sha256 ? { checksum: `sha256:${r.sha256}` } : r.sampleDigest ? { checksum: `sample:${r.sampleDigest}` } : {}),
...(r.replaced ? { replaced: r.replaced } : {}),
...(r.sweptPartials?.length ? { sweptPartials: r.sweptPartials } : {}),
});
const copyOpts = {
hash: x.forceFullVerify ? ("sha256" as const) : d.ports.options.verifyHash,
onExisting: x.onExisting,
replaceOld: x.replaceOld,
sweepPartialsOlderThanMs: fsr.partialMaxAgeMs > 0 ? fsr.partialMaxAgeMs : null,
};
try {
const [s, t] = await Promise.all([px.statSafe(src, {}, call), px.statSafe(dst, {}, call)]);
// 目标状态读不出(挂死/IO/无权限)≠ 目标不存在:不能据此往下写
const tFail = statFailure(t, dst, "目标");
if (tFail) return { status: "failed", error: tFail };
// 对账:上次 attempt 已完成外部动作但没留下结果
if (t.kind === "ok") {
if (s.kind === "missing" && kind === "move") {
return t.facts.size === size
? { status: "succeeded", bytes: size, resumed: true }
: {
status: "failed",
error: { code: "CONFLICT", message: "源已不存在且目标大小不符" },
};
}
if (s.kind === "ok") {
// 比对读失败/超时 → 按 IO/TIMEOUT 失败并留证据,不得冒充"内容不同"(EEXIST)
let same: { match: boolean; via: string | null };
try {
same = await px.filesMatchSha256(src, dst, call);
} catch (e) {
const err = fsErr(e);
return {
status: "failed",
error: { ...err, message: `对账比对失败:${err.message}` },
evidence: { phase: "reconcile", src, dst },
};
}
if (same.match) {
if (kind === "copy") {
return {
status: "succeeded",
bytes: size,
resumed,
...(same.via ? { hashVia: same.via } : {}),
};
}
if (crossDevice === true || resumed || x.onExisting === "replace") {
const r = await px.crossDeviceMove(src, dst, { hash: "sha256" }, call);
return {
status: r.status === "completed" ? "succeeded" : r.status,
bytes: size,
steps: stepsOf(r.steps),
resumed: true,
};
}
} else if (x.onExisting !== "replace") {
return {
status: "failed",
error: { code: "EEXIST", message: `目标已存在且内容不同:${dst}` },
};
}
}
}
const sFail = statFailure(s, src, "源");
if (sFail) return { status: "failed", error: sFail };
if (x.onExisting === "replace" && t.kind === "ok") {
// 替换:新文件写临时 → 校验 → 原子替换,旧文件按 FS_REPLACE_OLD 进回收目录/删除;move 再删源
const r = await px.copy(src, dst, copyOpts, call);
const out = copyReceipt(r);
if (kind === "move") {
const del = await px.run({ op: "unlink", path: src }, call).then(
() => null,
(e) => fsErr(e),
);
if (del) {
return {
...out,
status: "attention",
steps: [
{
step: "delete_source",
status: "failed",
at: new Date().toISOString(),
error: del,
},
],
};
}
}
return out;
}
if (kind === "copy") {
return copyReceipt(await px.copy(src, dst, copyOpts, call));
}
if (crossDevice !== true) {
const m = await px.moveSafe(src, dst, call);
if (m.status === "moved") return { status: "succeeded", bytes: size };
if (m.status === "exists") {
return {
status: "failed",
error: { code: "EEXIST", message: `目标已存在:${dst}` },
};
}
// EXDEV:不静默降级;按设置显式走可恢复三步
}
if (!x.allowCrossVolume) {
return {
status: "failed",
error: {
code: "EXDEV",
message: "未启用跨卷移动(settings.organize.allowCrossVolumeMove)",
},
};
}
const r = await px.crossDeviceMove(
src,
dst,
{
hash: d.ports.options.verifyHash,
},
call,
);
const failedStep = r.steps.find((st) => st.status === "failed");
return {
status: r.status === "completed" ? "succeeded" : r.status,
bytes: size,
steps: stepsOf(r.steps),
// 跨卷失败带上首个失败步骤的错误码(TIMEOUT/IO 走重试,EEXIST/VERIFY_FAILED 需人工)
...(r.status === "failed" && failedStep?.error ? { error: failedStep.error } : {}),
};
} catch (e) {
return { status: "failed", error: fsErr(e) };
}
}
@@ -0,0 +1,561 @@
import { readdir } from "node:fs/promises";
import { dirname, join, relative } from "node:path";
import { statSafe } from "@umt/adapters";
import type { DownloadTaskRow, OrganizationRecordRow } from "@umt/db";
import { sql } from "@umt/db";
import { findOtherVersions } from "@umt/domain";
import { organizationStatusFromFiles } from "@umt/mapping";
import { errMessage, jsonb } from "../util.ts";
import { type AoDeps, type Exec, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
import { deliveryForAsset } from "./library-versions.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
import type { ExecuteResult } from "./organize-exec.ts";
import { emitRecord } from "./organize-plan.ts";
import { finalizeTransfer } from "./organize-transfer.ts";
import type { FrozenPlan } from "./organize-types.ts";
import { cleanupReplacement, settleSamePathReplacement } from "./replacement.ts";
import { scrapeAfterImport } from "./scrape.ts";
import { slotComplete as sharedSlotComplete } from "./subscriptions.ts";
import { downloadTasksUnder } from "./takeover.ts";
/** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */
export async function finalizeRecord(d: AoDeps, recordId: string): Promise<ExecuteResult> {
const db = d.ctx.db;
const pre = await db
.selectFrom("organization_record")
.selectAll()
.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
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.execute();
// 同位置其它版本(MP latest 口径:同季同集同 Part、只看视频扩展名):升级时作为旧版本处置候选,不删
const otherVersions: string[] = [];
for (const f of filesPre) {
if (f.status === "succeeded" && f.role === "main" && f.target_path) {
const abs = join(frozenPre.rootPath, f.target_path);
const r = await d.ports.probe(abs);
probes.set(f.id, r);
const names = await readdir(dirname(abs)).catch(() => [] as string[]);
for (const o of findOtherVersions(
abs,
names.map((n) => join(dirname(abs), n)),
)) {
otherVersions.push(relative(frozenPre.rootPath, o));
}
}
}
const result = await 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,
move: false,
dt: null as DownloadTaskRow | null,
};
}
const frozen = rec.plan as unknown as FrozenPlan;
const now = d.ports.now();
const files = await trx
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.orderBy("ordinal")
.execute();
// 探测结果明确"不是视频" → 该文件核验失败
for (const f of files) {
const p = probes.get(f.id) as { status?: string; video?: unknown[] } | undefined;
if (p?.status === "ok" && Array.isArray(p.video) && p.video.length === 0) {
await trx
.updateTable("organization_file_result")
.set({
status: "failed",
error: jsonb({
code: "NOT_VIDEO",
message: "ffprobe 未发现视频流",
}),
})
.where("id", "=", f.id)
.execute();
f.status = "failed";
}
}
// 新路径绑定与资产登记同事务
for (const nb of frozen.newBindings) {
await sql`insert into path_binding (media_entity_id, season_number, binding_kind, library_root_id, relative_path, template_version, bound_by)
values (${frozen.entityId}, ${
nb.scope === "season" ? (nb.season ?? null) : null
}, ${nb.scope}, ${nb.libraryRootId}, ${nb.relativePath},
${String(nb.templateVersion)}, ${`organize:${recordId}`})
on conflict do nothing`.execute(trx);
}
const dt = rec.download_task_id
? await trx.selectFrom("download_task").selectAll().where("id", "=", rec.download_task_id).executeTakeFirst()
: undefined;
const planFiles = new Map(frozen.organize.files.map((f) => [f.src, f]));
const assetBySrc = new Map<string, { assetId: string; locationId: string }>();
const mainAssets: string[] = [];
const presentAssets: string[] = [];
for (const f of files) {
// 目标已在库且全量 sha256 相同(already_in_library):不重复登记,但已登记的资产算作本次核验到的内容
// (例如整季包里与单集入库相同的文件),参与 Slot 覆盖判断
if (f.status === "skipped" && f.skip_reason === "already_in_library" && f.role === "main" && f.target_path) {
const have = await trx
.selectFrom("asset_location as l")
.innerJoin("media_asset as a", "a.id", "l.media_asset_id")
.select("a.id")
.where("l.library_root_id", "=", frozen.libraryRootId)
.where("l.relative_path", "=", f.target_path)
.where("l.status", "!=", "removed")
.where("a.status", "=", "active")
.executeTakeFirst();
if (have) {
presentAssets.push(have.id);
await trx
.updateTable("organization_file_result")
.set({ media_asset_id: have.id })
.where("id", "=", f.id)
.execute();
}
continue;
}
if (f.status !== "succeeded" || !f.target_path) continue;
const pf = planFiles.get(f.source_path);
const isMain = f.role === "main";
const parent = pf?.mainSrc ? assetBySrc.get(pf.mainSrc) : undefined;
if (!isMain && !parent) continue;
let hit = await trx
.selectFrom("asset_location")
.select(["id", "media_asset_id"])
.where("library_root_id", "=", frozen.libraryRootId)
.where("relative_path", "=", f.target_path)
.where("status", "!=", "removed")
.executeTakeFirst();
if (
hit &&
pf?.action === "replace" &&
frozen.replacement?.assets.some((a) => a.files.some((old) => old.locationId === hit!.id))
) {
await trx.updateTable("asset_location").set({ status: "removed" }).where("id", "=", hit.id).execute();
await sql`update media_asset set status='retired' where id=${hit.media_asset_id} and not exists(select 1 from asset_location where media_asset_id=${hit.media_asset_id} and status <> 'removed')`.execute(
trx,
);
hit = undefined;
}
let assetId: string;
let locationId: string;
const st = await statSafe(join(frozen.rootPath, f.target_path));
const factsOf = st.kind === "ok" ? st.facts : null;
if (hit) {
assetId = hit.media_asset_id;
locationId = hit.id;
} else {
const fx = frozen.facts[f.source_path];
const probe = probes.get(f.id);
const asset = await trx
.insertInto("media_asset")
.values({
media_entity_id: frozen.entityId,
kind: isMain ? "video" : f.role === "audio" ? "audio" : f.role === "subtitle" ? "subtitle" : "other",
parent_asset_id: parent?.assetId ?? null,
size_bytes: factsOf?.size ?? f.source_size_bytes,
sample_digest: f.checksum?.startsWith("sample:") ? f.checksum.slice(7) : null,
content_hash: f.checksum?.startsWith("sha256:") ? f.checksum.slice(7) : null,
hash_algorithm: f.checksum?.startsWith("sha256:") ? "sha256" : null,
media_info: jsonb({
...(probe ? { probe } : {}),
_umt: {
quality: fx?.quality ?? {},
unitKeys: fx?.unitKeys ?? [],
},
}),
// LIBV-1:库内文件名 + 探测 + 资源解析合成版本规格
delivery: jsonb(
isMain
? deliveryForAsset(f.target_path, {
...(probe ? { probe } : {}),
_umt: { quality: fx?.quality ?? {} },
})
: fx
? {
...(fx.quality.resolution ? { resolution: fx.quality.resolution } : {}),
...(fx.quality.source ? { sourceType: fx.quality.source } : {}),
...(fx.quality.releaseGroup ? { releaseGroup: fx.quality.releaseGroup.name } : {}),
...(fx.quality.hdr?.length ? { hdr: fx.quality.hdr } : {}),
}
: {},
),
content_edition: fx?.quality.cut ?? null,
source_release_id: dt?.source_release_id ?? null,
download_task_id: dt?.id ?? null,
status: "active",
verified_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
assetId = asset.id;
const loc = await trx
.insertInto("asset_location")
.values({
media_asset_id: assetId,
location_kind: "library",
library_root_id: frozen.libraryRootId,
relative_path: f.target_path,
status: "available",
is_primary: true,
size_bytes: factsOf?.size ?? null,
inode: factsOf?.ino ?? null,
device: factsOf?.dev ?? null,
mtime: factsOf ? new Date(factsOf.mtimeMs) : null,
sidecar_of_location_id: parent?.locationId ?? null,
last_seen_at: now,
last_checked_at: now,
})
.returning("id")
.executeTakeFirstOrThrow();
locationId = loc.id;
if (isMain && fx?.episodes && frozen.mediaType === "tv") {
// 覆盖按实际文件:整季包里的每个文件按文件名解析出的集号登记;单元未建档的建临时单元(provisional)
const units = await ensureEpisodeUnits(trx, frozen.entityId, fx.season ?? 1, fx.episodes);
for (const u of units) {
await trx
.insertInto("asset_unit_coverage")
.values({
media_asset_id: assetId,
content_unit_id: u.id,
coverage: "full",
evidence: jsonb({ from: "file_name", recordId }),
})
.onConflict((oc) => oc.doNothing())
.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);
await trx
.updateTable("organization_file_result")
.set({ media_asset_id: assetId })
.where("id", "=", f.id)
.execute();
}
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,
};
const verifiedAssets = [...mainAssets, ...presentAssets];
const okAny = verifiedAssets.length > 0;
await trx
.updateTable("organization_record")
.set({
status,
...counts,
finished_at: now,
...(status === "failed" && !rec.reason
? { reason: rec.action === "in_place" ? "in_place_failed" : "transfer_failed" }
: {}),
})
.where("id", "=", recordId)
.execute();
// 下载任务 / 计划 / Slot
const move = rec.action === "move" && status === "succeeded";
if (dt) {
if (frozen.importPlanId && frozen.importPlanId !== dt.acquisition_plan_id) {
await trx
.updateTable("acquisition_plan")
.set({
status: okAny ? "completed" : "failed",
status_reason: `organize:${status}`,
})
.where("id", "=", frozen.importPlanId)
.execute();
}
await trx
.updateTable("download_task")
.set({ status: okAny ? "imported" : "completed" })
.where("id", "=", dt.id)
.execute();
if (dt.acquisition_plan_id) {
const plan = await trx
.selectFrom("acquisition_plan")
.select(["id", "slot_id", "purpose"])
.where("id", "=", dt.acquisition_plan_id)
.executeTakeFirst();
if (plan) {
await trx
.updateTable("acquisition_plan")
.set({
status: okAny ? "completed" : "failed",
status_reason: `organize:${status}`,
})
.where("id", "=", plan.id)
.execute();
const slot = plan.slot_id
? await trx.selectFrom("acquisition_slot").selectAll().where("id", "=", plan.slot_id).executeTakeFirst()
: undefined;
if (slot && slot.media_entity_id === frozen.entityId && okAny) {
const old = slot.current_asset_id;
// 需处理的 Slot(如自动整理被拦)经人工整理入库:先回到核验,再由 verify_passed 按实际入库资产定终态
if (slot.status === "attention") {
await applySlotEvent(d, trx, slot.id, {
type: "manual_verify",
reason: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
});
}
// 完成判定与订阅完成(含历史)统一由 applySlotEvent(verify_passed) 按总集数账本重算,这里不再本地判定
await applySlotEvent(
d,
trx,
slot.id,
{ type: "verify_passed", complete: false },
{ currentAssetId: verifiedAssets[0]!, reason: "verified" },
);
if (old && old !== verifiedAssets[0] && plan.purpose === "upgrade") {
// 升级:旧版本保留,处置只记待处理,不删
await upsertDecision(d, trx, {
kind: "other",
severity: "info",
title: "旧版本待处置",
summary: "新版本已核验入库,旧文件保留,按保留策略处理",
subjectType: "media_asset",
subjectId: old,
dedupeKey: `old_version:${old}`,
evidence: {
slotId: slot.id,
oldAssetId: old,
newAssetId: mainAssets[0],
...(otherVersions.length ? { otherVersionFiles: otherVersions } : {}),
},
});
}
} else if (slot && slot.media_entity_id === frozen.entityId && !okAny) {
await applySlotEvent(
d,
trx,
slot.id,
{ type: "verify_failed" },
{
reason: `organize:${status}`,
},
);
}
}
}
// 本下载任务先前被拦的整理事项:已有资产入库即视为处理完毕
if (okAny) await closeOrganizeDecisions(d, trx, dt.id, rec);
if (okAny) {
await trx
.updateTable("download_task")
.set({ media_entity_id: frozen.entityId })
.where("id", "=", dt.id)
.where("media_entity_id", "is", null)
.execute();
}
}
// 手动按路径整理了某个下载任务的内容(文件管理整理向导):关闭该任务的整理事项,回填作品
if (!dt && okAny) {
const srcs = [
...new Set([frozen.sourceRoot, ...files.map((f) => f.source_path)].filter((x): x is string => !!x)),
];
for (const t of await downloadTasksUnder(trx, srcs)) {
await closeOrganizeDecisions(d, trx, t.id, rec);
await trx
.updateTable("download_task")
.set({ media_entity_id: frozen.entityId })
.where("id", "=", t.id)
.where("media_entity_id", "is", null)
.execute();
}
}
// 手动整理指定的 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 sharedSlotComplete(trx, slot, frozen.mediaType, 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,
};
});
await settleSamePathReplacement(d, frozenPre, recordId);
await cleanupReplacement(d, frozenPre, recordId);
// SCR-1:有资产入库时按设置自动刮削(写 NFO 与图片;排任务,不在这里等)
if (result.status === "succeeded" || result.status === "partial") {
await scrapeAfterImport(d, frozenPre.entityId ?? null, recordId, {
force: frozenPre.metadataMode === "local",
enabled: frozenPre.scrapeAfterImport,
});
}
// Move 完成:移除 qB 任务记录,结束下载侧管理(不删数据)
if (result.move && result.dt) {
const topo = (
await loadInstances(db, {
enabledOnly: false,
ids: [result.dt.instance_id],
})
)[0];
if (topo) {
try {
const client = await d.ports.downloader(topo.inst);
await client.delete([result.dt.torrent_hash], { deleteFiles: false });
await db
.updateTable("download_task")
.set({ removed_at: d.ports.now(), managed: false })
.where("id", "=", result.dt.id)
.execute();
} catch (err) {
d.ctx.logger.warn(
{ downloadTaskId: result.dt.id, err: { message: errMessage(err) } },
"Move 后移除 qB 任务失败,下次同步重试",
);
}
}
}
return {
recordId: result.recordId,
status: result.status,
succeeded: result.succeeded,
failed: result.failed,
};
}
/** 关闭下载任务的"整理冲突 / 做种义务阻止移动 / 没识别出作品"事项(resolution.key=manual_organize,记整理记录 id) */
export async function closeOrganizeDecisions(d: AoDeps, trx: Exec, downloadTaskId: string, rec: OrganizationRecordRow) {
const by = rec.actor_id ? `${rec.actor_type}:${rec.actor_id}` : rec.actor_type;
const rows = await trx
.updateTable("decision")
.set({
status: "resolved",
resolved_by: by,
resolved_at: d.ports.now(),
resolution: jsonb({
key: rec.trigger === "auto" ? "organize_retry" : "manual_organize",
optionKey: null,
by,
note: "整理已成功入库",
organizationRecordId: rec.id,
}),
})
.where("dedupe_key", "in", [`organize_blocked:${downloadTaskId}`, `organize_identity:${downloadTaskId}`])
.where("status", "in", ["open", "investigating", "awaiting_user"])
.returning("id")
.execute();
for (const r of rows) {
await d.ctx.outbox.emit(
trx,
"decision.updated",
{ decisionId: r.id, status: "resolved" },
{ idempotencyKey: `decision.updated:${r.id}:resolved` },
);
}
}
/** 按季号与集号区间找或建集单元(临时 provisional,挂在季单元下) */
export async function ensureEpisodeUnits(
exec: Exec,
entityId: string,
season: number,
eps: { start: number; end: number },
): Promise<{ id: string }[]> {
const find = (kind: "season" | "episode", ep: number | null) => {
let q = exec
.selectFrom("content_unit")
.select("id")
.where("media_entity_id", "=", entityId)
.where("kind", "=", kind)
.where("season_number", "=", season)
.where("status", "!=", "retired");
q = ep === null ? q.where("episode_number", "is", null) : q.where("episode_number", "=", ep);
return q.orderBy("created_at").executeTakeFirst();
};
const create = async (kind: "season" | "episode", ep: number | null, parent: string | null) =>
exec
.insertInto("content_unit")
.values({
media_entity_id: entityId,
parent_unit_id: parent,
kind,
season_number: season,
episode_number: ep,
status: "provisional",
source: "file",
display_order: ep ?? season,
})
.returning("id")
.executeTakeFirstOrThrow();
let seasonUnit: { id: string } | undefined;
const out: { id: string }[] = [];
const end = Math.min(eps.end, eps.start + 199);
for (let e = eps.start; e <= end; e++) {
const hit = await find("episode", e);
if (hit) {
out.push(hit);
continue;
}
seasonUnit ??= (await find("season", null)) ?? (await create("season", null, null));
out.push(await create("episode", e, seasonUnit.id));
}
return out;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,192 @@
import { reasonSentence } from "@umt/contracts";
import type { OrganizationRecordRow } from "@umt/db";
import type { Exec } from "./common.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
// ── 公开记录映射 ──
export async function loadRecordsPublic(exec: Exec, rows: OrganizationRecordRow[], includeFiles: boolean) {
if (rows.length === 0) return [];
const ids = rows.map((r) => r.id);
const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))];
const dlIds = [...new Set(rows.map((r) => r.download_task_id).filter((x): x is string => !!x))];
const [files, ops, entities, idents, units, sizes, dls] = await Promise.all([
includeFiles
? exec
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "in", ids)
.orderBy("ordinal")
.execute()
: Promise.resolve([]),
exec
.selectFrom("asset_operation")
.select(["id", "organization_record_id"])
.where("organization_record_id", "in", ids)
.execute(),
entityIds.length
? exec.selectFrom("media_entity").selectAll().where("id", "in", entityIds).execute()
: Promise.resolve([]),
entityIds.length
? exec
.selectFrom("external_identity")
.select(["media_entity_id", "provider", "entity_type", "external_id"])
.where("media_entity_id", "in", entityIds)
.where("status", "=", "accepted")
.execute()
: Promise.resolve([]),
exec
.selectFrom("content_unit")
.select(["id", "season_number", "episode_number"])
.where("id", "in", [
...new Set(rows.map((r) => r.content_unit_id).filter((x): x is string => !!x)),
"00000000-0000-0000-0000-000000000000",
])
.execute(),
// HIS-1:列表也要大小,不带逐文件明细时单独汇总
exec
.selectFrom("organization_file_result")
.select(["organization_record_id", (eb) => eb.fn.sum<string | null>("source_size_bytes").as("bytes")])
.where("organization_record_id", "in", ids)
.groupBy("organization_record_id")
.execute(),
dlIds.length
? exec
.selectFrom("download_task as t")
.leftJoin("downloader_instance as i", "i.id", "t.instance_id")
.select(["t.id", "t.name", "t.torrent_hash", "i.name as downloader_name"])
.where("t.id", "in", dlIds)
.execute()
: Promise.resolve([]),
]);
const iso = (v: Date | null) => (v ? v.toISOString() : null);
const posterOf = (metadata: unknown) => {
const v = (metadata as { posterPath?: { value?: unknown } } | null)?.posterPath?.value;
return typeof v === "string" && v ? v : null;
};
// blocked:* 的说明在 error.conflicts[].detail(中文),有就用它,不把内部串直接给人看
const conflictText = (err: unknown) =>
((err as { conflicts?: { detail?: string }[] } | null)?.conflicts ?? [])
.map((c) => c.detail)
.filter(Boolean)
.join(";");
const reason = (code: string | null, err: unknown) =>
code
? {
code: code.split(":")[0]!,
message: conflictText(err) || reasonSentence(code, code),
}
: err
? {
code: (err as { code?: string }).code ?? "error",
message: (err as { message?: string }).message ?? JSON.stringify(err).slice(0, 300),
}
: null;
return rows.map((r) => {
const e = entities.find((x) => x.id === r.media_entity_id);
const u = units.find((x) => x.id === r.content_unit_id);
const targetRef = {
path: r.target_path,
view: "library" as const,
downloaderInstanceId: null,
libraryRootId: r.target_library_root_id,
};
return {
id: r.id,
taskId: r.task_id,
downloadTaskId: r.download_task_id,
operationIds: ops.filter((o) => o.organization_record_id === r.id).map((o) => o.id),
media: e
? {
entityId: e.id,
kind: e.kind,
title: e.title,
year: e.year,
contentUnitId: r.content_unit_id,
seasonNumber: u?.season_number ?? null,
episodeNumber: u?.episode_number ?? null,
externalIds: idents
.filter((i) => i.media_entity_id === e.id)
.map((i) => ({
provider: i.provider,
entityType: i.entity_type,
externalId: i.external_id,
})),
posterPath: posterOf(e.metadata),
}
: null,
trigger: r.trigger,
origin: r.trigger === "manual" ? ("manual" as const) : ("auto" as const),
actor: { type: r.actor_type, id: r.actor_id },
action: r.action,
status: r.status,
source: {
path: r.source_path,
view: r.source_path_view,
downloaderInstanceId: r.source_downloader_instance_id,
libraryRootId: null,
},
target: targetRef,
reason: reason(r.reason, r.error && r.status === "failed" ? r.error : null),
counts: {
total: r.file_total,
succeeded: r.file_succeeded,
failed: r.file_failed,
skipped: r.file_skipped,
},
sizeBytes: (() => {
const b = sizes.find((x) => x.organization_record_id === r.id)?.bytes;
return b == null ? null : Number(b);
})(),
download: (() => {
const t = dls.find((x) => x.id === r.download_task_id);
return t
? {
taskId: t.id,
name: t.name,
hash: t.torrent_hash,
downloaderName: t.downloader_name,
}
: null;
})(),
files: includeFiles
? files
.filter((f) => f.organization_record_id === r.id)
.map((f) => ({
id: f.id,
ordinal: f.ordinal,
role: f.role,
mode: f.mode,
status: f.status,
source: {
path: f.source_path,
view: r.source_path_view,
downloaderInstanceId: r.source_downloader_instance_id,
libraryRootId: null,
},
target: {
path: f.target_path,
view: "library" as const,
downloaderInstanceId: null,
libraryRootId: f.target_library_root_id,
},
sizeBytes: f.source_size_bytes,
contentUnitId: f.content_unit_id,
mediaAssetId: f.media_asset_id,
operationId: f.asset_operation_id,
skipReason: f.skip_reason ? { code: f.skip_reason.split(":")[0]!, message: f.skip_reason } : null,
failure: f.status === "failed" && f.error ? reason(null, f.error) : null,
startedAt: iso(f.started_at),
finishedAt: iso(f.finished_at),
}))
: null,
createdAt: r.created_at.toISOString(),
updatedAt: r.updated_at.toISOString(),
startedAt: iso(r.started_at),
finishedAt: iso(r.finished_at),
changeSeq: String(r.change_seq),
};
});
}
@@ -0,0 +1,126 @@
import type { ActorType, OrganizationRecord, OrganizationTrigger } from "@umt/contracts";
import type { ExistingPolicy, NewBinding, OrganizePlan, QualityFacts } from "@umt/domain";
import type { FrozenTransfer, TransferSpec } from "./organize-transfer.ts";
import type { FrozenReplacement } from "./replacement.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
export interface ImportRequest {
skipSuccessfulRecords?: boolean | undefined;
scrapeAfterImport?: boolean | undefined;
importMode?: "normal" | "replace";
replacementAssetIds?: string[];
sourceConfigId?: string | undefined;
idempotencyKey?: string | undefined;
downloadTaskId?: string | undefined;
/** 手动:执行进程可访问的源路径(文件或目录) */
sourcePaths?: string[] | undefined;
mediaEntityId?: string | undefined;
contentUnitId?: string | undefined;
action?: "copy" | "move" | "in_place" | undefined;
targetLibraryRootId?: string | undefined;
trigger: OrganizationTrigger;
actorType: ActorType;
actorId?: string | null;
taskId?: string | null;
/** 只生成计划,不写记录、不建待处理 */
dryRun?: boolean;
/** 手动按路径整理:遍历到的每个文件都经它复核(realpath 在允许的根下),越界抛 PathGuardError */
pathGuard?: ((p: string) => Promise<string>) | undefined;
/** 目标已存在策略(缺省取 ORGANIZE_ON_EXISTING,再缺省 conflict);replace_with_verify 还需 grantReplace,否则阻塞待授权 */
onExisting?: ExistingPolicy | undefined;
/** 已获替换授权(审批/手动确认) */
grantReplace?: boolean | undefined;
/** QB-1 目标已存在时"保留两者":新文件(连同附属文件)改名加序号放在旁边,已有文件不动 */
keepBoth?: 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;
/** W9-H 主视频最小体积(字节),低于的排除为 too_small;缺省取端口选项 minVideoBytes */
minSizeBytes?: number | undefined;
/** W9-H 命名方式:template(默认)按模板;keep 保留原文件名(目录仍按模板/绑定/targetDir) */
namingMode?: NamingMode | undefined;
/** W9-H 调用方声明的命名模板来源(本次覆写等);缺省按模板层级推断,冻结进 plan.naming */
namingSource?: NamingSourceTag | undefined;
}
export type NamingMode = "template" | "keep";
export type NamingSourceTag = "entity" | "root" | "global" | "override";
/** W9-H:冻结计划里的命名记录(organization_record.plan.naming) */
export interface FrozenNaming {
mode: NamingMode;
templateSource: NamingSourceTag;
templateVersion: number | null;
}
export interface FileOverrideInput {
season?: number;
episodes?: { start: number; end: number };
contentUnitIds?: string[];
}
/** organization_record.plan 的冻结内容 */
export interface FrozenPlan {
scrapeAfterImport?: boolean;
skippedSuccessful?: OrganizePlan["files"];
replacement?: FrozenReplacement;
importPlanId?: string;
metadataMode?: "local";
sourceFacts?: Record<string, { size: number; mtimeMs: number }>;
sourceConfigId?: string;
idempotencyKey?: string;
classification?: {
category: string | null;
matchedFields: string[];
fallback: boolean;
};
version: 1;
organize: OrganizePlan;
entityId: string;
mediaType: "movie" | "tv";
libraryRootId: string;
rootPath: string;
sentinel: string;
targetDirs: string[];
newBindings: NewBinding[];
sourceRoot: string | null;
/** 主视频 src → 解析出的质量特征与单元键 */
facts: Record<
string,
{
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;
/** W9-H:命名方式与模板来源/版本 */
naming?: FrozenNaming;
}
export interface PlanResult {
/** dryRun 时为 null */
recordId: string | null;
/** 按路径整理时源目录文件数超过上限(walkMaxFiles),计划只含前 N 个文件(仅 dryRun 会出现) */
truncated?: boolean;
status: OrganizationRecord["status"];
reason: string | null;
plan?: FrozenPlan;
}
File diff suppressed because it is too large. Load diff
+19 -169
View File
@@ -16,7 +16,6 @@ import {
InMemoryProfileLock,
imageDimensions,
type PageContent,
type PageMetrics,
parseTorrent,
prepareImage,
profileKeyFor,
@@ -32,15 +31,12 @@ import type {
BrowserCoordinateSpace,
BrowserCredentialState,
BrowserDownloadVerification,
BrowserEgressProxy,
BrowserEvidenceRef,
BrowserHealth,
BrowserIdentity,
BrowserProviderView,
BrowserReconnectState,
BrowserSession,
BrowserSessionView,
BrowserTab,
DownloadArtifact,
NavigationBudget,
} from "@umt/contracts";
@@ -73,6 +69,25 @@ import { AGENT_KEEPALIVE_MAX_IDLE_MS, BROWSER_KEEPALIVE_MS, BrowserKeepalive } f
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
import {
coordinateSpace,
egressProxy,
holderName,
iso,
obj,
REASON_TEXT,
restoreUrl,
SOLVABLE,
safeUrl,
screenToPage,
toLease,
toSession,
toTab,
type VisionShot,
} from "./session-view.ts";
export type { VisionShot } from "./session-view.ts";
export { coordinateSpace, egressProxy, imageSize, screenToPage, toLease, toSession, toTab } from "./session-view.ts";
export type Actor = {
kind: "automation" | "human";
@@ -167,10 +182,6 @@ const HUMAN_CHALLENGE =
export const TWO_FACTOR =
/二次验证|两步验证|二步验证|双重验证|短信验证码|验证码已发送|本人设备|设备确认|身份验证器|2fa|two-?step|two-?factor|one-time (?:code|password)|authenticator app|verification code (?:has been |was )?sent|confirm (?:it'?s|this is) you|approve (?:the )?sign.?in/i;
const iso = (d: Date | string | null | undefined) => (d ? new Date(d).toISOString() : null);
type Json = Record<string, unknown>;
const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {});
export interface RecoveryState {
currentTabId?: string | null;
needsSnapshot?: boolean;
@@ -2279,165 +2290,4 @@ export class BrowserService {
}
}
const REASON_TEXT: Record<string, string> = {
cf_challenge: "站点要求人机验证(Cloudflare),需要你过一次",
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "人工接管超时未交还",
agent_request: "智能体请求人工处理",
human_released_waiting: "人工交还时选择继续等待",
};
/** 重连回到的地址:会话记录的地址是 http(s)、在站点域名内、没有被脱敏过才用;否则 null(调用方退回站点首页) */
function restoreUrl(url: string | null, budget: NavigationBudget): string | null {
const u = url ? safeUrl(url) : null;
if (!u || !/^https?:$/.test(u.protocol) || !hostAllowed(u.hostname, budget.domainAllowlist)) return null;
if (url!.includes("[REDACTED")) return null;
return url;
}
function safeUrl(s: string): URL | null {
try {
return new URL(s);
} catch {
return null;
}
}
function holderName(holder: string): string {
return holder.includes(":") ? holder.slice(holder.indexOf(":") + 1) : holder;
}
export function toSession(r: BrowserSessionRow): BrowserSession {
return {
id: r.id,
providerId: r.provider_id,
siteId: r.site_id,
accountId: r.account_id,
profileKey: r.profile_key,
controller: r.controller as BrowserController,
currentUrl: r.current_url,
lastActivityAt: iso(r.last_activity_at)!,
upstreamRef: obj(r.upstream_ref),
recoveryState: obj(r.recovery_state),
closedAt: iso(r.closed_at),
closeReason: r.close_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toTab(r: BrowserTabRow): BrowserTab {
return {
id: r.id,
sessionId: r.session_id,
upstreamTabId: r.upstream_tab_id,
url: r.url,
title: r.title,
status: r.status as BrowserTab["status"],
lastSnapshotAt: iso(r.last_snapshot_at),
closedAt: iso(r.closed_at),
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toLease(r: BrowserControlLeaseRow): BrowserControlLease {
return {
id: r.id,
sessionId: r.session_id,
holderKind: r.holder_kind as BrowserControlLease["holderKind"],
holder: r.holder,
reason: r.reason,
status: r.status as BrowserControlLease["status"],
acquiredAt: iso(r.acquired_at)!,
expiresAt: iso(r.expires_at)!,
releasedAt: iso(r.released_at),
releaseReason: r.release_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
/** 视觉子流程可以接手的等待原因(验证码 / 页面挑战 / 上次没处理成功) */
const SOLVABLE = ["captcha", "challenge", "challenge_unsolved", "cf_challenge"];
/** capture / visionAct 的结果(能力输出的公共部分) */
export interface VisionShot {
evidence: BrowserEvidenceRef | null;
url: string | null;
controller: BrowserController;
sensitive: boolean;
imageForModel: boolean;
note: string | null;
textSnapshot: string | null;
coordinates: BrowserCoordinateSpace | null;
waitingReason: string | null;
modelImage: { mimeType: "image/png" | "image/jpeg"; data: string; width: number; height: number } | null;
}
/** 模型坐标系:缩放后的截图 ↔ 页面视口;取不到页面度量时按原始截图尺寸、DPR 1 推定 */
export function coordinateSpace(
src: { width: number; height: number },
img: { width: number; height: number },
m: PageMetrics | null,
): BrowserCoordinateSpace {
return {
imageWidth: img.width,
imageHeight: img.height,
sourceWidth: src.width,
sourceHeight: src.height,
viewportWidth: m?.viewportWidth ?? src.width,
viewportHeight: m?.viewportHeight ?? src.height,
devicePixelRatio: m?.devicePixelRatio ?? 1,
assumed: !m,
};
}
/** 截图像素(交给模型的缩放图)→ 页面 CSS 像素:按像素中心换算,四舍五入 */
export function screenToPage(sp: BrowserCoordinateSpace, x: number, y: number): { pageX: number; pageY: number } {
return {
pageX: Math.round(((x + 0.5) * sp.viewportWidth) / sp.imageWidth - 0.5),
pageY: Math.round(((y + 0.5) * sp.viewportHeight) / sp.imageHeight - 0.5),
};
}
/** 截图像素尺寸:PNG IHDR / JPEG SOF */
export function imageSize(data: Uint8Array): { width: number | null; height: number | null } {
if (data.length > 24 && data[0] === 0x89 && data[1] === 0x50) {
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return { width: v.getUint32(16), height: v.getUint32(20) };
}
if (data[0] === 0xff && data[1] === 0xd8) {
let i = 2;
while (i + 9 < data.length) {
if (data[i] !== 0xff) break;
const marker = data[i + 1]!;
const len = (data[i + 2]! << 8) | data[i + 3]!;
if (marker >= 0xc0 && marker <= 0xc3)
return { width: (data[i + 7]! << 8) | data[i + 8]!, height: (data[i + 5]! << 8) | data[i + 6]! };
i += 2 + len;
}
}
return { width: null, height: null };
}
export type { ElementTarget };
/** 代理地址 → 浏览器上下文代理(账号口令拆出来;Playwright 不认 URL 里的口令) */
export function egressProxy(url: string): BrowserEgressProxy | null {
try {
const u = new URL(url);
if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return null;
return {
server: `${u.protocol}//${u.host}`,
...(u.username ? { username: decodeURIComponent(u.username) } : {}),
...(u.password ? { password: decodeURIComponent(u.password) } : {}),
};
} catch {
return null;
}
}
@@ -0,0 +1,181 @@
import { hostAllowed, type PageMetrics } from "@umt/adapters";
import type {
BrowserControlLease,
BrowserController,
BrowserCoordinateSpace,
BrowserEgressProxy,
BrowserEvidenceRef,
BrowserSession,
BrowserTab,
NavigationBudget,
} from "@umt/contracts";
import type { BrowserControlLeaseRow, BrowserSessionRow, BrowserTabRow } from "@umt/db";
// C1-E 浏览器服务层:BrowserProvider(camofox)之上的会话/标签/控制权持久化(数据库为准)、
// 站点域名白名单 + 站点限流、凭证注入、接管状态机、下载产物暂存核验。
// 适配器只是内存镜像:服务重启后按数据库恢复,旧元素引用一律失效。
export const iso = (d: Date | string | null | undefined) => (d ? new Date(d).toISOString() : null);
export type Json = Record<string, unknown>;
export const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {});
export const REASON_TEXT: Record<string, string> = {
cf_challenge: "站点要求人机验证(Cloudflare),需要你过一次",
captcha: "登录页需要验证码",
challenge: "页面需要人工验证",
two_factor: "需要二次验证或本人设备确认",
challenge_unsolved: "自动处理页面挑战未成功",
login_required: "登录已失效:接管后在画面里登录一次,交还后全站生效",
lease_expired: "人工接管超时未交还",
agent_request: "智能体请求人工处理",
human_released_waiting: "人工交还时选择继续等待",
};
/** 重连回到的地址:会话记录的地址是 http(s)、在站点域名内、没有被脱敏过才用;否则 null(调用方退回站点首页) */
export function restoreUrl(url: string | null, budget: NavigationBudget): string | null {
const u = url ? safeUrl(url) : null;
if (!u || !/^https?:$/.test(u.protocol) || !hostAllowed(u.hostname, budget.domainAllowlist)) return null;
if (url!.includes("[REDACTED")) return null;
return url;
}
export function safeUrl(s: string): URL | null {
try {
return new URL(s);
} catch {
return null;
}
}
export function holderName(holder: string): string {
return holder.includes(":") ? holder.slice(holder.indexOf(":") + 1) : holder;
}
export function toSession(r: BrowserSessionRow): BrowserSession {
return {
id: r.id,
providerId: r.provider_id,
siteId: r.site_id,
accountId: r.account_id,
profileKey: r.profile_key,
controller: r.controller as BrowserController,
currentUrl: r.current_url,
lastActivityAt: iso(r.last_activity_at)!,
upstreamRef: obj(r.upstream_ref),
recoveryState: obj(r.recovery_state),
closedAt: iso(r.closed_at),
closeReason: r.close_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toTab(r: BrowserTabRow): BrowserTab {
return {
id: r.id,
sessionId: r.session_id,
upstreamTabId: r.upstream_tab_id,
url: r.url,
title: r.title,
status: r.status as BrowserTab["status"],
lastSnapshotAt: iso(r.last_snapshot_at),
closedAt: iso(r.closed_at),
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
export function toLease(r: BrowserControlLeaseRow): BrowserControlLease {
return {
id: r.id,
sessionId: r.session_id,
holderKind: r.holder_kind as BrowserControlLease["holderKind"],
holder: r.holder,
reason: r.reason,
status: r.status as BrowserControlLease["status"],
acquiredAt: iso(r.acquired_at)!,
expiresAt: iso(r.expires_at)!,
releasedAt: iso(r.released_at),
releaseReason: r.release_reason,
createdAt: iso(r.created_at)!,
updatedAt: iso(r.updated_at)!,
};
}
/** 视觉子流程可以接手的等待原因(验证码 / 页面挑战 / 上次没处理成功) */
export const SOLVABLE = ["captcha", "challenge", "challenge_unsolved", "cf_challenge"];
/** capture / visionAct 的结果(能力输出的公共部分) */
export interface VisionShot {
evidence: BrowserEvidenceRef | null;
url: string | null;
controller: BrowserController;
sensitive: boolean;
imageForModel: boolean;
note: string | null;
textSnapshot: string | null;
coordinates: BrowserCoordinateSpace | null;
waitingReason: string | null;
modelImage: { mimeType: "image/png" | "image/jpeg"; data: string; width: number; height: number } | null;
}
/** 模型坐标系:缩放后的截图 ↔ 页面视口;取不到页面度量时按原始截图尺寸、DPR 1 推定 */
export function coordinateSpace(
src: { width: number; height: number },
img: { width: number; height: number },
m: PageMetrics | null,
): BrowserCoordinateSpace {
return {
imageWidth: img.width,
imageHeight: img.height,
sourceWidth: src.width,
sourceHeight: src.height,
viewportWidth: m?.viewportWidth ?? src.width,
viewportHeight: m?.viewportHeight ?? src.height,
devicePixelRatio: m?.devicePixelRatio ?? 1,
assumed: !m,
};
}
/** 截图像素(交给模型的缩放图)→ 页面 CSS 像素:按像素中心换算,四舍五入 */
export function screenToPage(sp: BrowserCoordinateSpace, x: number, y: number): { pageX: number; pageY: number } {
return {
pageX: Math.round(((x + 0.5) * sp.viewportWidth) / sp.imageWidth - 0.5),
pageY: Math.round(((y + 0.5) * sp.viewportHeight) / sp.imageHeight - 0.5),
};
}
/** 截图像素尺寸:PNG IHDR / JPEG SOF */
export function imageSize(data: Uint8Array): { width: number | null; height: number | null } {
if (data.length > 24 && data[0] === 0x89 && data[1] === 0x50) {
const v = new DataView(data.buffer, data.byteOffset, data.byteLength);
return { width: v.getUint32(16), height: v.getUint32(20) };
}
if (data[0] === 0xff && data[1] === 0xd8) {
let i = 2;
while (i + 9 < data.length) {
if (data[i] !== 0xff) break;
const marker = data[i + 1]!;
const len = (data[i + 2]! << 8) | data[i + 3]!;
if (marker >= 0xc0 && marker <= 0xc3)
return { width: (data[i + 7]! << 8) | data[i + 8]!, height: (data[i + 5]! << 8) | data[i + 6]! };
i += 2 + len;
}
}
return { width: null, height: null };
}
/** 代理地址 → 浏览器上下文代理(账号口令拆出来;Playwright 不认 URL 里的口令) */
export function egressProxy(url: string): BrowserEgressProxy | null {
try {
const u = new URL(url);
if (!["http:", "https:", "socks4:", "socks5:"].includes(u.protocol) || !u.hostname) return null;
return {
server: `${u.protocol}//${u.host}`,
...(u.username ? { username: decodeURIComponent(u.username) } : {}),
...(u.password ? { password: decodeURIComponent(u.password) } : {}),
};
} catch {
return null;
}
}
@@ -0,0 +1,36 @@
import { useId } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
/** 整理表单里的标签 + 下拉选择 */
export function FormSelect({
label,
value,
onChange,
choices,
disabled,
}: {
label: string;
value: string;
onChange: (value: string) => void;
choices: { value: string; label: string; disabled?: boolean }[];
disabled?: boolean;
}) {
const id = useId();
return (
<label htmlFor={id} className="space-y-1.5 text-caption">
<span className="block text-muted-foreground">{label}</span>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} aria-label={label} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" collisionPadding={16}>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value} disabled={choice.disabled}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
@@ -1,18 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import {
type CursorPage,
EventType,
type IdentifyCompletedPayload,
type PathMapping,
TmdbMediaDetail,
} from "@umt/contracts";
import { EventType, type IdentifyCompletedPayload, TmdbMediaDetail } from "@umt/contracts";
import { Eye, FileStack, LoaderCircle, RotateCw, Search } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileRef,
type FileRoot,
type FilesOrganizeGroup,
filesErrorReason,
filesErrorText,
@@ -20,12 +11,9 @@ import {
useFileRoots,
useIdentify,
useOrganizeFiles,
useRecordSources,
useRecordSourcesMany,
} from "~/api/files.ts";
import { useOrganizeSources } from "~/api/organize-sources.ts";
import { apiParsed } from "~/api/parse.ts";
import { useRecord } from "~/api/records.ts";
import { useSettings } from "~/api/settings.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
@@ -35,18 +23,16 @@ import { matchingOrganizeSources } from "~/components/organize/source-match.ts";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { apiData } from "~/lib/api.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
import { candidateEntity, EntityPicker } from "./entity-picker.tsx";
import { FormSelect } from "./form-select.tsx";
import {
applyMinSize,
DEFAULT_MIN_SIZE,
defaultOptions,
dialogRows,
downloadContentRef,
entityLabel,
groupExecutable,
MIB,
@@ -60,169 +46,10 @@ import {
type WizardRow,
wizardStats,
} from "./model.ts";
import { type OrganizeSource, useResolvedSource } from "./source.ts";
import { PreviewList, PreviewStatsBar } from "./step-preview.tsx";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| {
kind: "files";
items: FileRef[];
hnrPaths?: string[];
label?: string;
preset?: WizardEntity | null;
sourceConfigId?: string;
defaultAction?: "copy" | "move";
scanSummary?: string;
}
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
tags?: string[];
}
| { kind: "record"; recordId: string }
/** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
profileId?: string;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (source.kind === "records") {
const label = `重新整理 ${source.recordIds.length} 条记录`;
if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true };
if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error };
const seen = new Set<string>();
const items = (many.data ?? [])
.flatMap((r) => r.items)
.filter((it) => {
const k = `${it.root}\u0000${it.path}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return {
...base,
items,
hnrPaths: [],
unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))],
preset: source.preset ?? null,
profileId: (many.data ?? []).every((r) => r.sourceConfigId === many.data?.[0]?.sourceConfigId)
? many.data?.[0]?.sourceConfigId
: undefined,
label,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
profileId: recSrc.data?.sourceConfigId,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [
source,
roots,
maps.isLoading,
maps.data,
recSrc.isPending,
recSrc.error,
recSrc.data,
rec.data,
many.isPending,
many.error,
many.data,
]);
}
function FormSelect({
label,
value,
onChange,
choices,
disabled,
}: {
label: string;
value: string;
onChange: (value: string) => void;
choices: { value: string; label: string; disabled?: boolean }[];
disabled?: boolean;
}) {
const id = useId();
return (
<label htmlFor={id} className="space-y-1.5 text-caption">
<span className="block text-muted-foreground">{label}</span>
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} aria-label={label} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" collisionPadding={16}>
{choices.map((choice) => (
<SelectItem key={choice.value} value={choice.value} disabled={choice.disabled}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
);
}
export type { OrganizeSource } from "./source.ts";
/** 所有入口共用一个表单;预览按需展开,提交仅加入现有后台队列。 */
export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; onClose: () => void }) {
@@ -0,0 +1,138 @@
// 整理弹窗的来源:各入口传入的上下文 → 待整理文件(文件管理选中项 / 下载内容 / 整理记录源路径)
import { useQuery } from "@tanstack/react-query";
import type { CursorPage, PathMapping } from "@umt/contracts";
import { useMemo } from "react";
import { type FileRef, type FileRoot, filesMockEnabled, useRecordSources, useRecordSourcesMany } from "~/api/files.ts";
import { useRecord } from "~/api/records.ts";
import { baseName } from "~/components/files/model.ts";
import { apiData } from "~/lib/api.ts";
import { downloadContentRef, entityLabel, type WizardEntity } from "./model.ts";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| {
kind: "files";
items: FileRef[];
hnrPaths?: string[];
label?: string;
preset?: WizardEntity | null;
sourceConfigId?: string;
defaultAction?: "copy" | "move";
scanSummary?: string;
}
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
tags?: string[];
}
| { kind: "record"; recordId: string }
/** HIS-1 批量重新整理:几条记录的源文件一起进向导;preset = 这几条同属一个作品时预选 */
| { kind: "records"; recordIds: string[]; preset?: WizardEntity | null };
export type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
profileId?: string;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
export function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
const many = useRecordSourcesMany(source.kind === "records" ? source.recordIds : []);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (source.kind === "records") {
const label = `重新整理 ${source.recordIds.length} 条记录`;
if (many.isPending) return { ...base, items: [], hnrPaths: [], label, pending: true };
if (many.error) return { ...base, items: [], hnrPaths: [], label, error: many.error };
const seen = new Set<string>();
const items = (many.data ?? [])
.flatMap((r) => r.items)
.filter((it) => {
const k = `${it.root}\u0000${it.path}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return {
...base,
items,
hnrPaths: [],
unresolved: [...new Set((many.data ?? []).flatMap((r) => r.unresolved))],
preset: source.preset ?? null,
profileId: (many.data ?? []).every((r) => r.sourceConfigId === many.data?.[0]?.sourceConfigId)
? many.data?.[0]?.sourceConfigId
: undefined,
label,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
profileId: recSrc.data?.sourceConfigId,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [
source,
roots,
maps.isLoading,
maps.data,
recSrc.isPending,
recSrc.error,
recSrc.data,
rec.data,
many.isPending,
many.error,
many.data,
]);
}