diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts new file mode 100644 index 00000000..a3a08b16 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts @@ -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 { + 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(); + /** 本轮 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( + 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///<原相对路径> 并写 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 { + 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(); + +/** 按 server 配置(FS_*)建一次 fsproxy,随 ctx.config 缓存 */ +export function fsRuntime(d: AoDeps): FsRuntime { + const c = (d.ctx.config ?? {}) as Partial; + 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 { + 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 { + 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) }; + } +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts new file mode 100644 index 00000000..5a1a71b6 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts @@ -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 { + 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(); + 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(); + 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; +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts new file mode 100644 index 00000000..b053ba6e --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -0,0 +1,1093 @@ +import { readdir } from "node:fs/promises"; +import { basename, dirname, join, relative } from "node:path"; +import { checkRoot, DEFAULT_ROOT_SENTINEL, FsError, sha256Full, statSafe } from "@umt/adapters"; +import type { ActorType, OrganizationTrigger, PathBinding } from "@umt/contracts"; +import { reasonText } from "@umt/contracts"; +import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow } from "@umt/db"; +import { sql } from "@umt/db"; +import { + buildNamingContext, + type PathBinding as DomainBinding, + type ExistingTarget, + fileKindOf, + type NewBinding, + type OrganizePlan, + overrideFor, + type ParsedRelease, + parseReleasePath, + planOrganize, + type QualityFacts, + replacementCovers, + replacementScope, + resolveTargetDirectory, + type SourceFile, + sidecarStem, +} from "@umt/domain"; +import { organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; +import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; +import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; +import { classifyEntity } from "./classification.ts"; +import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; +import { applySlotEvent } from "./downloads.ts"; +import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts"; +import { resolveOrganizeSource } from "./organize-config.ts"; +import { PathGuardError } from "./organize-guard.ts"; +import { successfulOrganizeCounts } from "./organize-history.ts"; +import { planTransfer } from "./organize-transfer.ts"; +// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ +// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 +import type { FrozenPlan, ImportRequest, PlanResult } from "./organize-types.ts"; +import { type FrozenReplacement, freezeReplacement } from "./replacement.ts"; +import { hnrSatisfied } from "./retention.ts"; +import { subscriptionLibraryRoot } from "./subscriptions.ts"; +import { + CONFLICT_OPTIONS, + identifyTakeover, + identityOptions, + keepBothTargets, + type QbFileLike, + type TakeoverIdentify, + takeoverSummary, +} from "./takeover.ts"; + +export const qualityOf = (p: ParsedRelease): QualityFacts => ({ + ...(p.resolution ? { resolution: p.resolution } : {}), + ...(p.source ? { source: p.source } : {}), + hdr: p.hdr, + ...(p.releaseGroup ? { releaseGroup: p.releaseGroup } : {}), + isEncode: p.isEncode, + ...(p.cut ? { cut: p.cut } : {}), +}); + +export function segmentsWithin(path: string, root: string | null) { + if (!root) return path; + const r = root.replace(/\/+$/, ""); + // 单文件种子:内容根就是文件本身,不拿缓存目录名参与解析 + if (path === r) return { file: r.split("/").pop() ?? r }; + if (!path.startsWith(`${r}/`)) return path; + const within = [r.split("/").pop() ?? "", ...path.slice(r.length + 1).split("/")].filter(Boolean); + const file = within.pop()!; + const parent = within.pop(); + const grandParent = within.pop(); + return { + file, + ...(parent !== undefined ? { parent } : {}), + ...(grandParent !== undefined ? { grandParent } : {}), + }; +} + +/** 递归列出文件(跳过隐藏项),最多 limit 个;调用方传 上限+1 判断是否截断 */ +export async function walk( + p: string, + out: string[], + limit: number, + guard?: ((p: string) => Promise) | undefined, +): Promise { + const st = await statSafe(p); + if (st.kind !== "ok") { + throw new FsError("ENOENT", `源路径不可访问:${p} (${st.kind})`, { + path: p, + }); + } + if (st.facts.isFile) { + // 目录里的符号链接可能指向根外:逐个文件复核 realpath + if (guard) await guard(p); + out.push(p); + return; + } + if (!st.facts.isDirectory) return; + const names = (await readdir(p)).sort(); + for (const n of names) { + if (out.length >= limit) return; + if (n.startsWith(".")) continue; + await walk(join(p, n), out, limit, guard); + } +} + +/** + * 选中的是单个视频文件时,把同目录里主干名(剥掉 .chs/.eng 等语言标签后)相同的字幕 / 外挂音轨补进源文件列表; + * 配对规则与 planOrganize 的附属文件一致(同目录、主干唯一匹配),这里只负责把它们带进来。 + * 目录源已由 walk 递归带上附属文件;已在列表里的不重复加。 + */ +export async function addSidecarSiblings( + sources: string[], + out: string[], + limit: number, + guard?: ((p: string) => Promise) | undefined, +): Promise { + const have = new Set(out); + const byDir = new Map>(); + for (const src of sources) { + if (fileKindOf(src) !== "video" || !have.has(src)) continue; + const dir = dirname(src); + const stems = byDir.get(dir) ?? new Set(); + stems.add( + basename(src) + .replace(/\.[^.]+$/, "") + .toLowerCase(), + ); + byDir.set(dir, stems); + } + for (const [dir, stems] of byDir) { + const names = (await readdir(dir).catch(() => [] as string[])).sort(); + for (const n of names) { + if (out.length >= limit) return; + if (n.startsWith(".")) continue; + const kind = fileKindOf(n); + if (kind !== "subtitle" && kind !== "audio") continue; + const p = join(dir, n); + if (have.has(p) || !stems.has(sidecarStem(n).stem)) continue; + const st = await statSafe(p); + if (st.kind !== "ok" || !st.facts.isFile) continue; + if (guard) await guard(p); + out.push(p); + have.add(p); + } + } +} + +export function libraryKinds(kind: MediaEntityRow["kind"]): string[] { + if (kind === "movie") return ["movie", "mixed"]; + if (kind === "variety") return ["variety", "series", "mixed"]; + return ["series", "mixed"]; +} + +export async function chooseRoot( + exec: Exec, + entity: MediaEntityRow, + bindings: DomainBinding[], + wanted?: string, + /** 订阅指定的库根:只在作品还没有路径绑定时用(首次整理建绑定),不存在/停用时按类型默认 */ + preferred?: string | null, +): Promise { + const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute(); + if (wanted) return roots.find((r) => r.id === wanted) ?? null; + const bound = bindings.find((b) => b.active !== false); + if (bound) return roots.find((r) => r.id === bound.libraryRootId) ?? null; + const pref = preferred ? roots.find((r) => r.id === preferred) : undefined; + if (pref) return pref; + const kinds = libraryKinds(entity.kind); + return ( + roots + .filter((r) => r.management_mode === "managed" && kinds.includes(r.media_kind)) + .sort((a, b) => kinds.indexOf(a.media_kind) - kinds.indexOf(b.media_kind))[0] ?? null + ); +} + +/** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */ +export async function planImport(d: AoDeps, req: ImportRequest): Promise { + if (req.transfer) return planTransfer(d, req, req.transfer); + const db = d.ctx.db; + if (req.idempotencyKey && !req.dryRun) { + const existing = await db + .selectFrom("organization_record") + .select(["id", "status", "reason"]) + .where(sql`plan->>'idempotencyKey'`, "=", req.idempotencyKey) + .where("status", "in", ["queued", "running", "succeeded", "partial"]) + .executeTakeFirst(); + if (existing) { + return { + recordId: existing.id, + status: existing.status, + reason: existing.reason, + }; + } + } + let dt: DownloadTaskRow | undefined; + if (req.downloadTaskId) { + dt = await db.selectFrom("download_task").selectAll().where("id", "=", req.downloadTaskId).executeTakeFirst(); + if (!dt) throw new Error(`下载任务 ${req.downloadTaskId} 不存在`); + // 同一下载任务已有未结束的记录:复用(步骤重放幂等) + const open = await db + .selectFrom("organization_record") + .select(["id", "status", "reason"]) + .where("download_task_id", "=", dt.id) + .where("status", "in", ["queued", "running"]) + .executeTakeFirst(); + if (open) { + return { recordId: open.id, status: open.status, reason: open.reason }; + } + } + const base = { + trigger: req.trigger, + actorType: req.actorType, + actorId: req.actorId ?? null, + taskId: req.taskId ?? null, + downloadTaskId: dt?.id ?? null, + }; + const dry = req.dryRun === true; + let truncated = false; + const decide_ = (input: Parameters[2]) => + dry ? Promise.resolve("") : upsertDecision(d, db, input); + const fail = async ( + reason: string, + extra: Record = {}, + entityId: string | null = null, + ): Promise => + dry + ? { + recordId: null, + status: "failed", + reason, + ...(truncated ? { truncated } : {}), + } + : writeFailedRecord(d, { + ...base, + action: req.action ?? "copy", + reason, + evidence: extra, + entityId, + sourceInstanceId: dt?.instance_id ?? null, + }); + + // ── 源文件 ── + const files: SourceFile[] = []; + const sourceFacts: NonNullable = {}; + let sourceRoot: string | null = null; + const topo = dt ? (await loadInstances(db, { enabledOnly: false, ids: [dt.instance_id] }))[0] : undefined; + let qbFiles: QbFileLike[] = []; + if (dt) { + if (!topo || !dt.save_path) return fail("source_path_unknown"); + const base = hostPathOf(topo, dt.save_path); + if (!base) return fail("path_mapping_missing", { savePath: dt.save_path }); + let list: { + name: string; + size: number; + priority?: number; + index?: number; + }[] = []; + try { + const client = await d.ports.downloader(topo.inst); + list = (await client.files(dt.torrent_hash)).map((f) => ({ + name: f.name, + size: f.size, + priority: f.priority, + index: f.index, + })); + } catch { + list = ((dt.file_list ?? []) as { path: string; size: number }[]).map((f) => ({ name: f.path, size: f.size })); + } + if (list.length === 0) return fail("file_list_unavailable"); + qbFiles = list; + sourceRoot = dt.content_path ? hostPathOf(topo, dt.content_path) : null; + for (const f of list) { + if (f.priority === 0) continue; + const abs = join(base, f.name); + const st = await statSafe(abs); + if (st.kind !== "ok") { + return fail("source_missing", { path: abs, stat: st.kind }); + } + if (st.facts.size !== f.size) { + return fail("source_size_mismatch", { + path: abs, + expected: f.size, + actual: st.facts.size, + }); + } + sourceFacts[abs] = { size: st.facts.size, mtimeMs: st.facts.mtimeMs }; + files.push({ + path: abs, + size: st.facts.size, + device: st.facts.dev, + ...(f.index !== undefined ? { index: f.index } : {}), + }); + } + } else if (req.sourcePaths?.length) { + const paths: string[] = []; + const max = d.ports.options.walkMaxFiles; + try { + // 多走一个用来判断是否超限 + for (const p of req.sourcePaths) { + await walk(p, paths, max + 1, req.pathGuard); + } + // 直接选中的视频文件(整理向导把目录展开成逐个视频):同目录同主干的字幕 / 外挂音轨随行进计划 + await addSidecarSiblings(req.sourcePaths, paths, max + 1, req.pathGuard); + } catch (err) { + if (err instanceof PathGuardError) throw err; + return fail("source_missing", { message: errMessage(err) }); + } + if (paths.length > max) { + paths.length = max; + truncated = true; + // 正式执行不接受截断的计划(会漏整理文件);dryRun 返回前 N 个文件的计划 + truncated 标记 + if (!dry) return fail("too_many_files", { limit: max }); + } + for (const p of paths) { + const st = await statSafe(p); + if (st.kind === "ok") { + sourceFacts[p] = { size: st.facts.size, mtimeMs: st.facts.mtimeMs }; + files.push({ path: p, size: st.facts.size, device: st.facts.dev }); + } + } + sourceRoot = req.sourcePaths.length === 1 ? req.sourcePaths[0]!.replace(/\/+$/, "") : null; + if (sourceRoot && files.length === 1 && files[0]!.path === sourceRoot) { + sourceRoot = dirname(sourceRoot); + } + } else { + return fail("no_source"); + } + + // ── 作品 ── + const importPlanId = dt?.import_plan_id ?? dt?.acquisition_plan_id; + const evaluation = importPlanId + ? ((await db.selectFrom("acquisition_plan").select("evaluation").where("id", "=", importPlanId).executeTakeFirst()) + ?.evaluation as + | { + manual?: boolean; + mediaEntityId?: string | null; + importMode?: "normal" | "replace"; + replacementAssetIds?: string[]; + } + | undefined) + : undefined; + let entityId = req.mediaEntityId ?? (evaluation?.manual ? evaluation.mediaEntityId : null) ?? null; + let slotId: string | null = null; + if (dt?.acquisition_plan_id) { + const p = await db + .selectFrom("acquisition_plan as p") + .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") + .select(["s.id as slot_id", "s.media_entity_id"]) + .where("p.id", "=", dt.acquisition_plan_id) + .executeTakeFirst(); + slotId = p?.slot_id ?? null; + entityId ??= p?.media_entity_id ?? null; + } + const rel = dt?.source_release_id + ? await db.selectFrom("source_release").selectAll().where("id", "=", dt.source_release_id).executeTakeFirst() + : undefined; + entityId ??= rel?.media_entity_id ?? null; + entityId ??= dt?.media_entity_id ?? null; + // QB-1 接管任务(没有站点资源 / 计划,用户手动加进 qB 的):按种子名 + 主视频文件名识别,成功直接整理 + let takeover: TakeoverIdentify | null = null; + let metadataMode: "local" | undefined; + if (!entityId && dt && !dt.source_release_id && !dt.acquisition_plan_id && !dry) { + takeover = await identifyTakeover(d, dt, qbFiles); + entityId = takeover.entityId; + } + if (!entityId && rel && !dry && d.ctx.capabilities.has("metadata.resolve_organize")) { + try { + const resolved = (await d.ctx.capabilities.invoke( + "metadata.resolve_organize", + { sourceReleaseId: rel.id }, + { + actorId: req.actorId ?? "system:organize", + taskId: req.taskId ?? `organize:${dt?.id ?? rel.id}`, + scopeIds: [], + signal: new AbortController().signal, + source: "task", + }, + )) as { mediaEntityId?: string | null; mode?: "tmdb" | "local" | null }; + entityId = resolved.mediaEntityId ?? null; + if (entityId && resolved.mode === "local") metadataMode = "local"; + } catch (error) { + d.ctx.logger.warn({ sourceReleaseId: rel.id, error: errMessage(error) }, "整理元数据补全失败"); + } + } + if (!entityId) { + const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {}); + await decide_({ + kind: "identity_ambiguous", + title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`, + summary: takeoverSummary(takeover), + subjectType: "organization_record", + subjectId: rid.recordId, + dedupeKey: `organize_identity:${dt?.id ?? rid.recordId ?? "x"}`, + evidence: { + downloadTaskId: dt?.id ?? null, + ...(takeover + ? { + torrentName: dt?.name ?? null, + mainFile: takeover.mainFile, + parsed: { + title: takeover.parsed.title ?? null, + year: takeover.parsed.year ?? null, + season: takeover.parsed.season ?? null, + episodes: takeover.parsed.episodes ?? null, + }, + outcome: takeover.result.outcome, + reason: takeover.result.reason, + candidates: takeover.result.candidates, + } + : {}), + }, + ...(dt ? { options: identityOptions(takeover, dt.name) } : {}), + }); + return rid; + } + const entity = await db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst(); + if (!entity) return fail("entity_not_found", {}, null); + if ( + !metadataMode && + Object.values(entity.metadata as Record).some( + (field) => + field && + typeof field === "object" && + "value" in field && + ["web", "agent"].includes(String((field as { source?: unknown }).source)), + ) + ) { + const tmdb = await db + .selectFrom("external_identity") + .select("id") + .where("media_entity_id", "=", entity.id) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .executeTakeFirst(); + if (!tmdb) metadataMode = "local"; + } + const mediaType: "movie" | "tv" = entity.kind === "movie" ? "movie" : "tv"; + + const inPlace = req.action === "in_place"; + const importMode = req.importMode ?? evaluation?.importMode ?? "normal"; + if (inPlace && importMode === "replace") return fail("in_place_cannot_replace", {}, entity.id); + let replacement: FrozenReplacement | undefined; + if (importMode === "replace") { + try { + replacement = await freezeReplacement( + d, + entity.id, + mediaType, + req.replacementAssetIds ?? evaluation?.replacementAssetIds ?? [], + ); + } catch (error) { + return fail("replacement_invalid", { message: errMessage(error) }, entity.id); + } + } + + // ── 操作方式 ── + const orgSettings = await d.ctx.settings.get("organize"); + let sourceConfig: Awaited> = null; + try { + sourceConfig = inPlace + ? null + : await resolveOrganizeSource(d, { + id: req.sourceConfigId, + paths: files.map((f) => f.path), + mediaType, + instanceId: dt?.instance_id, + tags: dt?.tags ?? undefined, + }); + } catch (error) { + return fail("source_config_mismatch", { message: errMessage(error) }, entity.id); + } + const classification = sourceConfig?.category + ? { category: sourceConfig.category, matchedFields: [], fallback: false } + : sourceConfig + ? await classifyEntity(d, entity) + : undefined; + // Existing bindings remain usable even if metadata cannot currently be fetched. + const hasBinding = await db + .selectFrom("path_binding") + .select("id") + .where("media_entity_id", "=", entity.id) + .where("status", "=", "active") + .executeTakeFirst(); + if (sourceConfig && !classification?.category && !hasBinding && !req.targetDir) { + await decide_({ + kind: "other", + title: `暂时无法分类:${entity.title}`, + summary: "检查 TMDB 连接或选择固定分类;目录扫描会在后续轮次重试,也可手动整理存量", + subjectType: "organize_source", + subjectId: sourceConfig.id, + dedupeKey: `source_classification:${sourceConfig.id}:${entity.id}`, + evidence: { + sourceConfigId: sourceConfig.id, + mediaEntityId: entity.id, + sourcePaths: req.sourcePaths ?? [], + }, + }); + return { + recordId: null, + status: "failed", + reason: "classification_unavailable", + }; + } + const policy = (await publishedAcquisitionPolicies(db))[0]; + let action: "copy" | "move" | "in_place" = + req.action ?? sourceConfig?.action ?? policy?.rules.organizeMode ?? orgSettings.defaultMode; + const site = rel + ? await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst() + : undefined; + const hnr = dt ? hnrSatisfied(dt, rel, site) : true; + let downgradedFromMove: string | undefined; + // 手动整理显式要求 Move 时不降级(未满足由路由 409 + 待处理拦下);只有自动整理按策略预授权改 Copy + if (action === "move" && hnr !== true && req.trigger !== "manual" && policy?.rules.allowCopyWhenHnrPending) { + // 策略预先授权:H&R 未满足时改用 Copy(显式记录,不是静默降级) + action = "copy"; + downgradedFromMove = hnr === false ? "hnr_unsatisfied" : "hnr_unknown"; + } + + // ── 路径绑定与库根 ── + const bindingRows = await db + .selectFrom("path_binding") + .selectAll() + .where("media_entity_id", "=", entity.id) + .where("status", "=", "active") + .execute(); + const bindings = bindingRows.map((r) => toDomainPathBinding(toApi(r))); + const root = inPlace + ? (await db.selectFrom("library_root").selectAll().where("enabled", "=", true).execute()) + .filter( + (r) => + (!req.targetLibraryRootId || r.id === req.targetLibraryRootId) && + files.every((f) => f.path.startsWith(`${r.local_path.replace(/\/+$/, "")}/`)), + ) + .sort((a, b) => b.local_path.length - a.local_path.length)[0] + : await chooseRoot( + db, + entity, + bindings, + req.targetLibraryRootId ?? (sourceConfig && !hasBinding ? sourceConfig.target.root.slice(4) : undefined), + await subscriptionLibraryRoot(db, { slotId, entityId: entity.id }), + ); + if (!root) return fail(inPlace ? "in_place_outside_library" : "no_library_root", {}, entity.id); + const targetDir = inPlace ? undefined : 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") { + const r = await fail( + `library_root_offline:${health.reason}`, + { + libraryRootId: root.id, + }, + entity.id, + ); + await decide_({ + kind: "other", + severity: "critical", + title: `媒体库目录离线:${root.name}`, + summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失", + subjectType: "library_root", + subjectId: root.id, + dedupeKey: `library_root_offline:${root.id}`, + evidence: { reason: health.reason }, + }); + return r; + } + + // ── 命名:模板按 作品 > 库根 > 全局 回落并登记版本;目录只在首次绑定时渲染,文件名每次按当前模板 ── + const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined); + const namingEntity = await loadNamingEntity(db, entity, { + libraryRoot: root.name, + slotId: req.slotId, + }); + if (classification?.category) namingEntity.category = classification.category; + const effective = effectiveNaming(namingSettings, root, entity).templates; + let configuredTemplates = effective; + if (sourceConfig && !req.targetDir && sourceConfig.target.root === `lib:${root.id}`) { + const prefix = [sourceConfig.target.path, ...(sourceConfig.category ? [] : [classification?.category])] + .filter(Boolean) + .join("/"); + if (prefix) { + const literal = `${prefix + .split("/") + .filter(Boolean) + .map((part) => `{{ ${JSON.stringify(part)} }}`) + .join("/")}/`; + configuredTemplates = { + movie: literal + effective.movie, + tv: literal + effective.tv, + variety: literal + effective.variety, + }; + } + } + const templates = await ensureNamingVersion(db, configuredTemplates); + const boundVersion = bindings.find((b) => b.scope === "entity")?.templateVersion; + const fileTemplates = + namingSettings?.lockFileNameForBound && boundVersion + ? ((await namingByVersion(db, boundVersion)) ?? templates) + : templates; + const naming = buildNamingContext(namingEntity); + const facts: FrozenPlan["facts"] = {}; + const seasons = new Set(); + 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] = { + quality: qualityOf(parsed), + 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 }; + const newBindings: NewBinding[] = []; + const target: { movieDir?: string; seasonDirs?: Record } = {}; + const working = [...bindings]; + const decide = (season?: number) => + resolveTargetDirectory({ + entity: { id: entity.id, kind: mediaType }, + naming, + namingKind: namingEntity.kind!, + ...(season !== undefined ? { season } : {}), + bindings: working, + libraryRoot: libRoot, + templates, + }); + const handle = async (dec: ReturnType, season?: number): Promise => { + if (dec.kind === "conflict" || dec.kind === "error") { + const r = await fail(`path_binding_${dec.kind}:${dec.reason}`, { season }, entity.id); + if (dec.kind === "conflict") { + await decide_({ + kind: "path_conflict", + title: `作品存在多个历史目录:${entity.title}`, + summary: "不会按最新标题自动合并。指定要保留的目录", + subjectType: "media_entity", + subjectId: entity.id, + dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`, + evidence: { + bindings: dec.bindings.map((b) => ({ + id: b.id, + libraryRootId: b.libraryRootId, + relativePath: b.relativePath, + })), + }, + }); + } + return r; + } + if (dec.kind === "new") { + for (const nb of dec.newBindings) { + if (!newBindings.some((x) => x.scope === nb.scope && x.season === nb.season)) newBindings.push(nb); + working.push({ + id: `pending:${nb.scope}:${nb.season ?? ""}`, + entityId: entity.id, + scope: nb.scope, + ...(nb.season !== undefined ? { season: nb.season } : {}), + libraryRootId: nb.libraryRootId, + relativePath: nb.relativePath, + templateVersion: nb.templateVersion, + active: true, + }); + } + } + if (dec.libraryRootId !== root.id) { + return fail("binding_on_other_root", { libraryRootId: dec.libraryRootId }, entity.id); + } + return join(root.local_path, dec.relativePath); + }; + if (inPlace) { + // 原地入库不渲染目标目录、不建立会影响后续搬运的路径绑定。 + } else 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; + } else { + target.seasonDirs = {}; + for (const s of [...seasons].sort((a, b) => a - b)) { + const r = await handle(decide(s), s); + if (typeof r !== "string") return r; + target.seasonDirs[s] = r; + } + } + + // ── 计划:先算目标,再观察目标是否已存在(失败 ≠ 不存在:stat 出错按存在处理) ── + const intendedScopes = Object.entries(facts).map(([path, f]) => + replacementScope( + path, + mediaType, + f.episodes + ? { + season: f.season, + episodes: Array.from( + { + length: f.episodes.end - f.episodes.start + 1, + }, + (_, i) => f.episodes!.start + i, + ), + } + : undefined, + ), + ); + const replacementPaths = replacement?.assets + .filter((a) => replacementCovers(a.scope, intendedScopes)) + .flatMap((a) => a.files.map((f) => join(f.rootPath, f.path))); + const baseInput = { + operation: action, + mediaType, + ...(sourceRoot ? { sourceRoot } : {}), + files, + entity: namingEntity, + templates: fileTemplates, + target: { + ...target, + ...(health.kind === "online" ? { device: health.dev } : {}), + }, + retention: { hnrSatisfied: hnr }, + minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes, + ...(req.fileOverrides ? { overrides: req.fileOverrides } : {}), + ...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG + ? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG } + : {}), + // 目标存在策略只透传给域层规划;执行端按冻结计划的 action(replace/transfer)传 onExisting + onExisting: replacement + ? "replace_with_verify" + : req.importMode || evaluation?.importMode + ? "conflict" + : (req.onExisting ?? d.ctx.config?.ORGANIZE_ON_EXISTING ?? "conflict"), + ...(replacement + ? { replacePaths: replacementPaths, grants: { replace: true } } + : req.grantReplace + ? { grants: { replace: true } } + : {}), + } as const; + const keepNames = req.namingMode === "keep"; + const draft = planOrganize({ ...baseInput, existingTargets: [] }); + // Only fully successful histories count; partial/failed attempts and target conflicts remain independent. + const skippedSuccessful: OrganizePlan["files"] = []; + if (req.skipSuccessfulRecords && files.length) { + const successful = await successfulOrganizeCounts( + db, + files.map((f) => f.path), + ); + for (const f of draft.files) { + if (successful.has(f.mainSrc ?? f.src)) skippedSuccessful.push(f); + } + const excluded = new Set(skippedSuccessful.map((f) => f.src)); + for (let i = files.length - 1; i >= 0; i--) { + if (excluded.has(files[i]!.path)) files.splice(i, 1); + } + } + const skippedPaths = new Set(skippedSuccessful.map((f) => f.src)); + const existing: ExistingTarget[] = []; + for (const f of draft.files) { + if (inPlace || skippedPaths.has(f.src)) continue; + // keep:按保留原名后的真实目标观察存在性,仍以模板目标为键交给域层判定策略,之后统一改名 + const probe = keepNames ? keptDst(f) : f.dst; + const st = await statSafe(probe); + if (st.kind === "missing") continue; + if (st.kind !== "ok") { + existing.push({ path: f.dst, size: -1 }); + continue; + } + const e: ExistingTarget = { path: f.dst, size: st.facts.size }; + if (st.facts.size === f.size) { + // 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full) + e.sha256 = (await sha256Full(probe)).digest; + const src = files.find((x) => x.path === f.src); + if (src) src.sha256 = (await sha256Full(src.path)).digest; + } + existing.push(e); + } + const plan = planOrganize({ ...baseInput, files, existingTargets: existing }); + if (skippedSuccessful.length && !plan.files.length && !files.some((f) => fileKindOf(f.path) === "video")) { + plan.conflicts = plan.conflicts.filter((c) => c.kind !== "no_main_video"); + plan.requiresApproval = plan.conflicts.some((c) => c.blocking); + } + if (inPlace && plan.files.length) { + const registered = await db + .selectFrom("asset_location as l") + .innerJoin("media_asset as a", "a.id", "l.media_asset_id") + .select(["l.relative_path", "a.media_entity_id"]) + .where("l.library_root_id", "=", root.id) + .where( + "l.relative_path", + "in", + plan.files.map((f) => relative(root.local_path, f.src)), + ) + .where("l.status", "!=", "removed") + .execute(); + for (const file of plan.files) + if ( + registered.some( + (r) => r.relative_path === relative(root.local_path, file.src) && r.media_entity_id !== entity.id, + ) + ) { + file.action = "hold"; + plan.conflicts.push({ + kind: "target_exists", + blocking: true, + src: file.src, + dst: file.dst, + detail: "该文件已登记为其它作品", + }); + plan.requiresApproval = true; + } + } + if (keepNames && !inPlace) keepOriginalNames(plan); + if (req.keepBoth && !inPlace) await keepBothTargets(plan); + const frozen: FrozenPlan = { + version: 1, + ...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}), + ...(skippedSuccessful.length ? { skippedSuccessful } : {}), + ...(replacement ? { replacement } : {}), + ...(importPlanId ? { importPlanId } : {}), + ...(metadataMode ? { metadataMode } : {}), + sourceFacts, + ...(sourceConfig ? { sourceConfigId: sourceConfig.id } : {}), + ...(classification ? { classification } : {}), + ...(req.idempotencyKey ? { idempotencyKey: req.idempotencyKey } : {}), + organize: plan, + entityId: entity.id, + mediaType, + libraryRootId: root.id, + rootPath: root.local_path, + sentinel, + targetDirs: [...new Set(plan.files.map((f) => dirname(f.dst)))], + newBindings, + sourceRoot, + facts, + ...(downgradedFromMove ? { downgradedFromMove } : {}), + ...(req.slotId ? { slotId: req.slotId } : {}), + naming: { + mode: inPlace || keepNames ? "keep" : "template", + // 模板来源按 作品 > 库根 > 全局 回落;保留原文件名 = 本次覆写 + templateSource: + req.namingSource ?? + (inPlace || keepNames ? "override" : effectiveNaming(namingSettings, root, entity).sources[namingEntity.kind!]), + templateVersion: fileTemplates.version, + }, + }; + const drafts = organizePlanToFileResults(plan, { + targetLibraryRootId: root.id, + }); + const blocking = plan.conflicts.filter((c) => c.blocking); + const reason = blocking.length + ? `blocked:${[...new Set(blocking.map((c) => c.kind))].join(",")}` + : downgradedFromMove + ? `move_downgraded_to_copy:${downgradedFromMove}` + : null; + const now = d.ports.now(); + const targetRel = (abs: string | null | undefined) => (abs ? relative(root.local_path, abs) : null); + if (dry) { + return { + recordId: null, + status: blocking.length ? "failed" : "queued", + reason, + plan: frozen, + ...(truncated ? { truncated } : {}), + }; + } + + const result = await 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: entity.id, + content_unit_id: req.contentUnitId ?? null, + download_task_id: dt?.id ?? null, + source_downloader_instance_id: dt?.instance_id ?? null, + source_path_view: "local", + source_path: sourceRoot ?? files[0]?.path ?? null, + target_library_root_id: root.id, + target_path: targetRel(frozen.targetDirs[0]), + action, + status: blocking.length ? "failed" : "running", + reason, + plan: jsonb(frozen), + plan_fingerprint: hashJson(frozen), + file_total: drafts.length, + file_skipped: drafts.filter((f) => f.status === "skipped" || blocking.length > 0).length, + started_at: now, + ...(blocking.length ? { finished_at: now, error: jsonb({ conflicts: blocking }) } : {}), + }) + .returning(["id", "status", "change_seq"]) + .executeTakeFirstOrThrow(); + for (const f of drafts) { + 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: targetRel(f.targetPath), + status: blocking.length ? "skipped" : f.status, + skip_reason: blocking.length && f.status !== "skipped" ? "blocked_by_conflict" : f.skipReason, + }) + .execute(); + } + if (dt) { + await trx + .updateTable("download_task") + .set({ status: blocking.length ? "completed" : "importing" }) + .where("id", "=", dt.id) + .where("status", "in", ["completed", "importing"]) + .execute(); + // 下载历史按作品列出:整理计划定下的作品回填到下载任务(已有的不覆盖) + await trx + .updateTable("download_task") + .set({ media_entity_id: entity.id }) + .where("id", "=", dt.id) + .where("media_entity_id", "is", null) + .execute(); + } + if (blocking.length) { + const hnrConflict = blocking.find((c) => c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown"); + const existsConflict = blocking.some((c) => c.kind === "target_exists"); + await upsertDecision(d, trx, { + kind: hnrConflict ? "approval_required" : "path_conflict", + title: hnrConflict + ? `做种义务未满足,已阻止移动:${entity.title}` + : `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`, + summary: blocking + .map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind) + .join(";") + .slice(0, 500), + subjectType: "organization_record", + subjectId: rec.id, + dedupeKey: `organize_blocked:${dt?.id ?? rec.id}`, + evidence: { + conflicts: blocking, + action, + downloadTaskId: dt?.id ?? null, + }, + options: hnrConflict + ? [ + { key: "wait", label: "做种满足后再移动" }, + { key: "copy", label: "改用复制" }, + ] + : existsConflict + ? CONFLICT_OPTIONS + : [{ key: "review", label: "指定处理方式" }], + }); + if (slotId) { + await applySlotEvent( + d, + trx, + slotId, + { type: "verify_failed" }, + { + reason: reason ?? "blocked", + }, + ); + } + } + await emitRecord(d, trx, rec.id); + return { recordId: rec.id, status: rec.status, reason }; + }); + return result; +} + +/** W9-H 保留原文件名:目标目录不变,文件名取源文件名 */ +export const keptDst = (f: { src: string; dst: string }) => join(dirname(f.dst), basename(f.src)); + +/** W9-H 保留原文件名:改写计划目标;改写后撞名的项转 hold + duplicate_target(阻塞) */ +export function keepOriginalNames(plan: OrganizePlan): void { + const renamed = new Map(); + for (const f of plan.files) { + const kept = keptDst(f); + renamed.set(f.dst, kept); + f.dst = kept; + } + for (const c of plan.conflicts) { + if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!; + } + const byDst = new Map(); + for (const f of plan.files) { + byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]); + } + for (const [dst, fs] of byDst) { + if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue; + for (const f of fs) f.action = "hold"; + plan.conflicts.push({ + kind: "duplicate_target", + blocking: true, + dst, + detail: fs.map((f) => f.src).join(" | "), + }); + } + plan.requiresApproval = plan.conflicts.some((c) => c.blocking); +} + +export async function writeFailedRecord( + d: AoDeps, + r: { + trigger: OrganizationTrigger; + actorType: ActorType; + actorId: string | null; + taskId: string | null; + downloadTaskId: string | null; + action: "copy" | "move" | "in_place"; + reason: string; + evidence: Record; + entityId: string | null; + sourceInstanceId: string | null; + }, +): Promise { + const now = d.ports.now(); + return d.ctx.db.transaction().execute(async (trx) => { + const rec = await trx + .insertInto("organization_record") + .values({ + task_id: r.taskId, + trigger: r.trigger, + actor_type: r.actorType, + actor_id: r.actorId, + media_entity_id: r.entityId, + download_task_id: r.downloadTaskId, + source_downloader_instance_id: r.sourceInstanceId, + action: r.action, + status: "failed", + reason: r.reason, + error: jsonb(r.evidence), + started_at: now, + finished_at: now, + }) + .returning(["id"]) + .executeTakeFirstOrThrow(); + await emitRecord(d, trx, rec.id); + return { recordId: rec.id, status: "failed" as const, reason: r.reason }; + }); +} + +export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promise { + const r = await exec + .selectFrom("organization_record") + .select(["status", "change_seq", "media_entity_id"]) + .where("id", "=", recordId) + .executeTakeFirstOrThrow(); + await d.ctx.outbox.emit(exec, "organization.changed", { + recordId, + status: r.status, + changeSeq: String(r.change_seq), + mediaEntityId: r.media_entity_id, + }); +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-records.ts b/apps/server/src/modules/acquisition-organize/services/organize-records.ts new file mode 100644 index 00000000..d89bfd7f --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-records.ts @@ -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("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), + }; + }); +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-types.ts b/apps/server/src/modules/acquisition-organize/services/organize-types.ts new file mode 100644 index 00000000..f6cdd4af --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-types.ts @@ -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) | 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 | 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; + 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; +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize.ts b/apps/server/src/modules/acquisition-organize/services/organize.ts index e4bef97d..625e4b7a 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize.ts @@ -1,2645 +1,17 @@ -import { randomUUID } from "node:crypto"; -import { mkdir, readdir, realpath, rmdir } from "node:fs/promises"; -import { basename, dirname, join, relative } from "node:path"; -import { - type CopyReceipt, - type CrossMoveStepLog, - checkRoot, - DEFAULT_ROOT_SENTINEL, - FsError, - FsProxy, - fsErrorClass, - type ReplaceOldPolicy, - type StatResult, - sha256Full, - statSafe, -} from "@umt/adapters"; -import type { ActorType, OrganizationRecord, OrganizationTrigger, PathBinding } from "@umt/contracts"; -import { reasonSentence, reasonText } from "@umt/contracts"; -import type { DownloadTaskRow, LibraryRootRow, MediaEntityRow, OrganizationRecordRow } from "@umt/db"; -import { sql } from "@umt/db"; -import { - buildNamingContext, - type PathBinding as DomainBinding, - type ExistingPolicy, - type ExistingTarget, - fileKindOf, - findOtherVersions, - type NewBinding, - type OrganizePlan, - overrideFor, - type ParsedRelease, - parseReleasePath, - planOrganize, - type QualityFacts, - replacementCovers, - replacementScope, - resolveTargetDirectory, - type SourceFile, - sidecarStem, -} from "@umt/domain"; -import { organizationStatusFromFiles, organizePlanToFileResults, toDomainPathBinding } from "@umt/mapping"; -import { withAttemptToken } from "@umt/tasks"; -import { errMessage, hashJson, jsonb, toApi } from "../util.ts"; -import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; -import { classifyEntity } from "./classification.ts"; -import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; -import { applySlotEvent } from "./downloads.ts"; -import { deliveryForAsset } from "./library-versions.ts"; -import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts"; -import { resolveOrganizeSource } from "./organize-config.ts"; -import { PathGuardError } from "./organize-guard.ts"; -import { successfulOrganizeCounts } from "./organize-history.ts"; -import { type FrozenTransfer, finalizeTransfer, planTransfer, type TransferSpec } from "./organize-transfer.ts"; -import { - cleanupReplacement, - type FrozenReplacement, - freezeReplacement, - settleSamePathReplacement, - stagedReplacementBackup, -} from "./replacement.ts"; -import { hnrSatisfied } from "./retention.ts"; -import { scrapeAfterImport } from "./scrape.ts"; -import { slotComplete as sharedSlotComplete, subscriptionLibraryRoot } from "./subscriptions.ts"; -import { - CONFLICT_OPTIONS, - downloadTasksUnder, - identifyTakeover, - identityOptions, - keepBothTargets, - type QbFileLike, - type TakeoverIdentify, - takeoverSummary, -} from "./takeover.ts"; +// 整理服务汇总入口:计划(organize-plan)→ 执行(organize-exec)→ 结算(organize-finalize), +// 类型在 organize-types,公开记录映射在 organize-records。外部统一从这里引用。 -// 入库:冻结整理计划(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) | 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 | 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; - 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; -} - -const qualityOf = (p: ParsedRelease): QualityFacts => ({ - ...(p.resolution ? { resolution: p.resolution } : {}), - ...(p.source ? { source: p.source } : {}), - hdr: p.hdr, - ...(p.releaseGroup ? { releaseGroup: p.releaseGroup } : {}), - isEncode: p.isEncode, - ...(p.cut ? { cut: p.cut } : {}), -}); - -function segmentsWithin(path: string, root: string | null) { - if (!root) return path; - const r = root.replace(/\/+$/, ""); - // 单文件种子:内容根就是文件本身,不拿缓存目录名参与解析 - if (path === r) return { file: r.split("/").pop() ?? r }; - if (!path.startsWith(`${r}/`)) return path; - const within = [r.split("/").pop() ?? "", ...path.slice(r.length + 1).split("/")].filter(Boolean); - const file = within.pop()!; - const parent = within.pop(); - const grandParent = within.pop(); - return { - file, - ...(parent !== undefined ? { parent } : {}), - ...(grandParent !== undefined ? { grandParent } : {}), - }; -} - -/** 递归列出文件(跳过隐藏项),最多 limit 个;调用方传 上限+1 判断是否截断 */ -async function walk( - p: string, - out: string[], - limit: number, - guard?: ((p: string) => Promise) | undefined, -): Promise { - const st = await statSafe(p); - if (st.kind !== "ok") { - throw new FsError("ENOENT", `源路径不可访问:${p} (${st.kind})`, { - path: p, - }); - } - if (st.facts.isFile) { - // 目录里的符号链接可能指向根外:逐个文件复核 realpath - if (guard) await guard(p); - out.push(p); - return; - } - if (!st.facts.isDirectory) return; - const names = (await readdir(p)).sort(); - for (const n of names) { - if (out.length >= limit) return; - if (n.startsWith(".")) continue; - await walk(join(p, n), out, limit, guard); - } -} - -/** - * 选中的是单个视频文件时,把同目录里主干名(剥掉 .chs/.eng 等语言标签后)相同的字幕 / 外挂音轨补进源文件列表; - * 配对规则与 planOrganize 的附属文件一致(同目录、主干唯一匹配),这里只负责把它们带进来。 - * 目录源已由 walk 递归带上附属文件;已在列表里的不重复加。 - */ -async function addSidecarSiblings( - sources: string[], - out: string[], - limit: number, - guard?: ((p: string) => Promise) | undefined, -): Promise { - const have = new Set(out); - const byDir = new Map>(); - for (const src of sources) { - if (fileKindOf(src) !== "video" || !have.has(src)) continue; - const dir = dirname(src); - const stems = byDir.get(dir) ?? new Set(); - stems.add( - basename(src) - .replace(/\.[^.]+$/, "") - .toLowerCase(), - ); - byDir.set(dir, stems); - } - for (const [dir, stems] of byDir) { - const names = (await readdir(dir).catch(() => [] as string[])).sort(); - for (const n of names) { - if (out.length >= limit) return; - if (n.startsWith(".")) continue; - const kind = fileKindOf(n); - if (kind !== "subtitle" && kind !== "audio") continue; - const p = join(dir, n); - if (have.has(p) || !stems.has(sidecarStem(n).stem)) continue; - const st = await statSafe(p); - if (st.kind !== "ok" || !st.facts.isFile) continue; - if (guard) await guard(p); - out.push(p); - have.add(p); - } - } -} - -function libraryKinds(kind: MediaEntityRow["kind"]): string[] { - if (kind === "movie") return ["movie", "mixed"]; - if (kind === "variety") return ["variety", "series", "mixed"]; - return ["series", "mixed"]; -} - -async function chooseRoot( - exec: Exec, - entity: MediaEntityRow, - bindings: DomainBinding[], - wanted?: string, - /** 订阅指定的库根:只在作品还没有路径绑定时用(首次整理建绑定),不存在/停用时按类型默认 */ - preferred?: string | null, -): Promise { - const roots = await exec.selectFrom("library_root").selectAll().where("enabled", "=", true).execute(); - if (wanted) return roots.find((r) => r.id === wanted) ?? null; - const bound = bindings.find((b) => b.active !== false); - if (bound) return roots.find((r) => r.id === bound.libraryRootId) ?? null; - const pref = preferred ? roots.find((r) => r.id === preferred) : undefined; - if (pref) return pref; - const kinds = libraryKinds(entity.kind); - return ( - roots - .filter((r) => r.management_mode === "managed" && kinds.includes(r.media_kind)) - .sort((a, b) => kinds.indexOf(a.media_kind) - kinds.indexOf(b.media_kind))[0] ?? null - ); -} - -/** 生成并冻结整理计划;阻塞冲突 → 记录失败 + 待处理,不执行 */ -export async function planImport(d: AoDeps, req: ImportRequest): Promise { - if (req.transfer) return planTransfer(d, req, req.transfer); - const db = d.ctx.db; - if (req.idempotencyKey && !req.dryRun) { - const existing = await db - .selectFrom("organization_record") - .select(["id", "status", "reason"]) - .where(sql`plan->>'idempotencyKey'`, "=", req.idempotencyKey) - .where("status", "in", ["queued", "running", "succeeded", "partial"]) - .executeTakeFirst(); - if (existing) { - return { - recordId: existing.id, - status: existing.status, - reason: existing.reason, - }; - } - } - let dt: DownloadTaskRow | undefined; - if (req.downloadTaskId) { - dt = await db.selectFrom("download_task").selectAll().where("id", "=", req.downloadTaskId).executeTakeFirst(); - if (!dt) throw new Error(`下载任务 ${req.downloadTaskId} 不存在`); - // 同一下载任务已有未结束的记录:复用(步骤重放幂等) - const open = await db - .selectFrom("organization_record") - .select(["id", "status", "reason"]) - .where("download_task_id", "=", dt.id) - .where("status", "in", ["queued", "running"]) - .executeTakeFirst(); - if (open) { - return { recordId: open.id, status: open.status, reason: open.reason }; - } - } - const base = { - trigger: req.trigger, - actorType: req.actorType, - actorId: req.actorId ?? null, - taskId: req.taskId ?? null, - downloadTaskId: dt?.id ?? null, - }; - const dry = req.dryRun === true; - let truncated = false; - const decide_ = (input: Parameters[2]) => - dry ? Promise.resolve("") : upsertDecision(d, db, input); - const fail = async ( - reason: string, - extra: Record = {}, - entityId: string | null = null, - ): Promise => - dry - ? { - recordId: null, - status: "failed", - reason, - ...(truncated ? { truncated } : {}), - } - : writeFailedRecord(d, { - ...base, - action: req.action ?? "copy", - reason, - evidence: extra, - entityId, - sourceInstanceId: dt?.instance_id ?? null, - }); - - // ── 源文件 ── - const files: SourceFile[] = []; - const sourceFacts: NonNullable = {}; - let sourceRoot: string | null = null; - const topo = dt ? (await loadInstances(db, { enabledOnly: false, ids: [dt.instance_id] }))[0] : undefined; - let qbFiles: QbFileLike[] = []; - if (dt) { - if (!topo || !dt.save_path) return fail("source_path_unknown"); - const base = hostPathOf(topo, dt.save_path); - if (!base) return fail("path_mapping_missing", { savePath: dt.save_path }); - let list: { - name: string; - size: number; - priority?: number; - index?: number; - }[] = []; - try { - const client = await d.ports.downloader(topo.inst); - list = (await client.files(dt.torrent_hash)).map((f) => ({ - name: f.name, - size: f.size, - priority: f.priority, - index: f.index, - })); - } catch { - list = ((dt.file_list ?? []) as { path: string; size: number }[]).map((f) => ({ name: f.path, size: f.size })); - } - if (list.length === 0) return fail("file_list_unavailable"); - qbFiles = list; - sourceRoot = dt.content_path ? hostPathOf(topo, dt.content_path) : null; - for (const f of list) { - if (f.priority === 0) continue; - const abs = join(base, f.name); - const st = await statSafe(abs); - if (st.kind !== "ok") { - return fail("source_missing", { path: abs, stat: st.kind }); - } - if (st.facts.size !== f.size) { - return fail("source_size_mismatch", { - path: abs, - expected: f.size, - actual: st.facts.size, - }); - } - sourceFacts[abs] = { size: st.facts.size, mtimeMs: st.facts.mtimeMs }; - files.push({ - path: abs, - size: st.facts.size, - device: st.facts.dev, - ...(f.index !== undefined ? { index: f.index } : {}), - }); - } - } else if (req.sourcePaths?.length) { - const paths: string[] = []; - const max = d.ports.options.walkMaxFiles; - try { - // 多走一个用来判断是否超限 - for (const p of req.sourcePaths) { - await walk(p, paths, max + 1, req.pathGuard); - } - // 直接选中的视频文件(整理向导把目录展开成逐个视频):同目录同主干的字幕 / 外挂音轨随行进计划 - await addSidecarSiblings(req.sourcePaths, paths, max + 1, req.pathGuard); - } catch (err) { - if (err instanceof PathGuardError) throw err; - return fail("source_missing", { message: errMessage(err) }); - } - if (paths.length > max) { - paths.length = max; - truncated = true; - // 正式执行不接受截断的计划(会漏整理文件);dryRun 返回前 N 个文件的计划 + truncated 标记 - if (!dry) return fail("too_many_files", { limit: max }); - } - for (const p of paths) { - const st = await statSafe(p); - if (st.kind === "ok") { - sourceFacts[p] = { size: st.facts.size, mtimeMs: st.facts.mtimeMs }; - files.push({ path: p, size: st.facts.size, device: st.facts.dev }); - } - } - sourceRoot = req.sourcePaths.length === 1 ? req.sourcePaths[0]!.replace(/\/+$/, "") : null; - if (sourceRoot && files.length === 1 && files[0]!.path === sourceRoot) { - sourceRoot = dirname(sourceRoot); - } - } else { - return fail("no_source"); - } - - // ── 作品 ── - const importPlanId = dt?.import_plan_id ?? dt?.acquisition_plan_id; - const evaluation = importPlanId - ? ((await db.selectFrom("acquisition_plan").select("evaluation").where("id", "=", importPlanId).executeTakeFirst()) - ?.evaluation as - | { - manual?: boolean; - mediaEntityId?: string | null; - importMode?: "normal" | "replace"; - replacementAssetIds?: string[]; - } - | undefined) - : undefined; - let entityId = req.mediaEntityId ?? (evaluation?.manual ? evaluation.mediaEntityId : null) ?? null; - let slotId: string | null = null; - if (dt?.acquisition_plan_id) { - const p = await db - .selectFrom("acquisition_plan as p") - .innerJoin("acquisition_slot as s", "s.id", "p.slot_id") - .select(["s.id as slot_id", "s.media_entity_id"]) - .where("p.id", "=", dt.acquisition_plan_id) - .executeTakeFirst(); - slotId = p?.slot_id ?? null; - entityId ??= p?.media_entity_id ?? null; - } - const rel = dt?.source_release_id - ? await db.selectFrom("source_release").selectAll().where("id", "=", dt.source_release_id).executeTakeFirst() - : undefined; - entityId ??= rel?.media_entity_id ?? null; - entityId ??= dt?.media_entity_id ?? null; - // QB-1 接管任务(没有站点资源 / 计划,用户手动加进 qB 的):按种子名 + 主视频文件名识别,成功直接整理 - let takeover: TakeoverIdentify | null = null; - let metadataMode: "local" | undefined; - if (!entityId && dt && !dt.source_release_id && !dt.acquisition_plan_id && !dry) { - takeover = await identifyTakeover(d, dt, qbFiles); - entityId = takeover.entityId; - } - if (!entityId && rel && !dry && d.ctx.capabilities.has("metadata.resolve_organize")) { - try { - const resolved = (await d.ctx.capabilities.invoke( - "metadata.resolve_organize", - { sourceReleaseId: rel.id }, - { - actorId: req.actorId ?? "system:organize", - taskId: req.taskId ?? `organize:${dt?.id ?? rel.id}`, - scopeIds: [], - signal: new AbortController().signal, - source: "task", - }, - )) as { mediaEntityId?: string | null; mode?: "tmdb" | "local" | null }; - entityId = resolved.mediaEntityId ?? null; - if (entityId && resolved.mode === "local") metadataMode = "local"; - } catch (error) { - d.ctx.logger.warn({ sourceReleaseId: rel.id, error: errMessage(error) }, "整理元数据补全失败"); - } - } - if (!entityId) { - const rid = await fail("identity_unknown", takeover ? { outcome: takeover.result.outcome } : {}); - await decide_({ - kind: "identity_ambiguous", - title: `没识别出作品:${dt?.name ?? req.sourcePaths?.[0] ?? "未知来源"}`, - summary: takeoverSummary(takeover), - subjectType: "organization_record", - subjectId: rid.recordId, - dedupeKey: `organize_identity:${dt?.id ?? rid.recordId ?? "x"}`, - evidence: { - downloadTaskId: dt?.id ?? null, - ...(takeover - ? { - torrentName: dt?.name ?? null, - mainFile: takeover.mainFile, - parsed: { - title: takeover.parsed.title ?? null, - year: takeover.parsed.year ?? null, - season: takeover.parsed.season ?? null, - episodes: takeover.parsed.episodes ?? null, - }, - outcome: takeover.result.outcome, - reason: takeover.result.reason, - candidates: takeover.result.candidates, - } - : {}), - }, - ...(dt ? { options: identityOptions(takeover, dt.name) } : {}), - }); - return rid; - } - const entity = await db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst(); - if (!entity) return fail("entity_not_found", {}, null); - if ( - !metadataMode && - Object.values(entity.metadata as Record).some( - (field) => - field && - typeof field === "object" && - "value" in field && - ["web", "agent"].includes(String((field as { source?: unknown }).source)), - ) - ) { - const tmdb = await db - .selectFrom("external_identity") - .select("id") - .where("media_entity_id", "=", entity.id) - .where("provider", "=", "tmdb") - .where("status", "=", "accepted") - .executeTakeFirst(); - if (!tmdb) metadataMode = "local"; - } - const mediaType: "movie" | "tv" = entity.kind === "movie" ? "movie" : "tv"; - - const inPlace = req.action === "in_place"; - const importMode = req.importMode ?? evaluation?.importMode ?? "normal"; - if (inPlace && importMode === "replace") return fail("in_place_cannot_replace", {}, entity.id); - let replacement: FrozenReplacement | undefined; - if (importMode === "replace") { - try { - replacement = await freezeReplacement( - d, - entity.id, - mediaType, - req.replacementAssetIds ?? evaluation?.replacementAssetIds ?? [], - ); - } catch (error) { - return fail("replacement_invalid", { message: errMessage(error) }, entity.id); - } - } - - // ── 操作方式 ── - const orgSettings = await d.ctx.settings.get("organize"); - let sourceConfig: Awaited> = null; - try { - sourceConfig = inPlace - ? null - : await resolveOrganizeSource(d, { - id: req.sourceConfigId, - paths: files.map((f) => f.path), - mediaType, - instanceId: dt?.instance_id, - tags: dt?.tags ?? undefined, - }); - } catch (error) { - return fail("source_config_mismatch", { message: errMessage(error) }, entity.id); - } - const classification = sourceConfig?.category - ? { category: sourceConfig.category, matchedFields: [], fallback: false } - : sourceConfig - ? await classifyEntity(d, entity) - : undefined; - // Existing bindings remain usable even if metadata cannot currently be fetched. - const hasBinding = await db - .selectFrom("path_binding") - .select("id") - .where("media_entity_id", "=", entity.id) - .where("status", "=", "active") - .executeTakeFirst(); - if (sourceConfig && !classification?.category && !hasBinding && !req.targetDir) { - await decide_({ - kind: "other", - title: `暂时无法分类:${entity.title}`, - summary: "检查 TMDB 连接或选择固定分类;目录扫描会在后续轮次重试,也可手动整理存量", - subjectType: "organize_source", - subjectId: sourceConfig.id, - dedupeKey: `source_classification:${sourceConfig.id}:${entity.id}`, - evidence: { - sourceConfigId: sourceConfig.id, - mediaEntityId: entity.id, - sourcePaths: req.sourcePaths ?? [], - }, - }); - return { - recordId: null, - status: "failed", - reason: "classification_unavailable", - }; - } - const policy = (await publishedAcquisitionPolicies(db))[0]; - let action: "copy" | "move" | "in_place" = - req.action ?? sourceConfig?.action ?? policy?.rules.organizeMode ?? orgSettings.defaultMode; - const site = rel - ? await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst() - : undefined; - const hnr = dt ? hnrSatisfied(dt, rel, site) : true; - let downgradedFromMove: string | undefined; - // 手动整理显式要求 Move 时不降级(未满足由路由 409 + 待处理拦下);只有自动整理按策略预授权改 Copy - if (action === "move" && hnr !== true && req.trigger !== "manual" && policy?.rules.allowCopyWhenHnrPending) { - // 策略预先授权:H&R 未满足时改用 Copy(显式记录,不是静默降级) - action = "copy"; - downgradedFromMove = hnr === false ? "hnr_unsatisfied" : "hnr_unknown"; - } - - // ── 路径绑定与库根 ── - const bindingRows = await db - .selectFrom("path_binding") - .selectAll() - .where("media_entity_id", "=", entity.id) - .where("status", "=", "active") - .execute(); - const bindings = bindingRows.map((r) => toDomainPathBinding(toApi(r))); - const root = inPlace - ? (await db.selectFrom("library_root").selectAll().where("enabled", "=", true).execute()) - .filter( - (r) => - (!req.targetLibraryRootId || r.id === req.targetLibraryRootId) && - files.every((f) => f.path.startsWith(`${r.local_path.replace(/\/+$/, "")}/`)), - ) - .sort((a, b) => b.local_path.length - a.local_path.length)[0] - : await chooseRoot( - db, - entity, - bindings, - req.targetLibraryRootId ?? (sourceConfig && !hasBinding ? sourceConfig.target.root.slice(4) : undefined), - await subscriptionLibraryRoot(db, { slotId, entityId: entity.id }), - ); - if (!root) return fail(inPlace ? "in_place_outside_library" : "no_library_root", {}, entity.id); - const targetDir = inPlace ? undefined : 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") { - const r = await fail( - `library_root_offline:${health.reason}`, - { - libraryRootId: root.id, - }, - entity.id, - ); - await decide_({ - kind: "other", - severity: "critical", - title: `媒体库目录离线:${root.name}`, - summary: "媒体库目录不可访问或未挂载,整理已暂停。文件不会因此被判为缺失", - subjectType: "library_root", - subjectId: root.id, - dedupeKey: `library_root_offline:${root.id}`, - evidence: { reason: health.reason }, - }); - return r; - } - - // ── 命名:模板按 作品 > 库根 > 全局 回落并登记版本;目录只在首次绑定时渲染,文件名每次按当前模板 ── - const namingSettings = await d.ctx.settings.get("naming").catch(() => undefined); - const namingEntity = await loadNamingEntity(db, entity, { - libraryRoot: root.name, - slotId: req.slotId, - }); - if (classification?.category) namingEntity.category = classification.category; - const effective = effectiveNaming(namingSettings, root, entity).templates; - let configuredTemplates = effective; - if (sourceConfig && !req.targetDir && sourceConfig.target.root === `lib:${root.id}`) { - const prefix = [sourceConfig.target.path, ...(sourceConfig.category ? [] : [classification?.category])] - .filter(Boolean) - .join("/"); - if (prefix) { - const literal = `${prefix - .split("/") - .filter(Boolean) - .map((part) => `{{ ${JSON.stringify(part)} }}`) - .join("/")}/`; - configuredTemplates = { - movie: literal + effective.movie, - tv: literal + effective.tv, - variety: literal + effective.variety, - }; - } - } - const templates = await ensureNamingVersion(db, configuredTemplates); - const boundVersion = bindings.find((b) => b.scope === "entity")?.templateVersion; - const fileTemplates = - namingSettings?.lockFileNameForBound && boundVersion - ? ((await namingByVersion(db, boundVersion)) ?? templates) - : templates; - const naming = buildNamingContext(namingEntity); - const facts: FrozenPlan["facts"] = {}; - const seasons = new Set(); - 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] = { - quality: qualityOf(parsed), - 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 }; - const newBindings: NewBinding[] = []; - const target: { movieDir?: string; seasonDirs?: Record } = {}; - const working = [...bindings]; - const decide = (season?: number) => - resolveTargetDirectory({ - entity: { id: entity.id, kind: mediaType }, - naming, - namingKind: namingEntity.kind!, - ...(season !== undefined ? { season } : {}), - bindings: working, - libraryRoot: libRoot, - templates, - }); - const handle = async (dec: ReturnType, season?: number): Promise => { - if (dec.kind === "conflict" || dec.kind === "error") { - const r = await fail(`path_binding_${dec.kind}:${dec.reason}`, { season }, entity.id); - if (dec.kind === "conflict") { - await decide_({ - kind: "path_conflict", - title: `作品存在多个历史目录:${entity.title}`, - summary: "不会按最新标题自动合并。指定要保留的目录", - subjectType: "media_entity", - subjectId: entity.id, - dedupeKey: `path_binding_conflict:${entity.id}:${season ?? "entity"}`, - evidence: { - bindings: dec.bindings.map((b) => ({ - id: b.id, - libraryRootId: b.libraryRootId, - relativePath: b.relativePath, - })), - }, - }); - } - return r; - } - if (dec.kind === "new") { - for (const nb of dec.newBindings) { - if (!newBindings.some((x) => x.scope === nb.scope && x.season === nb.season)) newBindings.push(nb); - working.push({ - id: `pending:${nb.scope}:${nb.season ?? ""}`, - entityId: entity.id, - scope: nb.scope, - ...(nb.season !== undefined ? { season: nb.season } : {}), - libraryRootId: nb.libraryRootId, - relativePath: nb.relativePath, - templateVersion: nb.templateVersion, - active: true, - }); - } - } - if (dec.libraryRootId !== root.id) { - return fail("binding_on_other_root", { libraryRootId: dec.libraryRootId }, entity.id); - } - return join(root.local_path, dec.relativePath); - }; - if (inPlace) { - // 原地入库不渲染目标目录、不建立会影响后续搬运的路径绑定。 - } else 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; - } else { - target.seasonDirs = {}; - for (const s of [...seasons].sort((a, b) => a - b)) { - const r = await handle(decide(s), s); - if (typeof r !== "string") return r; - target.seasonDirs[s] = r; - } - } - - // ── 计划:先算目标,再观察目标是否已存在(失败 ≠ 不存在:stat 出错按存在处理) ── - const intendedScopes = Object.entries(facts).map(([path, f]) => - replacementScope( - path, - mediaType, - f.episodes - ? { - season: f.season, - episodes: Array.from( - { - length: f.episodes.end - f.episodes.start + 1, - }, - (_, i) => f.episodes!.start + i, - ), - } - : undefined, - ), - ); - const replacementPaths = replacement?.assets - .filter((a) => replacementCovers(a.scope, intendedScopes)) - .flatMap((a) => a.files.map((f) => join(f.rootPath, f.path))); - const baseInput = { - operation: action, - mediaType, - ...(sourceRoot ? { sourceRoot } : {}), - files, - entity: namingEntity, - templates: fileTemplates, - target: { - ...target, - ...(health.kind === "online" ? { device: health.dev } : {}), - }, - retention: { hnrSatisfied: hnr }, - minVideoBytes: req.minSizeBytes ?? orgSettings.minSizeBytes ?? d.ports.options.minVideoBytes, - ...(req.fileOverrides ? { overrides: req.fileOverrides } : {}), - ...(d.ctx.config?.ORGANIZE_DEFAULT_SUBTITLE_LANG - ? { defaultSubtitleLang: d.ctx.config.ORGANIZE_DEFAULT_SUBTITLE_LANG } - : {}), - // 目标存在策略只透传给域层规划;执行端按冻结计划的 action(replace/transfer)传 onExisting - onExisting: replacement - ? "replace_with_verify" - : req.importMode || evaluation?.importMode - ? "conflict" - : (req.onExisting ?? d.ctx.config?.ORGANIZE_ON_EXISTING ?? "conflict"), - ...(replacement - ? { replacePaths: replacementPaths, grants: { replace: true } } - : req.grantReplace - ? { grants: { replace: true } } - : {}), - } as const; - const keepNames = req.namingMode === "keep"; - const draft = planOrganize({ ...baseInput, existingTargets: [] }); - // Only fully successful histories count; partial/failed attempts and target conflicts remain independent. - const skippedSuccessful: OrganizePlan["files"] = []; - if (req.skipSuccessfulRecords && files.length) { - const successful = await successfulOrganizeCounts( - db, - files.map((f) => f.path), - ); - for (const f of draft.files) { - if (successful.has(f.mainSrc ?? f.src)) skippedSuccessful.push(f); - } - const excluded = new Set(skippedSuccessful.map((f) => f.src)); - for (let i = files.length - 1; i >= 0; i--) { - if (excluded.has(files[i]!.path)) files.splice(i, 1); - } - } - const skippedPaths = new Set(skippedSuccessful.map((f) => f.src)); - const existing: ExistingTarget[] = []; - for (const f of draft.files) { - if (inPlace || skippedPaths.has(f.src)) continue; - // keep:按保留原名后的真实目标观察存在性,仍以模板目标为键交给域层判定策略,之后统一改名 - const probe = keepNames ? keptDst(f) : f.dst; - const st = await statSafe(probe); - if (st.kind === "missing") continue; - if (st.kind !== "ok") { - existing.push({ path: f.dst, size: -1 }); - continue; - } - const e: ExistingTarget = { path: f.dst, size: st.facts.size }; - if (st.facts.size === f.size) { - // 同尺寸已存在的目标:全量 sha256 判定是否同一文件(子进程,见 adapters sha256Full) - e.sha256 = (await sha256Full(probe)).digest; - const src = files.find((x) => x.path === f.src); - if (src) src.sha256 = (await sha256Full(src.path)).digest; - } - existing.push(e); - } - const plan = planOrganize({ ...baseInput, files, existingTargets: existing }); - if (skippedSuccessful.length && !plan.files.length && !files.some((f) => fileKindOf(f.path) === "video")) { - plan.conflicts = plan.conflicts.filter((c) => c.kind !== "no_main_video"); - plan.requiresApproval = plan.conflicts.some((c) => c.blocking); - } - if (inPlace && plan.files.length) { - const registered = await db - .selectFrom("asset_location as l") - .innerJoin("media_asset as a", "a.id", "l.media_asset_id") - .select(["l.relative_path", "a.media_entity_id"]) - .where("l.library_root_id", "=", root.id) - .where( - "l.relative_path", - "in", - plan.files.map((f) => relative(root.local_path, f.src)), - ) - .where("l.status", "!=", "removed") - .execute(); - for (const file of plan.files) - if ( - registered.some( - (r) => r.relative_path === relative(root.local_path, file.src) && r.media_entity_id !== entity.id, - ) - ) { - file.action = "hold"; - plan.conflicts.push({ - kind: "target_exists", - blocking: true, - src: file.src, - dst: file.dst, - detail: "该文件已登记为其它作品", - }); - plan.requiresApproval = true; - } - } - if (keepNames && !inPlace) keepOriginalNames(plan); - if (req.keepBoth && !inPlace) await keepBothTargets(plan); - const frozen: FrozenPlan = { - version: 1, - ...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}), - ...(skippedSuccessful.length ? { skippedSuccessful } : {}), - ...(replacement ? { replacement } : {}), - ...(importPlanId ? { importPlanId } : {}), - ...(metadataMode ? { metadataMode } : {}), - sourceFacts, - ...(sourceConfig ? { sourceConfigId: sourceConfig.id } : {}), - ...(classification ? { classification } : {}), - ...(req.idempotencyKey ? { idempotencyKey: req.idempotencyKey } : {}), - organize: plan, - entityId: entity.id, - mediaType, - libraryRootId: root.id, - rootPath: root.local_path, - sentinel, - targetDirs: [...new Set(plan.files.map((f) => dirname(f.dst)))], - newBindings, - sourceRoot, - facts, - ...(downgradedFromMove ? { downgradedFromMove } : {}), - ...(req.slotId ? { slotId: req.slotId } : {}), - naming: { - mode: inPlace || keepNames ? "keep" : "template", - // 模板来源按 作品 > 库根 > 全局 回落;保留原文件名 = 本次覆写 - templateSource: - req.namingSource ?? - (inPlace || keepNames ? "override" : effectiveNaming(namingSettings, root, entity).sources[namingEntity.kind!]), - templateVersion: fileTemplates.version, - }, - }; - const drafts = organizePlanToFileResults(plan, { - targetLibraryRootId: root.id, - }); - const blocking = plan.conflicts.filter((c) => c.blocking); - const reason = blocking.length - ? `blocked:${[...new Set(blocking.map((c) => c.kind))].join(",")}` - : downgradedFromMove - ? `move_downgraded_to_copy:${downgradedFromMove}` - : null; - const now = d.ports.now(); - const targetRel = (abs: string | null | undefined) => (abs ? relative(root.local_path, abs) : null); - if (dry) { - return { - recordId: null, - status: blocking.length ? "failed" : "queued", - reason, - plan: frozen, - ...(truncated ? { truncated } : {}), - }; - } - - const result = await 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: entity.id, - content_unit_id: req.contentUnitId ?? null, - download_task_id: dt?.id ?? null, - source_downloader_instance_id: dt?.instance_id ?? null, - source_path_view: "local", - source_path: sourceRoot ?? files[0]?.path ?? null, - target_library_root_id: root.id, - target_path: targetRel(frozen.targetDirs[0]), - action, - status: blocking.length ? "failed" : "running", - reason, - plan: jsonb(frozen), - plan_fingerprint: hashJson(frozen), - file_total: drafts.length, - file_skipped: drafts.filter((f) => f.status === "skipped" || blocking.length > 0).length, - started_at: now, - ...(blocking.length ? { finished_at: now, error: jsonb({ conflicts: blocking }) } : {}), - }) - .returning(["id", "status", "change_seq"]) - .executeTakeFirstOrThrow(); - for (const f of drafts) { - 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: targetRel(f.targetPath), - status: blocking.length ? "skipped" : f.status, - skip_reason: blocking.length && f.status !== "skipped" ? "blocked_by_conflict" : f.skipReason, - }) - .execute(); - } - if (dt) { - await trx - .updateTable("download_task") - .set({ status: blocking.length ? "completed" : "importing" }) - .where("id", "=", dt.id) - .where("status", "in", ["completed", "importing"]) - .execute(); - // 下载历史按作品列出:整理计划定下的作品回填到下载任务(已有的不覆盖) - await trx - .updateTable("download_task") - .set({ media_entity_id: entity.id }) - .where("id", "=", dt.id) - .where("media_entity_id", "is", null) - .execute(); - } - if (blocking.length) { - const hnrConflict = blocking.find((c) => c.kind === "hnr_unsatisfied" || c.kind === "hnr_unknown"); - const existsConflict = blocking.some((c) => c.kind === "target_exists"); - await upsertDecision(d, trx, { - kind: hnrConflict ? "approval_required" : "path_conflict", - title: hnrConflict - ? `做种义务未满足,已阻止移动:${entity.title}` - : `${reasonText(blocking[0]?.kind)?.text ?? "整理冲突"}:${entity.title}`, - summary: blocking - .map((c) => c.detail ?? reasonText(c.kind)?.text ?? c.kind) - .join(";") - .slice(0, 500), - subjectType: "organization_record", - subjectId: rec.id, - dedupeKey: `organize_blocked:${dt?.id ?? rec.id}`, - evidence: { - conflicts: blocking, - action, - downloadTaskId: dt?.id ?? null, - }, - options: hnrConflict - ? [ - { key: "wait", label: "做种满足后再移动" }, - { key: "copy", label: "改用复制" }, - ] - : existsConflict - ? CONFLICT_OPTIONS - : [{ key: "review", label: "指定处理方式" }], - }); - if (slotId) { - await applySlotEvent( - d, - trx, - slotId, - { type: "verify_failed" }, - { - reason: reason ?? "blocked", - }, - ); - } - } - await emitRecord(d, trx, rec.id); - return { recordId: rec.id, status: rec.status, reason }; - }); - return result; -} - -/** W9-H 保留原文件名:目标目录不变,文件名取源文件名 */ -const keptDst = (f: { src: string; dst: string }) => join(dirname(f.dst), basename(f.src)); - -/** W9-H 保留原文件名:改写计划目标;改写后撞名的项转 hold + duplicate_target(阻塞) */ -function keepOriginalNames(plan: OrganizePlan): void { - const renamed = new Map(); - for (const f of plan.files) { - const kept = keptDst(f); - renamed.set(f.dst, kept); - f.dst = kept; - } - for (const c of plan.conflicts) { - if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!; - } - const byDst = new Map(); - for (const f of plan.files) { - byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]); - } - for (const [dst, fs] of byDst) { - if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue; - for (const f of fs) f.action = "hold"; - plan.conflicts.push({ - kind: "duplicate_target", - blocking: true, - dst, - detail: fs.map((f) => f.src).join(" | "), - }); - } - plan.requiresApproval = plan.conflicts.some((c) => c.blocking); -} - -async function writeFailedRecord( - d: AoDeps, - r: { - trigger: OrganizationTrigger; - actorType: ActorType; - actorId: string | null; - taskId: string | null; - downloadTaskId: string | null; - action: "copy" | "move" | "in_place"; - reason: string; - evidence: Record; - entityId: string | null; - sourceInstanceId: string | null; - }, -): Promise { - const now = d.ports.now(); - return d.ctx.db.transaction().execute(async (trx) => { - const rec = await trx - .insertInto("organization_record") - .values({ - task_id: r.taskId, - trigger: r.trigger, - actor_type: r.actorType, - actor_id: r.actorId, - media_entity_id: r.entityId, - download_task_id: r.downloadTaskId, - source_downloader_instance_id: r.sourceInstanceId, - action: r.action, - status: "failed", - reason: r.reason, - error: jsonb(r.evidence), - started_at: now, - finished_at: now, - }) - .returning(["id"]) - .executeTakeFirstOrThrow(); - await emitRecord(d, trx, rec.id); - return { recordId: rec.id, status: "failed" as const, reason: r.reason }; - }); -} - -export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promise { - const r = await exec - .selectFrom("organization_record") - .select(["status", "change_seq", "media_entity_id"]) - .where("id", "=", recordId) - .executeTakeFirstOrThrow(); - await d.ctx.outbox.emit(exec, "organization.changed", { - recordId, - status: r.status, - changeSeq: String(r.change_seq), - mediaEntityId: r.media_entity_id, - }); -} - -// ── 执行 ── - -type StepLog = { - step: string; - status: string; - at: string; - detail?: string; - error?: { code: string; message: string }; -}; -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[]; -}; -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 { - 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(); - /** 本轮 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( - 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///<原相对路径> 并写 trash_item(与 file-manager/trash.ts moveToTrash 同构, - * 还原/清除按 original_path 反推 uuid 目录,布局必须一致)。 - */ -async function trashReplacedOld( - d: AoDeps, - fsr: FsRuntime, - frozen: FrozenPlan, - dst: string, - ref: { recordId: string; opId: string }, - old: { path?: string; size: number }, -): Promise { - 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 内抛出,账本不落结果 */ -class RetryableTransfer extends Error { - constructor( - readonly error: { code: string; message: string }, - readonly attempt: number, - ) { - super(`${error.code}: ${error.message}`); - } -} - -interface FsRuntime { - proxy: FsProxy; - partialMaxAgeMs: number; - maxAttempts: number; - replaceOld: ReplaceOldPolicy["mode"]; -} -const fsRuntimes = new WeakMap(); - -/** 按 server 配置(FS_*)建一次 fsproxy,随 ctx.config 缓存 */ -export function fsRuntime(d: AoDeps): FsRuntime { - const c = (d.ctx.config ?? {}) as Partial; - 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) */ -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 { - 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 }; -} - -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 { - 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) }; - } -} - -/** 登记资产/位置/绑定 + Slot/计划/下载任务 + 记录终态,同一事务;记录行锁作 fencing */ -export async function finalizeRecord(d: AoDeps, recordId: string): Promise { - 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(); - 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(); - 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) */ -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,挂在季单元下) */ -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; -} - -// ── 公开记录映射 ── - -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("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), - }; - }); -} +export type { ExecuteResult } from "./organize-exec.ts"; +export { executeRecord, fsRuntime, verifyInPlace } from "./organize-exec.ts"; +export { finalizeRecord } from "./organize-finalize.ts"; +export { emitRecord, planImport } from "./organize-plan.ts"; +export { loadRecordsPublic } from "./organize-records.ts"; +export type { + FileOverrideInput, + FrozenNaming, + FrozenPlan, + ImportRequest, + NamingMode, + NamingSourceTag, + PlanResult, +} from "./organize-types.ts"; diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts index ac902b3d..e0769e85 100644 --- a/apps/server/src/modules/browser/service.ts +++ b/apps/server/src/modules/browser/service.ts @@ -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; -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 = { - 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; - } -} diff --git a/apps/server/src/modules/browser/session-view.ts b/apps/server/src/modules/browser/session-view.ts new file mode 100644 index 00000000..44e82985 --- /dev/null +++ b/apps/server/src/modules/browser/session-view.ts @@ -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; +export const obj = (v: unknown): Json => (v && typeof v === "object" && !Array.isArray(v) ? (v as Json) : {}); + +export const REASON_TEXT: Record = { + 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; + } +} diff --git a/apps/web/app/components/organize-wizard/form-select.tsx b/apps/web/app/components/organize-wizard/form-select.tsx new file mode 100644 index 00000000..278ca188 --- /dev/null +++ b/apps/web/app/components/organize-wizard/form-select.tsx @@ -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 ( + + ); +} diff --git a/apps/web/app/components/organize-wizard/organize-wizard.tsx b/apps/web/app/components/organize-wizard/organize-wizard.tsx index 725db6ff..c67d0b43 100644 --- a/apps/web/app/components/organize-wizard/organize-wizard.tsx +++ b/apps/web/app/components/organize-wizard/organize-wizard.tsx @@ -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>("/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(); - 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 ( - - ); -} +export type { OrganizeSource } from "./source.ts"; /** 所有入口共用一个表单;预览按需展开,提交仅加入现有后台队列。 */ export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; onClose: () => void }) { diff --git a/apps/web/app/components/organize-wizard/source.ts b/apps/web/app/components/organize-wizard/source.ts new file mode 100644 index 00000000..3f1dcea1 --- /dev/null +++ b/apps/web/app/components/organize-wizard/source.ts @@ -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>("/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(); + 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, + ]); +} diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index c952b3ff..013f2106 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1,2713 +1,47 @@ -import { z } from "zod"; -import * as agent from "./agent.ts"; -import * as assets from "./assets.ts"; -import * as auth from "./auth.ts"; -import * as browser from "./browser.ts"; -import * as cache from "./cache.ts"; -import { MetadataEnrichOutput, WebSearchTestInput, WebSearchTestResult } from "./capabilities-io.ts"; -import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts"; -import * as classification from "./classification.ts"; -import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts"; -import * as content from "./content.ts"; -import * as board from "./download-board.ts"; -import * as events from "./events.ts"; -import * as execution from "./execution.ts"; -import * as files from "./files.ts"; -import * as identity from "./identity.ts"; -import * as instances from "./instances.ts"; -import * as libraryVersions from "./library-versions.ts"; -import * as mcp from "./mcp.ts"; -import * as ops from "./ops.ts"; -import * as organization from "./organization.ts"; -import * as organizeSources from "./organize-sources.ts"; -import * as pi from "./pi.ts"; -import * as policy from "./policy.ts"; -import * as schedules from "./schedules.ts"; -import * as scrape from "./scrape.ts"; -import * as search from "./search.ts"; -import * as settingsNs from "./settings.ts"; -import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts"; -import * as sources from "./sources.ts"; +import type { z } from "zod"; +import { browserApi } from "./api/browser.ts"; +import { contentApi } from "./api/content.ts"; +import { executionApi } from "./api/execution.ts"; +import { instancesApi } from "./api/instances.ts"; +import { libraryApi } from "./api/library.ts"; +import { opsApi } from "./api/ops.ts"; +import { organizeApi } from "./api/organize.ts"; +import { platformApi } from "./api/platform.ts"; +import { reviewApi } from "./api/review.ts"; +import { rulesApi } from "./api/rules.ts"; +import { schedulesApi } from "./api/schedules.ts"; +import { API_PREFIX, type EndpointDef } from "./api/shared.ts"; +import { sourcesApi } from "./api/sources.ts"; +import { systemApi } from "./api/system.ts"; +export type { EndpointAuth, EndpointDef, HttpMethod } from "./api/shared.ts"; +export { + API_PREFIX, + DecisionSummary, + DecisionSummaryGroup, + IdentityRevisionList, + OkResult, + ReleaseIdentityResult, + TaskAccepted, +} from "./api/shared.ts"; export * from "./search.ts"; -// 所有 REST 端点的路径常量与请求/响应 schema 汇总。 -// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用); -// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。 - -export const API_PREFIX = "/api/v1"; - -export type HttpMethod = "get" | "post" | "patch" | "put" | "delete"; - -/** - * 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用): - * - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言); - * - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。 - */ -export type EndpointAuth = "public" | "session"; - -export interface EndpointDef { - method: HttpMethod; - path: string; - scope: ApiScope; - auth?: EndpointAuth; - summary: string; - params?: z.ZodObject; - query?: z.ZodType; - body?: z.ZodType; - response: z.ZodType; -} - -const ep = (e: E): E => e; - -/** 列表查询:游标分页 + 资源自有过滤 */ -const listQuery = (filters: S) => CursorQuery.extend(filters); - -/** 删除/无内容动作的响应 */ -export const OkResult = z.object({ ok: z.literal(true) }); - -interface CrudSchemas { - row: z.ZodType; - create?: z.ZodType; - update?: z.ZodType; - filters?: z.ZodRawShape; -} - -/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */ -function crud( - base: B, - name: string, - s: CrudSchemas, - scopes: { read: ApiScope; write: ApiScope }, -) { - const item = `${base}/{id}` as const; - return { - list: ep({ - method: "get", - path: base, - scope: scopes.read, - summary: `${name}列表`, - query: listQuery(s.filters ?? {}), - response: cursorPage(s.row), - }), - get: ep({ - method: "get", - path: item, - scope: scopes.read, - summary: `${name}详情`, - params: IdParam, - response: s.row, - }), - ...(s.create - ? { - create: ep({ - method: "post", - path: base, - scope: scopes.write, - summary: `新建${name}`, - body: s.create, - response: s.row, - }), - } - : {}), - ...(s.update - ? { - update: ep({ - method: "patch", - path: item, - scope: scopes.write, - summary: `修改${name}`, - params: IdParam, - body: s.update, - response: s.row, - }), - } - : {}), - }; -} - -const R = { read: "read", write: "admin" } as const; - -const del = (path: string, summary: string) => - ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult }); - -/** 实例连通性测试 */ -const instanceTest = (path: string, summary: string) => - ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult }); - -/** 入队后台任务的受理结果(taskId = 引擎 run id) */ -export const TaskAccepted = z.object({ taskId: Id }); -export type TaskAccepted = z.infer; - -/** 人工指定来源资源身份的结果(待识别池 assign / source-releases identity) */ -export const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }); -export type ReleaseIdentityResult = z.infer; - -/** 身份修订历史 */ -export const IdentityRevisionList = z.object({ items: z.array(identity.IdentityRevision) }); -export type IdentityRevisionList = z.infer; - -/** 字段覆盖/取消覆盖的结果 */ -const FieldOverrideResult = z.object({ - mediaEntityId: Id, - changedFields: z.array(z.string()), - lockedFields: z.array(z.string()), -}); -const FieldParam = z.object({ id: Id, field: content.EntityMetadataField }); -const UnitFieldParam = z.object({ id: Id, field: content.UnitMetadataField }); -const UnitFieldOverrideResult = z.object({ - contentUnitId: Id, - changedFields: z.array(z.string()), - lockedFields: z.array(z.string()), -}); - -/** 引擎 run id 不是 uuid(openworkflow 自有 id) */ -const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) }); - -/** 待处理事项聚合分组 */ -export const DecisionSummaryGroup = z.object({ - kind: agent.DecisionKind, - mediaEntityId: Id.nullable(), - mediaEntityTitle: z.string().nullable(), - count: z.number().int(), - maxSeverity: z.enum(["info", "warning", "critical"]), - latestAt: z.string(), -}); -export type DecisionSummaryGroup = z.infer; -export const DecisionSummary = z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }); -export type DecisionSummary = z.infer; - +// 所有 REST 端点的路径常量与请求/响应 schema 汇总,按领域分在 ./api/*.ts。 +// 合并顺序即路由注册顺序(静态路径须先于同前缀的 {id} 路径),调整分组顺序前先看 server 的路由注册。 export const Api = { - // ── 内容 / 身份 ── - mediaEntities: { - ...crud( - "/media-entities", - "作品", - { - row: content.MediaEntity, - create: content.MediaEntityCreate, - update: content.MediaEntityUpdate, - filters: { - kind: content.MediaKind.optional(), - status: content.MediaEntityStatus.optional(), - q: z.string().max(200).optional(), - }, - }, - R, - ), - merge: ep({ - method: "post", - path: "/media-entities/{id}/merge", - scope: "admin", - summary: "合并重复作品(保留旧 ID 跳转)", - params: IdParam, - body: z.object({ intoId: Id, reason: z.string().max(2000) }), - response: content.MediaEntity, - }), - detail: ep({ - method: "get", - path: "/media-entities/{id}/detail", - scope: "read", - summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)", - params: IdParam, - response: content.MediaEntityDetail, - }), - setIdentity: ep({ - method: "post", - path: "/media-entities/{id}/identities", - scope: "organize.execute", - summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁", - params: IdParam, - body: identity.EntityIdentitySet, - response: z.object({ - mediaEntityId: Id, - changed: z.boolean(), - identityId: Id.optional(), - retired: z.array(Id).optional(), - }), - }), - refresh: ep({ - method: "post", - path: "/media-entities/{id}/refresh", - scope: "organize.execute", - summary: - "刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息", - params: IdParam, - query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }), - response: TaskAccepted, - }), - setField: ep({ - method: "put", - path: "/media-entities/{id}/fields/{field}", - scope: "organize.execute", - summary: "覆盖作品字段并锁定(自动刷新不再改写)", - params: FieldParam, - body: content.FieldOverrideInput, - response: FieldOverrideResult, - }), - clearField: ep({ - method: "delete", - path: "/media-entities/{id}/fields/{field}", - scope: "organize.execute", - summary: "取消字段覆盖与锁定", - params: FieldParam, - response: FieldOverrideResult, - }), - }, - contentUnits: { - ...crud( - "/content-units", - "内容单元", - { - row: content.ContentUnit, - update: content.ContentUnitUpdate, - filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, - }, - R, - ), - setField: ep({ - method: "put", - path: "/content-units/{id}/fields/{field}", - scope: "organize.execute", - summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)", - params: UnitFieldParam, - body: content.FieldOverrideInput, - response: UnitFieldOverrideResult, - }), - clearField: ep({ - method: "delete", - path: "/content-units/{id}/fields/{field}", - scope: "organize.execute", - summary: "取消季 / 集字段覆盖与锁定", - params: UnitFieldParam, - response: UnitFieldOverrideResult, - }), - }, - titleAliases: { - ...crud( - "/title-aliases", - "别名", - { - row: content.TitleAlias, - create: content.TitleAliasCreate, - filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() }, - }, - R, - ), - remove: del("/title-aliases/{id}", "删除别名"), - }, - externalIdentities: { - ...crud( - "/external-identities", - "外部身份", - { - row: identity.ExternalIdentity, - create: identity.ExternalIdentityCreate, - update: identity.ExternalIdentityUpdate, - filters: { - mediaEntityId: Id.optional(), - provider: z.string().optional(), - status: identity.IdentityStatus.optional(), - }, - }, - R, - ), - revisions: ep({ - method: "get", - path: "/external-identities/{id}/revisions", - scope: "read", - summary: "身份修订历史", - params: IdParam, - response: IdentityRevisionList, - }), - }, - - // ── 文件(只读;写入只经整理/对账流程,不开放直接改) ── - mediaAssets: crud( - "/media-assets", - "资产", - { - row: assets.MediaAsset, - filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() }, - }, - R, - ), - assetUnitCoverage: crud( - "/asset-unit-coverage", - "资产覆盖", - { - row: assets.AssetUnitCoverage, - filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() }, - }, - R, - ), - assetLocations: crud( - "/asset-locations", - "资产位置", - { - row: assets.AssetLocation, - filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() }, - }, - R, - ), - pathBindings: crud( - "/path-bindings", - "路径绑定", - { - row: assets.PathBinding, - filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() }, - }, - R, - ), - - // ── 实例 ── - libraryRoots: { - ...crud( - "/library-roots", - "库根", - { - row: instances.LibraryRoot, - create: instances.LibraryRootCreate, - update: instances.LibraryRootUpdate, - }, - R, - ), - test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"), - remove: del("/library-roots/{id}", "删除库根"), - }, - downloaderInstances: { - ...crud( - "/downloader-instances", - "下载器实例", - { - row: instances.DownloaderInstance, - create: instances.DownloaderInstanceCreate, - update: instances.DownloaderInstanceUpdate, - }, - R, - ), - test: ep({ - method: "post", - path: "/downloader-instances/{id}/test", - scope: "admin", - summary: "测试下载器连接", - params: IdParam, - response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }), - }), - remove: del("/downloader-instances/{id}", "删除下载器实例"), - }, - mediaServerInstances: { - ...crud( - "/media-server-instances", - "媒体服务器实例", - { - row: instances.MediaServerInstance, - create: instances.MediaServerInstanceCreate, - update: instances.MediaServerInstanceUpdate, - }, - R, - ), - test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"), - remove: del("/media-server-instances/{id}", "删除媒体服务器实例"), - }, - pathMappings: { - ...crud( - "/path-mappings", - "路径映射", - { - row: instances.PathMapping, - create: instances.PathMappingCreate, - update: instances.PathMappingUpdate, - }, - R, - ), - verify: ep({ - method: "post", - path: "/path-mappings/{id}/verify", - scope: "admin", - summary: "校验路径映射可读写与挂载健康", - params: IdParam, - response: instances.PathMapping, - }), - remove: del("/path-mappings/{id}", "删除路径映射"), - }, - - // ── 来源 ── - sites: { - ...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R), - list: ep({ - method: "get", - path: "/sites", - scope: "read", - summary: "站点列表(include=accounts 附带账号与整体账号状态)", - query: sources.SiteListQuery, - response: cursorPage(sources.SiteListItem), - }), - remove: del("/sites/{id}", "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)"), - healthCheck: ep({ - method: "post", - path: "/sites/{id}/health-check", - scope: "admin", - summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列", - params: IdParam, - response: sources.SiteHealthResult, - }), - refreshLogin: ep({ - method: "post", - path: "/sites/{id}/refresh-login", - scope: "admin", - summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据", - params: IdParam, - body: sources.SiteRefreshLoginInput, - response: sources.SiteRefreshLoginAccepted, - }), - userData: ep({ - method: "get", - path: "/sites/{id}/user-data", - scope: "read", - summary: "站点账号用户数据按天历史", - params: IdParam, - query: sources.SiteUserDataQuery, - response: z.object({ items: z.array(sources.SiteUserDataPoint) }), - }), - enable: ep({ - method: "post", - path: "/sites/{id}/enable", - scope: "admin", - summary: "开启站点", - params: IdParam, - response: sources.Site, - }), - disable: ep({ - method: "post", - path: "/sites/{id}/disable", - scope: "admin", - summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)", - params: IdParam, - response: sources.Site, - }), - snapshot: ep({ - method: "post", - path: "/sites/{id}/snapshot", - scope: "admin", - summary: "立即同步站点快照(受站点限流)", - params: IdParam, - body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }), - response: TaskAccepted, - }), - snapshots: ep({ - method: "get", - path: "/sites/{id}/snapshots", - scope: "read", - summary: "站点快照/补查记录(覆盖范围与新鲜度)", - params: IdParam, - query: CursorQuery, - response: cursorPage(sources.SiteSnapshot), - }), - requestStats: ep({ - method: "get", - path: "/sites/{id}/request-stats", - scope: "read", - summary: "站点请求统计:最近 24 小时对该站发出的请求次数(按小时)与生效的访问默认", - params: IdParam, - response: sources.SiteRequestStats, - }), - credentials: ep({ - method: "put", - path: "/sites/{id}/credentials", - scope: "admin", - summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)", - params: IdParam, - body: instances.SiteCredentialsPut, - response: instances.SiteCredentialsResult, - }), - }, - siteDefinitions: { - list: ep({ - method: "get", - path: "/site-definitions", - scope: "read", - summary: "随产品发布的站点适配器定义(含是否已登记)", - response: z.object({ items: z.array(sources.SiteDefinitionView) }), - }), - }, - /** 站点工作台(整理视图):库存叠加走 inventory.overlay */ - siteWorkbench: { - releases: ep({ - method: "get", - path: "/sites/{id}/releases", - scope: "read", - summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", - params: IdParam, - query: sources.WorkbenchReleaseQuery, - response: sources.WorkbenchReleasePage, - }), - release: ep({ - method: "get", - path: "/sites/{id}/releases/{rid}", - scope: "read", - summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)", - params: z.object({ id: Id, rid: Id }), - query: z.object({ refresh: z.enum(["true", "false"]).optional() }), - response: sources.WorkbenchReleaseDetail, - }), - }, - siteAccounts: { - ...crud( - "/site-accounts", - "站点账号", - { - row: sources.SiteAccount, - create: sources.SiteAccountCreate, - update: sources.SiteAccountUpdate, - filters: { siteId: Id.optional() }, - }, - R, - ), - remove: del("/site-accounts/{id}", "删除站点账号与其凭证"), - }, - feedSources: { - ...crud( - "/feed-sources", - "Feed", - { - row: sources.FeedSource, - create: sources.FeedSourceCreate, - update: sources.FeedSourceUpdate, - filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() }, - }, - R, - ), - fetchNow: ep({ - method: "post", - path: "/feed-sources/{id}/fetch", - scope: "admin", - summary: "立即抓取(仍受站点限流)", - params: IdParam, - response: z.object({ taskId: Id }), - }), - enable: ep({ - method: "post", - path: "/feed-sources/{id}/enable", - scope: "admin", - summary: "启用 Feed", - params: IdParam, - response: sources.FeedSource, - }), - disable: ep({ - method: "post", - path: "/feed-sources/{id}/disable", - scope: "admin", - summary: "停用 Feed", - params: IdParam, - response: sources.FeedSource, - }), - reset: ep({ - method: "post", - path: "/feed-sources/{id}/reset", - scope: "admin", - summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)", - params: IdParam, - response: sources.FeedSource, - }), - }, - sourceReleases: { - ...crud( - "/source-releases", - "资源", - { - row: sources.SourceRelease, - filters: { - siteId: Id.optional(), - mediaEntityId: Id.optional(), - resolutionStatus: sources.ResolutionStatus.optional(), - q: z.string().max(200).optional(), - }, - }, - R, - ), - assignIdentity: ep({ - method: "post", - path: "/source-releases/{id}/identity", - scope: "organize.execute", - summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)", - params: IdParam, - body: identity.ReleaseIdentityAssign, - response: ReleaseIdentityResult, - }), - unlock: ep({ - method: "post", - path: "/source-releases/{id}/unlock", - scope: "organize.execute", - summary: "解除资源的人工锁并重新识别", - params: IdParam, - response: TaskAccepted, - }), - }, - observations: crud( - "/observations", - "观察", - { - row: sources.Observation, - filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() }, - }, - R, - ), - - // ── 规则 ── - subscriptions: { - ...crud( - "/subscriptions", - "订阅", - { - row: policy.Subscription, - create: policy.SubscriptionCreate, - update: policy.SubscriptionUpdate, - filters: { - status: policy.SubscriptionStatus.optional(), - mediaEntityId: Id.optional(), - mediaKind: policy.SubscriptionMediaKind.optional(), - /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ - q: z.string().max(200).optional(), - }, - }, - R, - ), - /** 日历(静态路径,须先于 /subscriptions/{id} 注册) */ - calendar: ep({ - method: "get", - path: "/subscriptions/calendar", - scope: "read", - summary: - "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", - query: z.object({ - from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - mediaKind: policy.SubscriptionMediaKind.optional(), - }), - response: policy.SubscriptionCalendar, - }), - reset: ep({ - method: "post", - path: "/subscriptions/{id}/reset", - scope: "admin", - summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留", - params: IdParam, - response: policy.SubscriptionResetResult, - }), - /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ - list: ep({ - method: "get", - path: "/subscriptions", - scope: "read", - summary: - "订阅总览(分组、进度、各版本摘要、上次更新、下次搜索、站点、规则组;cancelled 只在显式 status=cancelled 时返回)", - query: listQuery({ - status: policy.SubscriptionStatus.optional(), - mediaEntityId: Id.optional(), - mediaKind: policy.SubscriptionMediaKind.optional(), - /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ - q: z.string().max(200).optional(), - group: policy.SubscriptionGroup.optional(), - sort: policy.SubscriptionSort.optional(), - order: z.enum(["asc", "desc"]).optional(), - siteId: Id.optional(), - ruleGroupId: Id.optional(), - origin: policy.SubscriptionOrigin.optional(), - upgrading: z.enum(["true", "false"]).optional(), - }), - response: policy.SubscriptionOverviewPage, - }), - coverage: ep({ - method: "get", - path: "/subscriptions/{id}/coverage", - scope: "read", - summary: - "覆盖矩阵:季 → 集 × 收录版本(电影:内容版本 × 收录版本),每格 have/inflight/missing/skipped;缓存 30s,refresh=true 强制重算", - params: IdParam, - query: z.object({ refresh: z.enum(["true", "false"]).optional() }), - response: policy.SubscriptionCoverage, - }), - pause: ep({ - method: "post", - path: "/subscriptions/{id}/pause", - scope: "admin", - summary: "暂停订阅(不评估、不搜索)", - params: IdParam, - response: policy.Subscription, - }), - resume: ep({ - method: "post", - path: "/subscriptions/{id}/resume", - scope: "admin", - summary: "恢复已暂停的订阅(TMDB 未就绪回到 pending),立即重评并排上搜索", - params: IdParam, - response: policy.Subscription, - }), - resubscribe: ep({ - method: "post", - path: "/subscriptions/{id}/resubscribe", - scope: "admin", - summary: "重新订阅已完成/已取消的订阅(历史保留),立即重评并搜索", - params: IdParam, - response: policy.Subscription, - }), - batch: ep({ - method: "post", - path: "/subscriptions/batch", - scope: "admin", - summary: "批量:立即搜索 / 暂停 / 恢复 / 删除(删除 = 取消,写审计)", - body: policy.SubscriptionBatch, - response: policy.SubscriptionBatchResult, - }), - fillSlot: ep({ - method: "post", - path: "/subscriptions/{id}/slots/{slotId}/fill", - scope: "admin", - summary: "手动补全某收录版本(不受自动补全开关与补全预算限制)", - params: z.object({ id: Id, slotId: Id }), - body: policy.SlotFillRequest, - response: z.object({ runId: z.string(), deduplicated: z.boolean() }), - }), - slotOrder: ep({ - method: "put", - path: "/subscriptions/{id}/slot-order", - scope: "admin", - summary: "收录版本排序(第一个为主版本)", - params: IdParam, - body: policy.SlotOrderSet, - response: z.array(policy.AcquisitionSlot), - }), - search: ep({ - method: "post", - path: "/subscriptions/{id}/search", - scope: "admin", - summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", - params: IdParam, - response: z.object({ runId: z.string(), deduplicated: z.boolean() }), - }), - progress: ep({ - method: "get", - path: "/subscriptions/{id}/progress", - scope: "read", - summary: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、收录版本在途与延后", - params: IdParam, - response: policy.SubscriptionProgress, - }), - setEpisodeTotal: ep({ - method: "post", - path: "/subscriptions/{id}/episode-total", - scope: "admin", - summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", - params: IdParam, - body: policy.EpisodeTotalSet, - response: policy.SubscriptionProgress, - }), - }, - subscriptionHistory: crud( - "/subscription-history", - "订阅完成历史", - { - row: policy.SubscriptionHistory, - filters: { mediaEntityId: Id.optional(), subscriptionId: Id.optional() }, - }, - R, - ), - policyRevisions: { - ...crud( - "/policy-revisions", - "策略版本", - { - row: policy.PolicyRevision, - create: policy.PolicyRevisionCreate, - update: policy.PolicyRevisionUpdate, - filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }, - }, - R, - ), - preview: ep({ - method: "post", - path: "/policy-revisions/{id}/preview", - scope: "read", - summary: "历史回放预览", - params: IdParam, - response: policy.PolicyRevision, - }), - publish: ep({ - method: "post", - path: "/policy-revisions/{id}/publish", - scope: "admin", - summary: "发布策略版本", - params: IdParam, - body: z.object({ expectedRevision: z.number().int().min(1) }), - response: policy.PolicyRevision, - }), - rollback: ep({ - method: "post", - path: "/policy-revisions/{id}/rollback", - scope: "admin", - summary: "回滚到该版本", - params: IdParam, - response: policy.PolicyRevision, - }), - }, - acquisitionSlots: crud( - "/acquisition-slots", - "收录版本", - { - row: policy.AcquisitionSlot, - create: policy.AcquisitionSlotCreate, - update: policy.AcquisitionSlotUpdate, - filters: { - mediaEntityId: Id.optional(), - status: policy.SlotStatus.optional(), - subscriptionId: Id.optional(), - }, - }, - R, - ), - preferenceTags: { - ...crud( - "/preference-tags", - "偏好标签", - { - row: policy.PreferenceTag, - create: policy.PreferenceTagCreate, - update: policy.PreferenceTagUpdate, - filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() }, - }, - R, - ), - /** 删除 = 标记 deleted + 写抑制记录 */ - remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"), - revisions: ep({ - method: "get", - path: "/preference-tags/{id}/revisions", - scope: "read", - summary: "偏好标签修订历史(新→旧;取自审计事件)", - params: IdParam, - response: policy.PreferenceTagRevisionList, - }), - }, - preferenceSuppressions: crud( - "/preference-suppressions", - "偏好抑制", - { - row: policy.PreferenceSuppression, - create: policy.PreferenceSuppressionCreate, - update: policy.PreferenceSuppressionUpdate, - filters: { active: z.enum(["true", "false"]).optional() }, - }, - R, - ), - - // ── 规则(W9-D):规则组 = 收录过滤条件 + 偏好标签合一。静态路径先于 /rules/{id} 注册 ── - rules: { - keywords: ep({ - method: "get", - path: "/rules/keywords", - scope: "read", - summary: "关键字表:内置(对齐 MP,含判定依据与上游正则)+ 自定义条件", - response: policy.RuleKeywordList, - }), - parse: ep({ - method: "post", - path: "/rules/parse", - scope: "read", - summary: "规则串 → 结构化层(`>` 分层,层内!& | 与括号);错误逐条给位置与相近名", - body: policy.RuleParseRequest, - response: policy.RuleParseResult, - }), - serialize: ep({ - method: "post", - path: "/rules/serialize", - scope: "read", - summary: "结构化层 → 规范化规则串", - body: policy.RuleSerializeRequest, - response: policy.RuleSerializeResult, - }), - defaults: ep({ - method: "get", - path: "/rules/defaults", - scope: "read", - summary: "三套默认规则组链:订阅 / 洗版 / 搜索", - response: policy.RuleDefaults, - }), - setDefaults: ep({ - method: "put", - path: "/rules/defaults", - scope: "admin", - summary: "设置默认规则组链(按数组顺序串联,pri 取最后一个带层的组)", - body: policy.RuleDefaults, - response: policy.RuleDefaults, - }), - ...crud( - "/rules", - "规则组", - { - row: policy.RuleGroup, - create: policy.RuleGroupCreate, - update: policy.RuleGroupUpdate, - filters: { - kind: policy.RuleGroupKind.optional(), - source: policy.RuleGroupSource.optional(), - enabled: z.enum(["true", "false"]).optional(), - includeDeleted: z.enum(["true", "false"]).optional(), - q: z.string().max(80).optional(), - }, - }, - R, - ), - /** 删除 = 标记 deleted_at + 写抑制记录 */ - remove: del("/rules/{id}", "删除规则组(留抑制记录;被引用时 409)"), - revisions: ep({ - method: "get", - path: "/rules/{id}/revisions", - scope: "read", - summary: "规则组修订历史(新→旧;取自审计事件)", - params: IdParam, - response: policy.RuleGroupRevisionList, - }), - preview: ep({ - method: "post", - path: "/rules/{id}/preview", - scope: "read", - summary: "试算:对最近 N 条已识别资源(或指定资源/标题)跑本组,给 pri、命中层与拒绝原因", - params: IdParam, - body: policy.RulePreviewRequest, - response: policy.RulePreviewResult, - }), - }, - ruleAtoms: { - ...crud( - "/rules/atoms", - "自定义条件", - { row: policy.RuleAtom, create: policy.RuleAtomCreate, update: policy.RuleAtomUpdate }, - R, - ), - remove: del("/rules/atoms/{id}", "删除自定义条件(被规则组引用时 409)"), - }, - /** /policy-revisions 的新路径(旧路径保留为别名,见 docs/runbook/rules.md) */ - rulePolicies: { - list: ep({ - method: "get", - path: "/rules/policies", - scope: "read", - summary: "收录策略版本列表", - query: listQuery({ policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }), - response: cursorPage(policy.PolicyRevision), - }), - get: ep({ - method: "get", - path: "/rules/policies/{id}", - scope: "read", - summary: "收录策略版本", - params: IdParam, - response: policy.PolicyRevision, - }), - create: ep({ - method: "post", - path: "/rules/policies", - scope: "admin", - summary: "新建策略草案", - body: policy.PolicyRevisionCreate, - response: policy.PolicyRevision, - }), - update: ep({ - method: "patch", - path: "/rules/policies/{id}", - scope: "admin", - summary: "修改策略草案", - params: IdParam, - body: policy.PolicyRevisionUpdate, - response: policy.PolicyRevision, - }), - preview: ep({ - method: "post", - path: "/rules/policies/{id}/preview", - scope: "read", - summary: "预览策略草案", - params: IdParam, - response: policy.PolicyRevision, - }), - publish: ep({ - method: "post", - path: "/rules/policies/{id}/publish", - scope: "admin", - summary: "发布策略版本", - params: IdParam, - body: z.object({ expectedRevision: z.number().int().min(1) }), - response: policy.PolicyRevision, - }), - rollback: ep({ - method: "post", - path: "/rules/policies/{id}/rollback", - scope: "admin", - summary: "回滚到该策略版本", - params: IdParam, - response: policy.PolicyRevision, - }), - }, - /** /preference-suppressions 的新路径(旧路径保留为别名) */ - ruleSuppressions: crud( - "/rules/suppressions", - "规则抑制记录", - { - row: policy.PreferenceSuppression, - create: policy.PreferenceSuppressionCreate, - update: policy.PreferenceSuppressionUpdate, - filters: { active: z.enum(["true", "false"]).optional() }, - }, - R, - ), - - // ── 执行 ── - tasks: { - ...crud( - "/tasks", - "任务", - { - row: execution.Task, - filters: { - status: execution.TaskStatus.optional(), - kind: execution.TaskKind.optional(), - subjectId: Id.optional(), - }, - }, - R, - ), - cancel: ep({ - method: "post", - path: "/tasks/{id}/cancel", - scope: "organize.execute", - summary: "取消任务(停止未来步骤)", - params: IdParam, - body: execution.TaskCancelRequest, - response: execution.Task, - }), - retry: ep({ - method: "post", - path: "/tasks/{id}/retry", - scope: "organize.execute", - summary: "重试失败任务", - params: IdParam, - response: execution.Task, - }), - operations: ep({ - method: "get", - path: "/tasks/{id}/operations", - scope: "read", - summary: "任务的操作账本(业务时间线)", - params: IdParam, - response: z.object({ items: z.array(execution.AssetOperation) }), - }), - }, - acquisitionPlans: { - ...crud( - "/acquisition-plans", - "下载计划", - { - row: execution.AcquisitionPlan, - filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() }, - }, - R, - ), - create: ep({ - method: "post", - path: "/acquisition-plans", - scope: "organize.execute", - summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常或洗版入库", - body: execution.ManualAcquisitionRequest, - response: execution.ManualAcquisitionResult, - }), - why: ep({ - method: "get", - path: "/acquisition-plans/{id}/why", - scope: "read", - summary: - "下载的来由:来源(订阅 / 智能体会话 / 手动)、目的(补缺 / 升级 / 换源)、被替换的版本、选中理由要点与原始评估", - params: IdParam, - response: board.DownloadWhy, - }), - cancel: ep({ - method: "post", - path: "/acquisition-plans/{id}/cancel", - scope: "organize.execute", - summary: - "取消还没提交到下载器的计划(待批准 / 已预留):释放收录版本与预算预留;已有下载任务的返回 409。待批准的计划请拒绝审批(拒绝后计划自动取消)", - params: IdParam, - response: execution.AcquisitionPlan, - }), - }, - downloadTasks: { - ...crud( - "/download-tasks", - "下载任务", - { - row: execution.DownloadTask, - filters: { - instanceId: Id.optional(), - status: execution.DownloadStatus.optional(), - managed: z.enum(["true", "false"]).optional(), - }, - }, - R, - ), - board: ep({ - method: "get", - path: "/download-tasks/board", - scope: "read", - summary: - "下载面板:按进行中 / 已完成分组列出下载任务(进行中含还没提交、在等批准或下载盘空间的计划,kind=plan),附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", - query: board.DownloadBoardQuery, - response: board.DownloadBoard, - }), - pause: ep({ - method: "post", - path: "/download-tasks/{id}/pause", - scope: "organize.execute", - summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)", - params: IdParam, - response: execution.DownloadTask, - }), - resume: ep({ - method: "post", - path: "/download-tasks/{id}/resume", - scope: "organize.execute", - summary: "恢复下载", - params: IdParam, - response: execution.DownloadTask, - }), - }, - - // ── 人工 / AI ── - decisions: { - ...crud( - "/decisions", - "待处理事项", - { - row: agent.Decision, - filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() }, - }, - R, - ), - resolve: ep({ - method: "post", - path: "/decisions/{id}/resolve", - scope: "organize.execute", - summary: "处理待处理事项", - params: IdParam, - body: agent.DecisionResolveInput, - response: agent.Decision, - }), - summary: ep({ - method: "get", - path: "/decisions/summary", - scope: "read", - summary: "未关闭待处理事项按作品与问题类型聚合", - query: z.object({ status: agent.DecisionStatus.optional() }), - response: DecisionSummary, - }), - }, - approvals: { - ...crud( - "/approvals", - "审批", - { - row: agent.Approval, - filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() }, - }, - R, - ), - // 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1) - update: ep({ - method: "patch", - path: "/approvals/{id}", - scope: "admin", - auth: "session", - summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)", - params: IdParam, - body: agent.ApprovalUpdate, - response: agent.Approval, - }), - create: ep({ - method: "post", - path: "/approvals", - scope: "admin", - auth: "session", - summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)", - body: agent.ApprovalRequest, - response: agent.Approval, - }), - }, - automationGrants: crud( - "/automation-grants", - "持续授权", - { - row: agent.AutomationGrant, - create: agent.AutomationGrantCreate, - update: agent.AutomationGrantUpdate, - filters: { status: agent.GrantStatus.optional() }, - }, - R, - ), - /** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */ - agentRuns: { - list: ep({ - method: "get", - path: "/agent/runs", - scope: "read", - summary: "智能体运行列表(新→旧)", - query: agent.AgentRunListQuery, - response: cursorPage(agent.AgentRun), - }), - get: ep({ - method: "get", - path: "/agent/runs/{id}", - scope: "read", - summary: "智能体运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报", - params: IdParam, - query: agent.AgentRunDetailQuery, - response: agent.AgentRunDetail, - }), - create: ep({ - method: "post", - path: "/agent/runs", - scope: "organize.execute", - summary: "委托目标:建 agent_run 并入队 runAgent(proposeFirst = 先看方案再执行;mode 已弃用)", - body: agent.AgentRunStartInput, - response: agent.AgentRun, - }), - message: ep({ - method: "post", - path: "/agent/runs/{id}/messages", - scope: "organize.execute", - summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑", - params: IdParam, - body: agent.AgentRunMessageInput, - response: agent.AgentRun, - }), - // 回答 agent.propose_options(会话挂起等待中):选一个方案或都不要,会话随即继续 - choose: ep({ - method: "post", - path: "/agent/runs/{id}/choice", - scope: "organize.execute", - summary: "回答方案选择:选一个方案或都不要(说明原因),挂起的会话随即继续", - params: IdParam, - body: agent.AgentRunChoiceInput, - response: agent.AgentRun, - }), - abort: ep({ - method: "post", - path: "/agent/runs/{id}/abort", - scope: "organize.execute", - summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)", - params: IdParam, - body: agent.AgentRunAbortInput, - response: agent.AgentRun, - }), - }, - artifacts: crud( - "/artifacts", - "产物", - { - row: agent.Artifact, - filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() }, - }, - R, - ), - - // ── 运维 ── - reconcileRuns: crud( - "/reconcile-runs", - "校验运行", - { - row: ops.ReconcileRun, - filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() }, - }, - R, - ), - serverMappings: crud( - "/server-mappings", - "库存映射", - { - row: ops.ServerMapping, - filters: { - mediaServerInstanceId: Id.optional(), - mediaEntityId: Id.optional(), - matchStatus: ops.ServerMatchStatus.optional(), - }, - }, - R, - ), - apiKeys: { - list: ep({ - method: "get", - path: "/api-keys", - scope: "admin", - summary: "API Key 列表", - query: CursorQuery, - response: cursorPage(ops.ApiKey), - }), - create: ep({ - method: "post", - path: "/api-keys", - scope: "admin", - summary: "新建 API Key(明文只返回一次)", - body: ops.ApiKeyCreate, - response: ops.ApiKeyCreated, - }), - update: ep({ - method: "patch", - path: "/api-keys/{id}", - scope: "admin", - summary: "修改/撤销 API Key", - params: IdParam, - body: ops.ApiKeyUpdate, - response: ops.ApiKey, - }), - revoke: del("/api-keys/{id}", "撤销 API Key"), - }, - auditEvents: { - list: ep({ - method: "get", - path: "/audit-events", - scope: "admin", - summary: "审计事件(按 actor/action/target 过滤,新→旧)", - query: ops.AuditEventQuery, - response: cursorPage(ops.AuditEvent), - }), - }, - secrets: { - list: ep({ - method: "get", - path: "/secrets", - scope: "admin", - summary: "凭证元数据列表", - query: CursorQuery, - response: cursorPage(ops.Secret), - }), - create: ep({ - method: "post", - path: "/secrets", - scope: "admin", - summary: "新建凭证(只写)", - body: ops.SecretCreate, - response: ops.Secret, - }), - update: ep({ - method: "patch", - path: "/secrets/{id}", - scope: "admin", - summary: "轮换凭证", - params: IdParam, - body: ops.SecretUpdate, - response: ops.Secret, - }), - remove: del("/secrets/{id}", "删除凭证(被引用时 409)"), - }, - - // ── 整理(公开合同) ── - organization: { - list: ep({ - method: "get", - path: "/organization-records", - scope: "organize.read", - summary: "整理记录列表", - query: organization.OrganizationListQuery, - response: cursorPage(organization.PublicOrganizationRecord), - }), - get: ep({ - method: "get", - path: "/organization-records/{id}", - scope: "organize.read", - summary: "整理记录详情(含逐文件结果)", - params: IdParam, - response: organization.PublicOrganizationRecord, - }), - changes: ep({ - method: "get", - path: "/organization-records/changes", - scope: "organize.read", - summary: "按 changeSeq 增量读取(含既有记录状态更新)", - query: organization.OrganizationChangesQuery, - response: organization.OrganizationChangesResponse, - }), - stats: ep({ - method: "get", - path: "/organization-records/stats", - scope: "organize.read", - summary: "整理记录按状态计数(筛选同列表)", - query: organization.OrganizationStatsQuery, - response: organization.OrganizationStats, - }), - remove: ep({ - method: "post", - path: "/organization-records/delete", - scope: "organize.execute", - summary: "删除整理记录,可选把源文件 / 媒体库文件移入回收站(dryRun 只核对)", - body: organization.OrganizationDeleteRequest, - response: organization.OrganizationDeleteResult, - }), - create: ep({ - method: "post", - path: "/organization-records", - scope: "organize.execute", - summary: "手动发起整理", - body: organization.OrganizeRequest, - response: organization.OrganizeAccepted, - }), - }, - - // ── 文件管理(docs/runbook/files.md;只浏览已登记根,写操作两段式 dryRun → planId) ── - classification: { - get: ep({ - method: "get", - path: "/classification", - scope: "organize.read", - summary: "分类配置", - response: classification.ClassificationConfigView, - }), - put: ep({ - method: "put", - path: "/classification", - scope: "admin", - summary: "保存分类配置", - body: classification.ClassificationPut, - response: classification.ClassificationConfigView, - }), - preview: ep({ - method: "post", - path: "/classification/preview", - scope: "organize.read", - summary: "测试分类规则", - body: classification.ClassificationPreviewInput, - response: classification.ClassificationPreviewResult, - }), - }, - organizeSources: { - list: ep({ - method: "get", - path: "/organize-sources", - scope: "organize.read", - summary: "整理来源", - response: organizeSources.OrganizeSourceList, - }), - save: ep({ - method: "put", - path: "/organize-sources/{id}", - scope: "admin", - summary: "保存整理来源", - params: IdParam, - body: organizeSources.OrganizeSourceInput, - response: organizeSources.OrganizeSourceView, - }), - remove: ep({ - method: "delete", - path: "/organize-sources/{id}", - scope: "admin", - summary: "删除整理来源", - params: IdParam, - response: z.object({ deleted: z.boolean() }), - }), - stock: ep({ - method: "post", - path: "/organize-sources/{id}/stock", - scope: "organize.execute", - summary: "扫描存量文件", - params: IdParam, - body: organizeSources.OrganizeStockInput, - response: organizeSources.OrganizeStockResult, - }), - }, - files: { - roots: ep({ - method: "get", - path: "/files/roots", - scope: "organize.read", - summary: "可浏览的根(库根 + 下载根),含实时健康与空间", - response: files.FileRootList, - }), - list: ep({ - method: "get", - path: "/files", - scope: "organize.read", - summary: "列目录(分页/排序/过滤/截断,行内业务状态);根离线 503", - query: files.FilesListQuery, - response: files.FilesListResponse, - }), - stat: ep({ - method: "get", - path: "/files/stat", - scope: "organize.read", - summary: "单项详情:realpath/inode/nlink/sidecars/路径绑定", - query: files.FileStatQuery, - response: files.FileStat, - }), - mkdir: ep({ - method: "post", - path: "/files/mkdir", - scope: "organize.execute", - summary: "新建目录(非递归,已存在 409,仅 writable 根)", - body: files.FilesMkdirRequest, - response: files.FilesMkdirResult, - }), - rename: ep({ - method: "post", - path: "/files/rename", - scope: "organize.execute", - summary: "同目录改名(不覆盖;同步 asset_location 与 path_binding 修订;做种中 409)", - body: files.FilesRenameRequest, - response: files.FilesRenameResult, - }), - move: ep({ - method: "post", - path: "/files/move", - scope: "organize.execute", - summary: "移动/复制到目录:dryRun 返回 planId,执行回传 planId(源变更 409 plan_stale)", - body: files.FilesMoveRequest, - response: files.FilesTransferResult, - }), - copy: ep({ - method: "post", - path: "/files/copy", - scope: "organize.execute", - summary: "复制到目录(同 /files/move mode=copy)", - body: files.FilesCopyRequest, - response: files.FilesTransferResult, - }), - organize: ep({ - method: "post", - path: "/files/organize", - scope: "organize.execute", - summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", - body: files.FilesOrganizeRequest, - response: files.FilesOrganizeResult, - }), - delete: ep({ - method: "post", - path: "/files/delete", - scope: "organize.execute", - summary: "删除(默认进回收站;引用检查:做种/资产/绑定;permanent 仅 admin 会话)", - body: files.FilesDeleteRequest, - response: files.FilesDeleteResult, - }), - trash: ep({ - method: "get", - path: "/files/trash", - scope: "organize.read", - summary: "回收站列表", - query: files.FilesTrashQuery, - response: files.FilesTrashList, - }), - trashRestore: ep({ - method: "post", - path: "/files/trash/restore", - scope: "organize.execute", - summary: "从回收站还原(原位被占的项失败 conflict)", - body: files.FilesTrashRestoreRequest, - response: files.FilesTrashRestoreResult, - }), - trashPurge: ep({ - method: "post", - path: "/files/trash/purge", - scope: "admin", - auth: "session", - summary: "清空回收站(缺省只清过期项;仅管理员控制台会话,API Key 与 DEV key 一律 403)", - body: files.FilesTrashPurgeRequest, - response: files.FilesTrashPurgeResult, - }), - identify: ep({ - method: "post", - path: "/files/identify", - scope: "organize.read", - summary: "识别(只读):解析 + 本地/TMDB 召回前 5 候选,不落库", - body: files.FilesIdentifyRequest, - response: files.FilesIdentifyResult, - }), - scrape: ep({ - method: "post", - path: "/files/scrape", - scope: "organize.execute", - summary: - "刮削:对目录或视频文件就地写 NFO 与图片(按路径识别作品,不要求已登记;认不准时返回候选,选定后再提交)", - body: files.FilesScrapeRequest, - response: files.FilesScrapeResult, - }), - scrapeRecords: ep({ - method: "get", - path: "/files/scrape-records", - scope: "organize.read", - summary: "按 id 查刮削记录(目录、识别结果、逐文件结果)", - query: files.FilesScrapeRecordsQuery, - response: files.FilesScrapeRecordsResult, - }), - probe: ep({ - method: "post", - path: "/files/probe", - scope: "organize.execute", - summary: "ffprobe 摘要(≤20 项;已登记资产写回 media_info,所以要 organize.execute)", - body: files.FilesProbeRequest, - response: files.FilesProbeResult, - }), - download: ep({ - method: "get", - path: "/files/download", - scope: "admin", - auth: "session", - summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)", - query: files.FilesDownloadQuery, - response: files.FilesDownloadResponse, - }), - preview: ep({ - method: "get", - path: "/files/preview", - scope: "admin", - auth: "session", - summary: - "结构化预览(文本/NFO 前 1 MiB 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;仅管理员控制台会话)", - query: files.FilesPreviewQuery, - response: files.FilePreview, - }), - records: ep({ - method: "get", - path: "/files/records", - scope: "organize.read", - summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)", - query: files.FilesRecordsQuery, - response: files.FilesRecordsResult, - }), - // W9-H 文件管理 v2 后端缺口 - entityFromCandidate: ep({ - method: "post", - path: "/files/entity-from-candidate", - scope: "organize.execute", - summary: "按 TMDB 候选就地建本地作品 + 外部映射(accepted/manual)+ 季集单元;幂等", - body: files.FilesEntityFromCandidateRequest, - response: files.FilesEntityFromCandidateResult, - }), - scrapeBatch: ep({ - method: "post", - path: "/files/scrape-batch", - scope: "organize.execute", - summary: "批量刷新元数据:按作品去重入队 refreshMetadata(锁定字段不变)", - body: files.FilesScrapeBatchRequest, - response: files.FilesScrapeBatchResult, - }), - tree: ep({ - method: "get", - path: "/files/tree", - scope: "organize.read", - summary: "目录摘要:上一级、子目录(是否有下级)、视频/已登记计数(树与上一级用)", - query: files.FilesTreeQuery, - response: files.FilesTreeResult, - }), - recordSources: ep({ - method: "get", - path: "/files/record-sources", - scope: "organize.read", - summary: "整理记录的源路径换算成登记目录 FileRef(重新整理用)", - query: files.FilesRecordSourcesQuery, - response: files.FilesRecordSourcesResult, - }), - suggestName: ep({ - method: "post", - path: "/files/suggest-name", - scope: "organize.read", - summary: "按命名模板(作品 > 库根 > 全局)生成推荐名;识别不可信时 name=null + 原因", - body: files.FilesSuggestNameRequest, - response: files.FilesSuggestNameResult, - }), - }, - - // ── 能力 ── - capabilities: { - list: ep({ - method: "get", - path: "/capabilities", - scope: "read", - summary: "能力注册中心描述", - response: z.object({ items: z.array(CapabilityDescriptor) }), - }), - invoke: ep({ - method: "post", - path: "/capabilities/{capabilityId}/invoke", - scope: "read", - summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)", - params: z.object({ capabilityId: KnownCapabilityId }), - body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }), - response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }), - }), - stats: ep({ - method: "get", - path: "/capabilities/stats", - scope: "read", - summary: "能力调用统计:每能力调用数、失败数、平均耗时、最近调用、调用来源(内置智能体 / MCP / REST)", - query: mcp.CapabilityStatsQuery, - response: mcp.CapabilityStatsResponse, - }), - tools: ep({ - method: "get", - path: "/capabilities/tools", - scope: "read", - summary: "能力清单:描述符、工具名、输入输出 JSON Schema、当前授权状态", - response: mcp.CapabilityToolsResponse, - }), - }, - - // ── 对外 MCP 服务端(Streamable HTTP 在 /mcp,不进 OpenAPI;这里是状态与配置)── - mcp: { - status: ep({ - method: "get", - path: "/mcp/status", - scope: "admin", - summary: "MCP 服务端状态:启用、允许的 API Key、当前会话、最近调用、限速", - response: mcp.McpServerStatus, - }), - update: ep({ - method: "patch", - path: "/mcp/config", - scope: "admin", - summary: "修改 MCP 服务端配置(启用开关、允许的 API Key)", - body: mcp.McpServerConfigUpdate, - response: mcp.McpServerConfig, - }), - }, - - // ── 缓存(C2-A,docs/runbook/cache.md) ── - cache: { - tmdb: ep({ - method: "get", - path: "/cache/tmdb", - scope: "admin", - summary: "TMDB 缓存条目(请求响应与识别结果:键、过期时间、大小)与上限 / TTL", - query: cache.TmdbCacheQuery, - response: cache.TmdbCacheList, - }), - tmdbClear: ep({ - method: "post", - path: "/cache/tmdb/clear", - scope: "admin", - summary: "清空 TMDB 缓存(请求响应与识别结果)", - response: cache.CacheClearResult, - }), - images: ep({ - method: "get", - path: "/cache/images", - scope: "admin", - summary: "图片代理缓存用量", - response: cache.ImageCacheStatus, - }), - imagesClear: ep({ - method: "post", - path: "/cache/images/clear", - scope: "admin", - summary: "清空图片代理缓存", - response: cache.CacheClearResult, - }), - }, - - // ── 网页搜索(WEB-1,docs/runbook/websearch.md) ── - websearch: { - test: ep({ - method: "post", - path: "/websearch/test", - scope: "admin", - summary: "用已保存的搜索服务 Key 实际查询一次(不走缓存,计入每分钟上限)", - body: WebSearchTestInput, - response: WebSearchTestResult, - }), - }, - - // ── 设置 ── - settings: { - get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }), - update: ep({ - method: "patch", - path: "/settings", - scope: "admin", - summary: "修改全局设置", - body: GlobalSettingsUpdate, - response: GlobalSettings, - }), - }, - - // ── 整理命名模板(W9-C,docs/design/naming-template.md):保存前服务端编译校验 ── - naming: { - variables: ep({ - method: "get", - path: "/naming/variables", - scope: "read", - summary: "命名变量表(名称对齐 MP,含说明/示例值/我们特有标记)、过滤器、预览内置样本", - response: settingsNs.NamingVariablesResponse, - }), - preview: ep({ - method: "post", - path: "/naming/preview", - scope: "read", - summary: - "预览:模板 + 样本(文件名 / 资源 / 资产 / 内置)→ 相对路径、段角色、错误(行列号)、提示;已有路径绑定时目录用绑定", - body: settingsNs.NamingPreviewInput, - response: settingsNs.NamingPreviewResult, - }), - templates: ep({ - method: "get", - path: "/naming/templates", - scope: "read", - summary: "全局 / 库根 / 作品三级模板与按类型的生效结果(作品 > 库根 > 全局)", - query: settingsNs.NamingTemplatesQuery, - response: settingsNs.NamingTemplatesView, - }), - saveTemplates: ep({ - method: "put", - path: "/naming/templates", - scope: "admin", - summary: "保存全局命名模板(逐条编译校验,有错 422 不保存)", - body: settingsNs.NamingSettingsPut, - response: settingsNs.NamingSettings, - }), - libraryRoot: ep({ - method: "put", - path: "/library-roots/{id}/naming", - scope: "admin", - summary: "库根级命名覆盖(按类型;null 清除)", - params: IdParam, - body: settingsNs.LibraryRootNamingPut, - response: settingsNs.LibraryRootNamingPut, - }), - entity: ep({ - method: "put", - path: "/media-entities/{id}/naming-override", - scope: "admin", - summary: "作品级命名覆盖(按类型;null 清除)。已有路径绑定的目录不受影响,只影响之后的文件名与新季目录", - params: IdParam, - body: settingsNs.EntityNamingOverridePut, - response: settingsNs.EntityNamingOverridePut, - }), - }, - - // ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ── - pi: { - providers: ep({ - method: "get", - path: "/pi/providers", - scope: "admin", - summary: "Pi provider 能力表(认证方式、订阅登录方式与条款提示、baseUrl 覆盖、env 字段、静态模型表)", - query: pi.PiProvidersQuery, - response: pi.PiProvidersResponse, - }), - list: ep({ - method: "get", - path: "/pi/upstreams", - scope: "admin", - summary: "模型服务列表(不含密钥)", - response: pi.PiUpstreamList, - }), - get: ep({ - method: "get", - path: "/pi/upstreams/{id}", - scope: "admin", - summary: "模型服务详情", - params: IdParam, - response: pi.PiUpstream, - }), - create: ep({ - method: "post", - path: "/pi/upstreams", - scope: "admin", - auth: "session", - summary: "新建模型服务(apiKey 只写,存 secret 表)", - body: pi.PiUpstreamCreate, - response: pi.PiUpstream, - }), - update: ep({ - method: "patch", - path: "/pi/upstreams/{id}", - scope: "admin", - auth: "session", - summary: "修改模型服务(provider 不可改;makeDefault 设默认模型服务)", - params: IdParam, - body: pi.PiUpstreamUpdate, - response: pi.PiUpstream, - }), - remove: ep({ - method: "delete", - path: "/pi/upstreams/{id}", - scope: "admin", - auth: "session", - summary: "删除模型服务(连同凭据;是默认模型服务时清空默认)", - params: IdParam, - response: OkResult, - }), - test: ep({ - method: "post", - path: "/pi/upstreams/{id}/test", - scope: "admin", - auth: "session", - summary: "测试模型服务:默认只解析凭据与校验模型(不产生费用);mode=completion 发一次最小补全", - params: IdParam, - body: pi.PiUpstreamTestRequest, - response: pi.PiUpstreamTestResult, - }), - models: ep({ - method: "get", - path: "/pi/upstreams/{id}/models", - scope: "admin", - summary: "模型列表:已获取过远程列表时返回远程与静态目录的合并(远程优先),否则静态目录", - params: IdParam, - response: pi.PiUpstreamModels, - }), - modelsRefresh: ep({ - method: "post", - path: "/pi/upstreams/{id}/models/refresh", - scope: "admin", - auth: "session", - summary: "获取模型列表:用该模型服务的凭证调 provider 的模型列表接口并存库;失败或不支持时回静态目录并给出原因", - params: IdParam, - response: pi.PiUpstreamModels, - }), - modelsProbe: ep({ - method: "post", - path: "/pi/models/probe", - scope: "admin", - auth: "session", - summary: "新建模型服务前试拉模型列表(凭证只用于这一次请求,不落库)", - body: pi.PiModelsProbeRequest, - response: pi.PiUpstreamModels, - }), - oauthStart: ep({ - method: "post", - path: "/pi/upstreams/{id}/oauth/start", - scope: "admin", - auth: "session", - summary: "开始订阅登录:返回授权链接(browser)或验证码(device)", - params: IdParam, - body: pi.PiOAuthStartRequest, - response: pi.PiOAuthStartResult, - }), - oauthComplete: ep({ - method: "post", - path: "/pi/upstreams/{id}/oauth/complete", - scope: "admin", - auth: "session", - summary: "完成订阅登录:browser 粘回地址栏 URL;device 服务端轮询(本次最多等 waitMs,未完成返回 pending)", - params: IdParam, - body: pi.PiOAuthCompleteRequest, - response: pi.PiOAuthCompleteResult, - }), - oauthLogout: ep({ - method: "post", - path: "/pi/upstreams/{id}/oauth/logout", - scope: "admin", - auth: "session", - summary: "退出订阅登录:删除本地 token(不会撤销提供方侧授权)", - params: IdParam, - response: pi.PiUpstream, - }), - }, - - // ── 登录门禁 ── - /** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */ - auth: { - setup: ep({ - method: "post", - path: "/auth/setup", - scope: "read", - auth: "public", - summary: "首次初始化管理员", - body: auth.AdminSetupRequest, - response: auth.SessionInfo, - }), - login: ep({ - method: "post", - path: "/auth/login", - scope: "read", - auth: "public", - summary: "管理员登录", - body: auth.LoginRequest, - response: auth.LoginResult, - }), - logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }), - session: ep({ - method: "get", - path: "/auth/session", - scope: "read", - auth: "public", - summary: "当前登录态(免鉴权)", - response: auth.SessionInfo, - }), - password: ep({ - method: "post", - path: "/auth/password", - scope: "admin", - auth: "session", - summary: "修改密码(吊销其它会话)", - body: auth.PasswordChangeRequest, - response: auth.PasswordChangeResult, - }), - totpStatus: ep({ - method: "get", - path: "/auth/totp", - scope: "admin", - auth: "session", - summary: "TOTP 状态", - response: auth.TotpStatus, - }), - totp: ep({ - method: "post", - path: "/auth/totp", - scope: "admin", - auth: "session", - summary: "TOTP 绑定/启用/停用/重新生成恢复码", - body: auth.TotpAction, - response: auth.TotpActionResult, - }), - totpSetup: ep({ - method: "get", - path: "/auth/totp/setup", - scope: "admin", - auth: "session", - summary: "待确认的 TOTP 密钥与 otpauth URL(前端渲染二维码)", - response: auth.TotpSetup, - }), - passkeys: ep({ - method: "get", - path: "/auth/passkeys", - scope: "admin", - auth: "session", - summary: "已登记的 Passkey", - response: auth.PasskeyList, - }), - passkeyRegisterOptions: ep({ - method: "post", - path: "/auth/passkeys/register/options", - scope: "admin", - auth: "session", - summary: "Passkey 注册挑战", - response: auth.PasskeyChallenge, - }), - passkeyRegister: ep({ - method: "post", - path: "/auth/passkeys/register/verify", - scope: "admin", - auth: "session", - summary: "校验并登记 Passkey", - body: auth.PasskeyRegisterRequest, - response: auth.PasskeyInfo, - }), - passkeyRename: ep({ - method: "patch", - path: "/auth/passkeys/{id}", - scope: "admin", - auth: "session", - summary: "重命名 Passkey", - params: IdParam, - body: auth.PasskeyRenameRequest, - response: auth.PasskeyInfo, - }), - passkeyDelete: ep({ - method: "delete", - path: "/auth/passkeys/{id}", - scope: "admin", - auth: "session", - summary: "删除 Passkey", - params: IdParam, - response: OkResult, - }), - passkeyLoginOptions: ep({ - method: "post", - path: "/auth/passkeys/login/options", - scope: "read", - auth: "public", - summary: "Passkey 登录挑战(免鉴权)", - response: auth.PasskeyChallenge, - }), - passkeyLogin: ep({ - method: "post", - path: "/auth/passkeys/login/verify", - scope: "read", - auth: "public", - summary: "Passkey 登录(免鉴权;成功即下发会话)", - body: auth.PasskeyLoginRequest, - response: auth.LoginResult, - }), - }, - - // ── 事件 ── - events: { - list: ep({ - method: "get", - path: "/events", - scope: "read", - summary: - "事件流:控制台用 SSE(text/event-stream,cookie 会话);API Key 调用者带 limit 取 JSON 补读页(after=上次 nextAfter)", - query: events.EventReplayQuery, - response: events.EventReplayPage, - }), - }, - - // ── 系统 ── - system: { - health: ep({ - method: "get", - path: "/health", - scope: "read", - summary: "服务健康状态(免鉴权)", - response: HealthStatus, - }), - healthDetail: ep({ - method: "get", - path: "/system/health", - scope: "admin", - summary: "进程与健康:进程心跳 / 事件循环延迟 / SSE 连接 / DB / 磁盘 / 挂载根 / 引擎 / 定时任务", - response: ops.SystemHealth, - }), - healthHistory: ep({ - method: "get", - path: "/system/health/history", - scope: "admin", - summary: "心跳采样时间序列(事件循环延迟、运行中 run、SSE 连接;按桶聚合)", - query: ops.SystemHealthHistoryQuery, - response: ops.SystemHealthHistory, - }), - storage: ep({ - method: "get", - path: "/system/storage", - scope: "admin", - summary: "存储:表体积前 15、库总体积、各缓存用量与预算、备份占用、DATA_DIR 分目录用量、清理任务状态", - response: ops.SystemStorage, - }), - storageCleanup: ep({ - method: "post", - path: "/system/storage/cleanup/{target}", - scope: "admin", - summary: "立即执行某个存储清理任务(排入对应定时任务,已在排队或运行中则合并)", - params: ops.StorageCleanupParam, - response: ops.StorageCleanupResult, - }), - }, - - // ── 引擎面板(仅 admin) ── - engine: { - runs: ep({ - method: "get", - path: "/engine/runs", - scope: "admin", - summary: "引擎 runs(状态/名称/时间筛选,新→旧)", - query: ops.EngineRunQuery, - response: cursorPage(ops.EngineRun), - }), - run: ep({ - method: "get", - path: "/engine/runs/{id}", - scope: "admin", - summary: "run 详情(step attempts + signals)", - params: EngineRunIdParam, - response: ops.EngineRunDetail, - }), - stats: ep({ - method: "get", - path: "/engine/stats", - scope: "admin", - summary: "按状态计数 / 最老待处理 / 活跃 worker 快照", - response: ops.EngineStats, - }), - cancel: ep({ - method: "post", - path: "/engine/runs/{id}/cancel", - scope: "admin", - summary: "取消 run", - params: EngineRunIdParam, - body: ops.EngineCancelRequest, - response: ops.EngineCancelResult, - }), - retry: ep({ - method: "post", - path: "/engine/runs/{id}/retry", - scope: "admin", - summary: "按原任务名与输入重新入队(新幂等键 retry::<序号>)", - params: EngineRunIdParam, - response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }), - }), - }, - - // ── 待识别池 ── - identityPool: { - list: ep({ - method: "get", - path: "/identity-pool", - scope: "read", - summary: "未识别资源(可按原始标题检索;候选不建正式作品)", - query: identity.IdentityPoolQuery, - response: cursorPage(identity.IdentityPoolItem), - }), - assign: ep({ - method: "post", - path: "/identity-pool/{id}/assign", - scope: "organize.execute", - summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份", - params: IdParam, - body: identity.ReleaseIdentityAssign, - response: ReleaseIdentityResult, - }), - dismiss: ep({ - method: "post", - path: "/identity-pool/{id}/dismiss", - scope: "organize.execute", - summary: "忽略未识别资源(不再自动复查)", - params: IdParam, - body: z.object({ note: z.string().max(2000).optional() }), - response: identity.IdentityCandidate, - }), - retry: ep({ - method: "post", - path: "/identity-pool/{id}/retry", - scope: "organize.execute", - summary: "立即重新识别", - params: IdParam, - response: TaskAccepted, - }), - // META-1 元数据补全:先看建议(metadata.enrich,不改作品),再在控制台应用 - enrich: ep({ - method: "post", - path: "/identity-pool/{id}/enrich", - scope: "organize.execute", - summary: "元数据补全建议(网页搜索 + 白名单网页 + 模型抽取;不改作品;同一标题 7 天内返回上次结果)", - params: IdParam, - body: identity.EnrichRequest, - response: MetadataEnrichOutput, - }), - enrichApply: ep({ - method: "post", - path: "/identity-pool/{id}/enrich/apply", - scope: "admin", - auth: "session", - summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)", - params: IdParam, - body: identity.EnrichApplyInput, - response: identity.EnrichApplyResult, - }), - // META-2 补全命中率(按来源类别) - enrichStats: ep({ - method: "get", - path: "/identity/enrich-stats", - scope: "read", - summary: "元数据补全命中率(最近 N 天,按来源类别:百科、平台、豆瓣摘要、其它网站摘要)", - query: identity.EnrichStatsQuery, - response: identity.EnrichStats, - }), - }, - - // ── 媒体库 / 库存 / 首页 ── - library: { - list: ep({ - method: "get", - path: "/library", - scope: "read", - summary: "媒体库列表(已登记资产或正在追踪的作品)", - query: organization.LibraryListQuery, - response: cursorPage(organization.LibraryItem), - }), - get: ep({ - method: "get", - path: "/library/{id}", - scope: "read", - summary: "作品详情:实际路径、身份映射、元数据来源、各收录版本、候选拒绝理由、下载历史与校验结果", - params: IdParam, - response: organization.LibraryDetail, - }), - versions: ep({ - method: "get", - path: "/library/{id}/versions", - scope: "read", - summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", - params: IdParam, - query: libraryVersions.LibraryVersionsQuery, - response: libraryVersions.LibraryVersions, - }), - scrape: ep({ - method: "post", - path: "/library/{id}/scrape", - scope: "organize.execute", - summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)", - params: IdParam, - body: scrape.LibraryScrapeRequest, - response: scrape.LibraryScrapeResult, - }), - reconcile: ep({ - method: "post", - path: "/library/reconcile", - scope: "admin", - summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)", - body: z.object({ libraryRootId: z.uuid().optional() }), - response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }), - }), - }, - inventory: { - overlay: ep({ - method: "get", - path: "/inventory", - scope: "read", - summary: "库存叠加(站点工作台):按作品返回已有版本 / 收录版本缺口 / 下载中", - query: organization.InventoryQuery, - response: organization.InventoryResponse, - }), - mediaServers: ep({ - method: "get", - path: "/inventory/media-servers", - scope: "read", - summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)", - query: organization.MediaServerInventoryQuery, - response: organization.MediaServerInventory, - }), - }, - dashboard: { - get: ep({ - method: "get", - path: "/dashboard", - scope: "read", - summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出", - response: organization.Dashboard, - }), - }, - downloadCache: { - cleanup: ep({ - method: "post", - path: "/download-cache/cleanup", - scope: "admin", - summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)", - body: z.object({ downloaderInstanceId: z.uuid().optional() }), - response: z.object({ report: z.record(z.string(), z.unknown()) }), - }), - }, - - // ── 全局搜索(W9-G):顶栏搜索 / 命令面板 / 资源结果页 ── - search: { - all: ep({ - method: "get", - path: "/search", - scope: "read", - summary: - "全局搜索聚合:本地作品 / TMDB 候选 / 跨站资源(15s 超时、并发合并、30s 缓存、库存叠加)/ 页面与操作;每组带 tookMs", - query: search.SearchQuery, - response: search.SearchResponse, - }), - releases: ep({ - method: "get", - path: "/search/releases", - scope: "read", - summary: "跨站资源结果页:站点/分辨率/来源/大小/免费/H&R 筛选、排序、游标;与 /search 共用缓存", - query: search.ReleaseSearchQuery, - response: search.ReleaseSearchPage, - }), - plan: ep({ - method: "post", - path: "/search/releases/{siteId}/{siteReleaseId}/plan", - scope: "organize.execute", - summary: "从搜索结果直接下载:定位资源后走 POST /acquisition-plans 同一流程", - params: search.SearchReleaseParams, - body: search.SearchReleasePlanRequest, - response: execution.ManualAcquisitionResult, - }), - tmdbDetail: ep({ - method: "get", - path: "/media/tmdb/{type}/{id}", - scope: "read", - summary: - "仅 TMDB 作品的详情:TMDB 信息与季列表、本地库存对比、是否已建档、搜资源的查询词;include=mediaServers 同时查 Emby", - params: search.TmdbMediaParams, - query: search.TmdbMediaQuery, - response: search.TmdbMediaDetail, - }), - tmdbSeason: ep({ - method: "get", - path: "/media/tmdb/tv/{id}/season/{season}", - scope: "read", - summary: "仅 TMDB 剧集的季详情与每集信息(标题 / 简介按语言回退链;走 TMDB 请求缓存)", - params: search.TmdbSeasonParams, - response: search.TmdbSeasonDetailView, - }), - }, - - // ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ── - browser: { - providers: ep({ - method: "get", - path: "/browser/providers", - scope: "admin", - summary: "浏览器服务列表(地址、凭证引用、启用、能力 supported/verified);probe=true 现场探测并写回", - query: browser.BrowserProvidersQuery, - response: z.object({ items: z.array(browser.BrowserProviderView) }), - }), - createProvider: ep({ - method: "post", - path: "/browser/providers", - scope: "admin", - summary: "登记浏览器服务(camofox 地址、访问凭据存凭证库、启用)", - body: browser.BrowserProviderCreate, - response: browser.BrowserProviderView, - }), - updateProvider: ep({ - method: "patch", - path: "/browser/providers/{id}", - scope: "admin", - summary: "修改浏览器服务地址、访问凭据或启用(环境变量登记的只能改启用)", - params: browser.BrowserProviderParams, - body: browser.BrowserProviderUpdate, - response: browser.BrowserProviderView, - }), - deleteProvider: ep({ - method: "delete", - path: "/browser/providers/{id}", - scope: "admin", - summary: "删除浏览器服务(有未关闭会话或由环境变量登记时拒绝)", - params: browser.BrowserProviderParams, - response: z.object({ id: z.string() }), - }), - probeProvider: ep({ - method: "post", - path: "/browser/providers/{id}/probe", - scope: "admin", - summary: "测试连通:探测该浏览器服务的 /health 与 /openapi.json 并写回能力", - params: browser.BrowserProviderParams, - response: browser.BrowserProviderView, - }), - sessions: ep({ - method: "get", - path: "/browser/sessions", - scope: "admin", - summary: "浏览器会话列表(以数据库为准;默认只列未关闭的)", - query: browser.BrowserSessionsQuery, - response: z.object({ items: z.array(browser.BrowserSessionView) }), - }), - open: ep({ - method: "post", - path: "/browser/sessions", - scope: "admin", - summary: "打开站点浏览器会话(同一站点/账号已有未关闭会话时直接返回它)", - body: browser.BrowserSessionOpen, - response: browser.BrowserSessionView, - }), - get: ep({ - method: "get", - path: "/browser/sessions/{id}", - scope: "admin", - summary: "浏览器会话详情(控制者、标签页、租约、是否需重新读取页面)", - params: browser.BrowserSessionParams, - response: browser.BrowserSessionView, - }), - close: ep({ - method: "delete", - path: "/browser/sessions/{id}", - scope: "admin", - summary: "关闭浏览器会话(关闭上游标签页,撤销租约)", - params: browser.BrowserSessionParams, - response: browser.BrowserSessionView, - }), - openTab: ep({ - method: "post", - path: "/browser/sessions/{id}/tabs", - scope: "admin", - summary: "新建标签页并设为当前(需持有控制权)", - params: browser.BrowserSessionParams, - body: browser.BrowserTabOpen, - response: browser.BrowserSessionView, - }), - closeTab: ep({ - method: "delete", - path: "/browser/sessions/{id}/tabs/{tabId}", - scope: "admin", - summary: "关闭标签页(需持有控制权)", - params: browser.BrowserTabParams, - response: browser.BrowserSessionView, - }), - navigate: ep({ - method: "post", - path: "/browser/sessions/{id}/navigate", - scope: "admin", - summary: "地址栏导航或后退/前进/刷新(需持有控制权;限站点域名)", - params: browser.BrowserSessionParams, - body: browser.BrowserNavigate, - response: browser.BrowserNavigation, - }), - snapshot: ep({ - method: "get", - path: "/browser/sessions/{id}/snapshot", - scope: "admin", - summary: "结构化页面快照(凭证已遮蔽)", - params: browser.BrowserSessionParams, - query: browser.BrowserSnapshotQuery, - response: browser.BrowserSnapshot, - }), - screenshot: ep({ - method: "get", - path: "/browser/sessions/{id}/screenshot", - scope: "admin", - summary: "当前画面截图(base64,只给原站视图,不进模型)", - params: browser.BrowserSessionParams, - query: browser.BrowserScreenshotQuery, - response: browser.BrowserScreenshotView, - }), - click: ep({ - method: "post", - path: "/browser/sessions/{id}/click", - scope: "admin", - summary: "点击(快照引用 / 选择器 / 截图坐标;需持有控制权)", - params: browser.BrowserSessionParams, - body: browser.BrowserClick, - response: browser.BrowserActionResult, - }), - type: ep({ - method: "post", - path: "/browser/sessions/{id}/type", - scope: "admin", - summary: "输入文本 / 站点账号凭证 / 按键(需持有控制权;凭证由服务端注入)", - params: browser.BrowserSessionParams, - body: browser.BrowserType, - response: browser.BrowserActionResult, - }), - scroll: ep({ - method: "post", - path: "/browser/sessions/{id}/scroll", - scope: "admin", - summary: "滚动页面(需持有控制权)", - params: browser.BrowserSessionParams, - body: browser.BrowserScroll, - response: browser.BrowserActionResult, - }), - select: ep({ - method: "post", - path: "/browser/sessions/{id}/select", - scope: "admin", - summary: "下拉框选择(需持有控制权)", - params: browser.BrowserSessionParams, - body: browser.BrowserSelect, - response: browser.BrowserActionResult, - }), - extract: ep({ - method: "post", - path: "/browser/sessions/{id}/extract", - scope: "admin", - summary: "按 JSON Schema(x-ref)提取页面字段", - params: browser.BrowserSessionParams, - body: browser.BrowserExtract, - response: browser.BrowserExtractResult, - }), - resource: ep({ - method: "post", - path: "/browser/sessions/{id}/resource", - scope: "organize.execute", - summary: "解析当前原站资源详情,供独立手动下载使用", - params: browser.BrowserSessionParams, - body: browser.BrowserResourceRequest, - response: browser.BrowserResourceResult, - }), - download: ep({ - method: "post", - path: "/browser/sessions/{id}/download", - scope: "organize.execute", - summary: - "捕获浏览器下载 → 后端暂存并核验 infohash、匹配本地资源;给出 submit 时经 POST /acquisition-plans 同一流程提交", - params: browser.BrowserSessionParams, - body: browser.BrowserDownload, - response: browser.BrowserDownloadResult, - }), - reconnect: ep({ - method: "post", - path: "/browser/sessions/{id}/reconnect", - scope: "admin", - summary: - "浏览器服务重启后重新连接(幂等):上游上下文还在就什么都不做;否则注入账号 Cookie、新开标签页回到原地址,控制权不变;失败 503 browser_reconnect_failed", - params: browser.BrowserSessionParams, - response: browser.BrowserSessionView, - }), - acquire: ep({ - method: "post", - path: "/browser/sessions/{id}/control/acquire", - scope: "admin", - summary: "人工接管:先停止该会话新的自动化输入并等进行中的操作收尾,再发放人工租约", - params: browser.BrowserSessionParams, - body: browser.BrowserControlAcquire, - response: browser.BrowserControlResult, - }), - release: ep({ - method: "post", - path: "/browser/sessions/{id}/control/release", - scope: "admin", - summary: "交还控制权:智能体恢复前必须重新读取页面,旧元素引用失效", - params: browser.BrowserSessionParams, - body: browser.BrowserControlRelease, - response: browser.BrowserControlResult, - }), - }, - - // ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ── - schedules: { - list: ep({ - method: "get", - path: "/schedules", - scope: "admin", - auth: "session", - summary: "定时任务看板:全部登记的周期任务与智能体巡检、统计、调度器是否在线", - response: schedules.ScheduleBoard, - }), - create: ep({ - method: "post", - path: "/schedules", - scope: "admin", - auth: "session", - summary: "新建自定义智能体巡检(目标模板 + 能力白名单 + 预算 + 间隔)", - body: schedules.AgentCheckCreate, - response: schedules.ScheduledJob, - }), - update: ep({ - method: "patch", - path: "/schedules/{id}", - scope: "admin", - auth: "session", - summary: "修改开关 / 间隔或 cron / 参数;reset 恢复默认开关与调度", - params: schedules.ScheduleIdParam, - body: schedules.ScheduleUpdate, - response: schedules.ScheduledJob, - }), - remove: ep({ - method: "delete", - path: "/schedules/{id}", - scope: "admin", - auth: "session", - summary: "删除自定义智能体巡检(内置任务不可删)", - params: schedules.ScheduleIdParam, - response: OkResult, - }), - runNow: ep({ - method: "post", - path: "/schedules/{id}/run-now", - scope: "admin", - auth: "session", - summary: "立即执行一次(不改下次运行时间;已在排队或运行中则合并)", - params: schedules.ScheduleIdParam, - response: schedules.ScheduleRunNowResult, - }), - skip: ep({ - method: "post", - path: "/schedules/{id}/skip", - scope: "admin", - auth: "session", - summary: "跳过下一次运行(下次运行时间顺延一个周期)", - params: schedules.ScheduleIdParam, - response: schedules.ScheduledJob, - }), - runs: ep({ - method: "get", - path: "/schedules/{id}/runs", - scope: "admin", - auth: "session", - summary: "最近运行历史(新→旧)", - params: schedules.ScheduleIdParam, - query: schedules.ScheduleRunsQuery, - response: z.object({ items: z.array(schedules.ScheduledJobRun) }), - }), - }, + ...contentApi, + ...instancesApi, + ...sourcesApi, + ...rulesApi, + ...executionApi, + ...reviewApi, + ...opsApi, + ...organizeApi, + ...platformApi, + ...systemApi, + ...libraryApi, + ...browserApi, + ...schedulesApi, } as const; /** 用参数填充 `{name}` 路径占位,返回带 API_PREFIX 的完整路径 */ diff --git a/packages/contracts/src/api/browser.ts b/packages/contracts/src/api/browser.ts new file mode 100644 index 00000000..574afb58 --- /dev/null +++ b/packages/contracts/src/api/browser.ts @@ -0,0 +1,217 @@ +import { z } from "zod"; +import * as browser from "../browser.ts"; +import { ep } from "./shared.ts"; + +export const browserApi = { + // ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ── + browser: { + providers: ep({ + method: "get", + path: "/browser/providers", + scope: "admin", + summary: "浏览器服务列表(地址、凭证引用、启用、能力 supported/verified);probe=true 现场探测并写回", + query: browser.BrowserProvidersQuery, + response: z.object({ items: z.array(browser.BrowserProviderView) }), + }), + createProvider: ep({ + method: "post", + path: "/browser/providers", + scope: "admin", + summary: "登记浏览器服务(camofox 地址、访问凭据存凭证库、启用)", + body: browser.BrowserProviderCreate, + response: browser.BrowserProviderView, + }), + updateProvider: ep({ + method: "patch", + path: "/browser/providers/{id}", + scope: "admin", + summary: "修改浏览器服务地址、访问凭据或启用(环境变量登记的只能改启用)", + params: browser.BrowserProviderParams, + body: browser.BrowserProviderUpdate, + response: browser.BrowserProviderView, + }), + deleteProvider: ep({ + method: "delete", + path: "/browser/providers/{id}", + scope: "admin", + summary: "删除浏览器服务(有未关闭会话或由环境变量登记时拒绝)", + params: browser.BrowserProviderParams, + response: z.object({ id: z.string() }), + }), + probeProvider: ep({ + method: "post", + path: "/browser/providers/{id}/probe", + scope: "admin", + summary: "测试连通:探测该浏览器服务的 /health 与 /openapi.json 并写回能力", + params: browser.BrowserProviderParams, + response: browser.BrowserProviderView, + }), + sessions: ep({ + method: "get", + path: "/browser/sessions", + scope: "admin", + summary: "浏览器会话列表(以数据库为准;默认只列未关闭的)", + query: browser.BrowserSessionsQuery, + response: z.object({ items: z.array(browser.BrowserSessionView) }), + }), + open: ep({ + method: "post", + path: "/browser/sessions", + scope: "admin", + summary: "打开站点浏览器会话(同一站点/账号已有未关闭会话时直接返回它)", + body: browser.BrowserSessionOpen, + response: browser.BrowserSessionView, + }), + get: ep({ + method: "get", + path: "/browser/sessions/{id}", + scope: "admin", + summary: "浏览器会话详情(控制者、标签页、租约、是否需重新读取页面)", + params: browser.BrowserSessionParams, + response: browser.BrowserSessionView, + }), + close: ep({ + method: "delete", + path: "/browser/sessions/{id}", + scope: "admin", + summary: "关闭浏览器会话(关闭上游标签页,撤销租约)", + params: browser.BrowserSessionParams, + response: browser.BrowserSessionView, + }), + openTab: ep({ + method: "post", + path: "/browser/sessions/{id}/tabs", + scope: "admin", + summary: "新建标签页并设为当前(需持有控制权)", + params: browser.BrowserSessionParams, + body: browser.BrowserTabOpen, + response: browser.BrowserSessionView, + }), + closeTab: ep({ + method: "delete", + path: "/browser/sessions/{id}/tabs/{tabId}", + scope: "admin", + summary: "关闭标签页(需持有控制权)", + params: browser.BrowserTabParams, + response: browser.BrowserSessionView, + }), + navigate: ep({ + method: "post", + path: "/browser/sessions/{id}/navigate", + scope: "admin", + summary: "地址栏导航或后退/前进/刷新(需持有控制权;限站点域名)", + params: browser.BrowserSessionParams, + body: browser.BrowserNavigate, + response: browser.BrowserNavigation, + }), + snapshot: ep({ + method: "get", + path: "/browser/sessions/{id}/snapshot", + scope: "admin", + summary: "结构化页面快照(凭证已遮蔽)", + params: browser.BrowserSessionParams, + query: browser.BrowserSnapshotQuery, + response: browser.BrowserSnapshot, + }), + screenshot: ep({ + method: "get", + path: "/browser/sessions/{id}/screenshot", + scope: "admin", + summary: "当前画面截图(base64,只给原站视图,不进模型)", + params: browser.BrowserSessionParams, + query: browser.BrowserScreenshotQuery, + response: browser.BrowserScreenshotView, + }), + click: ep({ + method: "post", + path: "/browser/sessions/{id}/click", + scope: "admin", + summary: "点击(快照引用 / 选择器 / 截图坐标;需持有控制权)", + params: browser.BrowserSessionParams, + body: browser.BrowserClick, + response: browser.BrowserActionResult, + }), + type: ep({ + method: "post", + path: "/browser/sessions/{id}/type", + scope: "admin", + summary: "输入文本 / 站点账号凭证 / 按键(需持有控制权;凭证由服务端注入)", + params: browser.BrowserSessionParams, + body: browser.BrowserType, + response: browser.BrowserActionResult, + }), + scroll: ep({ + method: "post", + path: "/browser/sessions/{id}/scroll", + scope: "admin", + summary: "滚动页面(需持有控制权)", + params: browser.BrowserSessionParams, + body: browser.BrowserScroll, + response: browser.BrowserActionResult, + }), + select: ep({ + method: "post", + path: "/browser/sessions/{id}/select", + scope: "admin", + summary: "下拉框选择(需持有控制权)", + params: browser.BrowserSessionParams, + body: browser.BrowserSelect, + response: browser.BrowserActionResult, + }), + extract: ep({ + method: "post", + path: "/browser/sessions/{id}/extract", + scope: "admin", + summary: "按 JSON Schema(x-ref)提取页面字段", + params: browser.BrowserSessionParams, + body: browser.BrowserExtract, + response: browser.BrowserExtractResult, + }), + resource: ep({ + method: "post", + path: "/browser/sessions/{id}/resource", + scope: "organize.execute", + summary: "解析当前原站资源详情,供独立手动下载使用", + params: browser.BrowserSessionParams, + body: browser.BrowserResourceRequest, + response: browser.BrowserResourceResult, + }), + download: ep({ + method: "post", + path: "/browser/sessions/{id}/download", + scope: "organize.execute", + summary: + "捕获浏览器下载 → 后端暂存并核验 infohash、匹配本地资源;给出 submit 时经 POST /acquisition-plans 同一流程提交", + params: browser.BrowserSessionParams, + body: browser.BrowserDownload, + response: browser.BrowserDownloadResult, + }), + reconnect: ep({ + method: "post", + path: "/browser/sessions/{id}/reconnect", + scope: "admin", + summary: + "浏览器服务重启后重新连接(幂等):上游上下文还在就什么都不做;否则注入账号 Cookie、新开标签页回到原地址,控制权不变;失败 503 browser_reconnect_failed", + params: browser.BrowserSessionParams, + response: browser.BrowserSessionView, + }), + acquire: ep({ + method: "post", + path: "/browser/sessions/{id}/control/acquire", + scope: "admin", + summary: "人工接管:先停止该会话新的自动化输入并等进行中的操作收尾,再发放人工租约", + params: browser.BrowserSessionParams, + body: browser.BrowserControlAcquire, + response: browser.BrowserControlResult, + }), + release: ep({ + method: "post", + path: "/browser/sessions/{id}/control/release", + scope: "admin", + summary: "交还控制权:智能体恢复前必须重新读取页面,旧元素引用失效", + params: browser.BrowserSessionParams, + body: browser.BrowserControlRelease, + response: browser.BrowserControlResult, + }), + }, +} as const; diff --git a/packages/contracts/src/api/content.ts b/packages/contracts/src/api/content.ts new file mode 100644 index 00000000..ba02f55c --- /dev/null +++ b/packages/contracts/src/api/content.ts @@ -0,0 +1,201 @@ +import { z } from "zod"; +import * as assets from "../assets.ts"; +import { Id, IdParam } from "../common.ts"; +import * as content from "../content.ts"; +import * as identity from "../identity.ts"; +import { + crud, + del, + ep, + FieldOverrideResult, + FieldParam, + IdentityRevisionList, + R, + TaskAccepted, + UnitFieldOverrideResult, + UnitFieldParam, +} from "./shared.ts"; + +export const contentApi = { + // ── 内容 / 身份 ── + mediaEntities: { + ...crud( + "/media-entities", + "作品", + { + row: content.MediaEntity, + create: content.MediaEntityCreate, + update: content.MediaEntityUpdate, + filters: { + kind: content.MediaKind.optional(), + status: content.MediaEntityStatus.optional(), + q: z.string().max(200).optional(), + }, + }, + R, + ), + merge: ep({ + method: "post", + path: "/media-entities/{id}/merge", + scope: "admin", + summary: "合并重复作品(保留旧 ID 跳转)", + params: IdParam, + body: z.object({ intoId: Id, reason: z.string().max(2000) }), + response: content.MediaEntity, + }), + detail: ep({ + method: "get", + path: "/media-entities/{id}/detail", + scope: "read", + summary: "作品详情:外部身份、别名、内容单元、字段来源与覆盖(旧 ID 自动跳转)", + params: IdParam, + response: content.MediaEntityDetail, + }), + setIdentity: ep({ + method: "post", + path: "/media-entities/{id}/identities", + scope: "organize.execute", + summary: "人工纠错外部映射:旧映射退役(留修订),新映射 accepted + 人工锁", + params: IdParam, + body: identity.EntityIdentitySet, + response: z.object({ + mediaEntityId: Id, + changed: z.boolean(), + identityId: Id.optional(), + retired: z.array(Id).optional(), + }), + }), + refresh: ep({ + method: "post", + path: "/media-entities/{id}/refresh", + scope: "organize.execute", + summary: + "刷新元数据(字段级;用户锁优先,不动路径)。标题 / 简介按设置 metadata.languages 回退;full=1 拉取全部季的每集信息", + params: IdParam, + query: z.object({ full: z.enum(["0", "1", "true", "false"]).optional() }), + response: TaskAccepted, + }), + setField: ep({ + method: "put", + path: "/media-entities/{id}/fields/{field}", + scope: "organize.execute", + summary: "覆盖作品字段并锁定(自动刷新不再改写)", + params: FieldParam, + body: content.FieldOverrideInput, + response: FieldOverrideResult, + }), + clearField: ep({ + method: "delete", + path: "/media-entities/{id}/fields/{field}", + scope: "organize.execute", + summary: "取消字段覆盖与锁定", + params: FieldParam, + response: FieldOverrideResult, + }), + }, + contentUnits: { + ...crud( + "/content-units", + "内容单元", + { + row: content.ContentUnit, + update: content.ContentUnitUpdate, + filters: { mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() }, + }, + R, + ), + setField: ep({ + method: "put", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "覆盖季 / 集字段并锁定(元数据刷新不再改写)", + params: UnitFieldParam, + body: content.FieldOverrideInput, + response: UnitFieldOverrideResult, + }), + clearField: ep({ + method: "delete", + path: "/content-units/{id}/fields/{field}", + scope: "organize.execute", + summary: "取消季 / 集字段覆盖与锁定", + params: UnitFieldParam, + response: UnitFieldOverrideResult, + }), + }, + titleAliases: { + ...crud( + "/title-aliases", + "别名", + { + row: content.TitleAlias, + create: content.TitleAliasCreate, + filters: { mediaEntityId: Id.optional(), q: z.string().max(200).optional() }, + }, + R, + ), + remove: del("/title-aliases/{id}", "删除别名"), + }, + externalIdentities: { + ...crud( + "/external-identities", + "外部身份", + { + row: identity.ExternalIdentity, + create: identity.ExternalIdentityCreate, + update: identity.ExternalIdentityUpdate, + filters: { + mediaEntityId: Id.optional(), + provider: z.string().optional(), + status: identity.IdentityStatus.optional(), + }, + }, + R, + ), + revisions: ep({ + method: "get", + path: "/external-identities/{id}/revisions", + scope: "read", + summary: "身份修订历史", + params: IdParam, + response: IdentityRevisionList, + }), + }, + + // ── 文件(只读;写入只经整理/对账流程,不开放直接改) ── + mediaAssets: crud( + "/media-assets", + "资产", + { + row: assets.MediaAsset, + filters: { mediaEntityId: Id.optional(), status: assets.AssetStatus.optional() }, + }, + R, + ), + assetUnitCoverage: crud( + "/asset-unit-coverage", + "资产覆盖", + { + row: assets.AssetUnitCoverage, + filters: { mediaAssetId: Id.optional(), contentUnitId: Id.optional() }, + }, + R, + ), + assetLocations: crud( + "/asset-locations", + "资产位置", + { + row: assets.AssetLocation, + filters: { mediaAssetId: Id.optional(), status: assets.LocationStatus.optional() }, + }, + R, + ), + pathBindings: crud( + "/path-bindings", + "路径绑定", + { + row: assets.PathBinding, + filters: { mediaEntityId: Id.optional(), status: assets.BindingStatus.optional() }, + }, + R, + ), +} as const; diff --git a/packages/contracts/src/api/execution.ts b/packages/contracts/src/api/execution.ts new file mode 100644 index 00000000..43cf3cbc --- /dev/null +++ b/packages/contracts/src/api/execution.ts @@ -0,0 +1,126 @@ +import { z } from "zod"; +import { Id, IdParam } from "../common.ts"; +import * as board from "../download-board.ts"; +import * as execution from "../execution.ts"; +import { crud, ep, R } from "./shared.ts"; + +export const executionApi = { + // ── 执行 ── + tasks: { + ...crud( + "/tasks", + "任务", + { + row: execution.Task, + filters: { + status: execution.TaskStatus.optional(), + kind: execution.TaskKind.optional(), + subjectId: Id.optional(), + }, + }, + R, + ), + cancel: ep({ + method: "post", + path: "/tasks/{id}/cancel", + scope: "organize.execute", + summary: "取消任务(停止未来步骤)", + params: IdParam, + body: execution.TaskCancelRequest, + response: execution.Task, + }), + retry: ep({ + method: "post", + path: "/tasks/{id}/retry", + scope: "organize.execute", + summary: "重试失败任务", + params: IdParam, + response: execution.Task, + }), + operations: ep({ + method: "get", + path: "/tasks/{id}/operations", + scope: "read", + summary: "任务的操作账本(业务时间线)", + params: IdParam, + response: z.object({ items: z.array(execution.AssetOperation) }), + }), + }, + acquisitionPlans: { + ...crud( + "/acquisition-plans", + "下载计划", + { + row: execution.AcquisitionPlan, + filters: { slotId: Id.optional(), status: execution.PlanStatus.optional() }, + }, + R, + ), + create: ep({ + method: "post", + path: "/acquisition-plans", + scope: "organize.execute", + summary: "独立手动下载:不要求订阅,支持未知资源确认、下载器与目录选择,以及本次正常或洗版入库", + body: execution.ManualAcquisitionRequest, + response: execution.ManualAcquisitionResult, + }), + why: ep({ + method: "get", + path: "/acquisition-plans/{id}/why", + scope: "read", + summary: + "下载的来由:来源(订阅 / 智能体会话 / 手动)、目的(补缺 / 升级 / 换源)、被替换的版本、选中理由要点与原始评估", + params: IdParam, + response: board.DownloadWhy, + }), + cancel: ep({ + method: "post", + path: "/acquisition-plans/{id}/cancel", + scope: "organize.execute", + summary: + "取消还没提交到下载器的计划(待批准 / 已预留):释放收录版本与预算预留;已有下载任务的返回 409。待批准的计划请拒绝审批(拒绝后计划自动取消)", + params: IdParam, + response: execution.AcquisitionPlan, + }), + }, + downloadTasks: { + ...crud( + "/download-tasks", + "下载任务", + { + row: execution.DownloadTask, + filters: { + instanceId: Id.optional(), + status: execution.DownloadStatus.optional(), + managed: z.enum(["true", "false"]).optional(), + }, + }, + R, + ), + board: ep({ + method: "get", + path: "/download-tasks/board", + scope: "read", + summary: + "下载面板:按进行中 / 已完成分组列出下载任务(进行中含还没提交、在等批准或下载盘空间的计划,kind=plan),附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", + query: board.DownloadBoardQuery, + response: board.DownloadBoard, + }), + pause: ep({ + method: "post", + path: "/download-tasks/{id}/pause", + scope: "organize.execute", + summary: "暂停(记为用户暂停,阈值恢复不会自动恢复)", + params: IdParam, + response: execution.DownloadTask, + }), + resume: ep({ + method: "post", + path: "/download-tasks/{id}/resume", + scope: "organize.execute", + summary: "恢复下载", + params: IdParam, + response: execution.DownloadTask, + }), + }, +} as const; diff --git a/packages/contracts/src/api/instances.ts b/packages/contracts/src/api/instances.ts new file mode 100644 index 00000000..d079c192 --- /dev/null +++ b/packages/contracts/src/api/instances.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { IdParam } from "../common.ts"; +import * as instances from "../instances.ts"; +import { crud, del, ep, instanceTest, R } from "./shared.ts"; + +export const instancesApi = { + // ── 实例 ── + libraryRoots: { + ...crud( + "/library-roots", + "库根", + { + row: instances.LibraryRoot, + create: instances.LibraryRootCreate, + update: instances.LibraryRootUpdate, + }, + R, + ), + test: instanceTest("/library-roots/{id}/test", "检查库根在线(目录 + 哨兵文件)"), + remove: del("/library-roots/{id}", "删除库根"), + }, + downloaderInstances: { + ...crud( + "/downloader-instances", + "下载器实例", + { + row: instances.DownloaderInstance, + create: instances.DownloaderInstanceCreate, + update: instances.DownloaderInstanceUpdate, + }, + R, + ), + test: ep({ + method: "post", + path: "/downloader-instances/{id}/test", + scope: "admin", + summary: "测试下载器连接", + params: IdParam, + response: z.object({ ok: z.boolean(), version: z.string().nullable(), message: z.string().nullable() }), + }), + remove: del("/downloader-instances/{id}", "删除下载器实例"), + }, + mediaServerInstances: { + ...crud( + "/media-server-instances", + "媒体服务器实例", + { + row: instances.MediaServerInstance, + create: instances.MediaServerInstanceCreate, + update: instances.MediaServerInstanceUpdate, + }, + R, + ), + test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"), + remove: del("/media-server-instances/{id}", "删除媒体服务器实例"), + }, + pathMappings: { + ...crud( + "/path-mappings", + "路径映射", + { + row: instances.PathMapping, + create: instances.PathMappingCreate, + update: instances.PathMappingUpdate, + }, + R, + ), + verify: ep({ + method: "post", + path: "/path-mappings/{id}/verify", + scope: "admin", + summary: "校验路径映射可读写与挂载健康", + params: IdParam, + response: instances.PathMapping, + }), + remove: del("/path-mappings/{id}", "删除路径映射"), + }, +} as const; diff --git a/packages/contracts/src/api/library.ts b/packages/contracts/src/api/library.ts new file mode 100644 index 00000000..dd9162a3 --- /dev/null +++ b/packages/contracts/src/api/library.ts @@ -0,0 +1,210 @@ +import { z } from "zod"; +import { MetadataEnrichOutput } from "../capabilities-io.ts"; +import { cursorPage, IdParam } from "../common.ts"; +import * as execution from "../execution.ts"; +import * as identity from "../identity.ts"; +import * as libraryVersions from "../library-versions.ts"; +import * as organization from "../organization.ts"; +import * as scrape from "../scrape.ts"; +import * as search from "../search.ts"; +import { ep, ReleaseIdentityResult, TaskAccepted } from "./shared.ts"; + +export const libraryApi = { + // ── 待识别池 ── + identityPool: { + list: ep({ + method: "get", + path: "/identity-pool", + scope: "read", + summary: "未识别资源(可按原始标题检索;候选不建正式作品)", + query: identity.IdentityPoolQuery, + response: cursorPage(identity.IdentityPoolItem), + }), + assign: ep({ + method: "post", + path: "/identity-pool/{id}/assign", + scope: "organize.execute", + summary: "为未识别资源手动指定候选 / 本地作品 / 外部身份", + params: IdParam, + body: identity.ReleaseIdentityAssign, + response: ReleaseIdentityResult, + }), + dismiss: ep({ + method: "post", + path: "/identity-pool/{id}/dismiss", + scope: "organize.execute", + summary: "忽略未识别资源(不再自动复查)", + params: IdParam, + body: z.object({ note: z.string().max(2000).optional() }), + response: identity.IdentityCandidate, + }), + retry: ep({ + method: "post", + path: "/identity-pool/{id}/retry", + scope: "organize.execute", + summary: "立即重新识别", + params: IdParam, + response: TaskAccepted, + }), + // META-1 元数据补全:先看建议(metadata.enrich,不改作品),再在控制台应用 + enrich: ep({ + method: "post", + path: "/identity-pool/{id}/enrich", + scope: "organize.execute", + summary: "元数据补全建议(网页搜索 + 白名单网页 + 模型抽取;不改作品;同一标题 7 天内返回上次结果)", + params: IdParam, + body: identity.EnrichRequest, + response: MetadataEnrichOutput, + }), + enrichApply: ep({ + method: "post", + path: "/identity-pool/{id}/enrich/apply", + scope: "admin", + auth: "session", + summary: "应用元数据补全建议:建本地临时作品(或并入同名作品)并关联未识别资源(只接受控制台管理员会话)", + params: IdParam, + body: identity.EnrichApplyInput, + response: identity.EnrichApplyResult, + }), + // META-2 补全命中率(按来源类别) + enrichStats: ep({ + method: "get", + path: "/identity/enrich-stats", + scope: "read", + summary: "元数据补全命中率(最近 N 天,按来源类别:百科、平台、豆瓣摘要、其它网站摘要)", + query: identity.EnrichStatsQuery, + response: identity.EnrichStats, + }), + }, + + // ── 媒体库 / 库存 / 首页 ── + library: { + list: ep({ + method: "get", + path: "/library", + scope: "read", + summary: "媒体库列表(已登记资产或正在追踪的作品)", + query: organization.LibraryListQuery, + response: cursorPage(organization.LibraryItem), + }), + get: ep({ + method: "get", + path: "/library/{id}", + scope: "read", + summary: "作品详情:实际路径、身份映射、元数据来源、各收录版本、候选拒绝理由、下载历史与校验结果", + params: IdParam, + response: organization.LibraryDetail, + }), + versions: ep({ + method: "get", + path: "/library/{id}/versions", + scope: "read", + summary: "作品的入库版本:每季每集的本地文件版本与规格、季 / 作品汇总、与版本目标的比对", + params: IdParam, + query: libraryVersions.LibraryVersionsQuery, + response: libraryVersions.LibraryVersions, + }), + scrape: ep({ + method: "post", + path: "/library/{id}/scrape", + scope: "organize.execute", + summary: "刮削:把作品的 NFO 与图片写进媒体库目录(后台任务 scrapeMedia;覆盖方式缺省按设置)", + params: IdParam, + body: scrape.LibraryScrapeRequest, + response: scrape.LibraryScrapeResult, + }), + reconcile: ep({ + method: "post", + path: "/library/reconcile", + scope: "admin", + summary: "立即做一轮资产对账(库根哨兵 + 增量 stat;不删记录、不补下载)", + body: z.object({ libraryRootId: z.uuid().optional() }), + response: z.object({ runId: z.uuid(), stats: z.record(z.string(), z.unknown()) }), + }), + }, + inventory: { + overlay: ep({ + method: "get", + path: "/inventory", + scope: "read", + summary: "库存叠加(站点工作台):按作品返回已有版本 / 收录版本缺口 / 下载中", + query: organization.InventoryQuery, + response: organization.InventoryResponse, + }), + mediaServers: ep({ + method: "get", + path: "/inventory/media-servers", + scope: "read", + summary: "Emby 兼容库存查询(多实例;接口失败/截断 = unknown,不等于缺失)", + query: organization.MediaServerInventoryQuery, + response: organization.MediaServerInventory, + }), + }, + dashboard: { + get: ep({ + method: "get", + path: "/dashboard", + scope: "read", + summary: "首页聚合:自动化概览/当前工作/需要我处理/缓存与保留/来源健康/最近产出", + response: organization.Dashboard, + }), + }, + downloadCache: { + cleanup: ep({ + method: "post", + path: "/download-cache/cleanup", + scope: "admin", + summary: "立即按阈值清理下载缓存(只清已验证入库且满足保留/H&R 的纳管任务)", + body: z.object({ downloaderInstanceId: z.uuid().optional() }), + response: z.object({ report: z.record(z.string(), z.unknown()) }), + }), + }, + + // ── 全局搜索(W9-G):顶栏搜索 / 命令面板 / 资源结果页 ── + search: { + all: ep({ + method: "get", + path: "/search", + scope: "read", + summary: + "全局搜索聚合:本地作品 / TMDB 候选 / 跨站资源(15s 超时、并发合并、30s 缓存、库存叠加)/ 页面与操作;每组带 tookMs", + query: search.SearchQuery, + response: search.SearchResponse, + }), + releases: ep({ + method: "get", + path: "/search/releases", + scope: "read", + summary: "跨站资源结果页:站点/分辨率/来源/大小/免费/H&R 筛选、排序、游标;与 /search 共用缓存", + query: search.ReleaseSearchQuery, + response: search.ReleaseSearchPage, + }), + plan: ep({ + method: "post", + path: "/search/releases/{siteId}/{siteReleaseId}/plan", + scope: "organize.execute", + summary: "从搜索结果直接下载:定位资源后走 POST /acquisition-plans 同一流程", + params: search.SearchReleaseParams, + body: search.SearchReleasePlanRequest, + response: execution.ManualAcquisitionResult, + }), + tmdbDetail: ep({ + method: "get", + path: "/media/tmdb/{type}/{id}", + scope: "read", + summary: + "仅 TMDB 作品的详情:TMDB 信息与季列表、本地库存对比、是否已建档、搜资源的查询词;include=mediaServers 同时查 Emby", + params: search.TmdbMediaParams, + query: search.TmdbMediaQuery, + response: search.TmdbMediaDetail, + }), + tmdbSeason: ep({ + method: "get", + path: "/media/tmdb/tv/{id}/season/{season}", + scope: "read", + summary: "仅 TMDB 剧集的季详情与每集信息(标题 / 简介按语言回退链;走 TMDB 请求缓存)", + params: search.TmdbSeasonParams, + response: search.TmdbSeasonDetailView, + }), + }, +} as const; diff --git a/packages/contracts/src/api/ops.ts b/packages/contracts/src/api/ops.ts new file mode 100644 index 00000000..dae274b7 --- /dev/null +++ b/packages/contracts/src/api/ops.ts @@ -0,0 +1,95 @@ +import { CursorQuery, cursorPage, Id, IdParam } from "../common.ts"; +import * as ops from "../ops.ts"; +import { crud, del, ep, R } from "./shared.ts"; + +export const opsApi = { + // ── 运维 ── + reconcileRuns: crud( + "/reconcile-runs", + "校验运行", + { + row: ops.ReconcileRun, + filters: { kind: ops.ReconcileKind.optional(), status: ops.ReconcileStatus.optional() }, + }, + R, + ), + serverMappings: crud( + "/server-mappings", + "库存映射", + { + row: ops.ServerMapping, + filters: { + mediaServerInstanceId: Id.optional(), + mediaEntityId: Id.optional(), + matchStatus: ops.ServerMatchStatus.optional(), + }, + }, + R, + ), + apiKeys: { + list: ep({ + method: "get", + path: "/api-keys", + scope: "admin", + summary: "API Key 列表", + query: CursorQuery, + response: cursorPage(ops.ApiKey), + }), + create: ep({ + method: "post", + path: "/api-keys", + scope: "admin", + summary: "新建 API Key(明文只返回一次)", + body: ops.ApiKeyCreate, + response: ops.ApiKeyCreated, + }), + update: ep({ + method: "patch", + path: "/api-keys/{id}", + scope: "admin", + summary: "修改/撤销 API Key", + params: IdParam, + body: ops.ApiKeyUpdate, + response: ops.ApiKey, + }), + revoke: del("/api-keys/{id}", "撤销 API Key"), + }, + auditEvents: { + list: ep({ + method: "get", + path: "/audit-events", + scope: "admin", + summary: "审计事件(按 actor/action/target 过滤,新→旧)", + query: ops.AuditEventQuery, + response: cursorPage(ops.AuditEvent), + }), + }, + secrets: { + list: ep({ + method: "get", + path: "/secrets", + scope: "admin", + summary: "凭证元数据列表", + query: CursorQuery, + response: cursorPage(ops.Secret), + }), + create: ep({ + method: "post", + path: "/secrets", + scope: "admin", + summary: "新建凭证(只写)", + body: ops.SecretCreate, + response: ops.Secret, + }), + update: ep({ + method: "patch", + path: "/secrets/{id}", + scope: "admin", + summary: "轮换凭证", + params: IdParam, + body: ops.SecretUpdate, + response: ops.Secret, + }), + remove: del("/secrets/{id}", "删除凭证(被引用时 409)"), + }, +} as const; diff --git a/packages/contracts/src/api/organize.ts b/packages/contracts/src/api/organize.ts new file mode 100644 index 00000000..872c0f5a --- /dev/null +++ b/packages/contracts/src/api/organize.ts @@ -0,0 +1,322 @@ +import { z } from "zod"; +import * as classification from "../classification.ts"; +import { cursorPage, IdParam } from "../common.ts"; +import * as files from "../files.ts"; +import * as organization from "../organization.ts"; +import * as organizeSources from "../organize-sources.ts"; +import { ep } from "./shared.ts"; + +export const organizeApi = { + // ── 整理(公开合同) ── + organization: { + list: ep({ + method: "get", + path: "/organization-records", + scope: "organize.read", + summary: "整理记录列表", + query: organization.OrganizationListQuery, + response: cursorPage(organization.PublicOrganizationRecord), + }), + get: ep({ + method: "get", + path: "/organization-records/{id}", + scope: "organize.read", + summary: "整理记录详情(含逐文件结果)", + params: IdParam, + response: organization.PublicOrganizationRecord, + }), + changes: ep({ + method: "get", + path: "/organization-records/changes", + scope: "organize.read", + summary: "按 changeSeq 增量读取(含既有记录状态更新)", + query: organization.OrganizationChangesQuery, + response: organization.OrganizationChangesResponse, + }), + stats: ep({ + method: "get", + path: "/organization-records/stats", + scope: "organize.read", + summary: "整理记录按状态计数(筛选同列表)", + query: organization.OrganizationStatsQuery, + response: organization.OrganizationStats, + }), + remove: ep({ + method: "post", + path: "/organization-records/delete", + scope: "organize.execute", + summary: "删除整理记录,可选把源文件 / 媒体库文件移入回收站(dryRun 只核对)", + body: organization.OrganizationDeleteRequest, + response: organization.OrganizationDeleteResult, + }), + create: ep({ + method: "post", + path: "/organization-records", + scope: "organize.execute", + summary: "手动发起整理", + body: organization.OrganizeRequest, + response: organization.OrganizeAccepted, + }), + }, + + // ── 文件管理(docs/runbook/files.md;只浏览已登记根,写操作两段式 dryRun → planId) ── + classification: { + get: ep({ + method: "get", + path: "/classification", + scope: "organize.read", + summary: "分类配置", + response: classification.ClassificationConfigView, + }), + put: ep({ + method: "put", + path: "/classification", + scope: "admin", + summary: "保存分类配置", + body: classification.ClassificationPut, + response: classification.ClassificationConfigView, + }), + preview: ep({ + method: "post", + path: "/classification/preview", + scope: "organize.read", + summary: "测试分类规则", + body: classification.ClassificationPreviewInput, + response: classification.ClassificationPreviewResult, + }), + }, + organizeSources: { + list: ep({ + method: "get", + path: "/organize-sources", + scope: "organize.read", + summary: "整理来源", + response: organizeSources.OrganizeSourceList, + }), + save: ep({ + method: "put", + path: "/organize-sources/{id}", + scope: "admin", + summary: "保存整理来源", + params: IdParam, + body: organizeSources.OrganizeSourceInput, + response: organizeSources.OrganizeSourceView, + }), + remove: ep({ + method: "delete", + path: "/organize-sources/{id}", + scope: "admin", + summary: "删除整理来源", + params: IdParam, + response: z.object({ deleted: z.boolean() }), + }), + stock: ep({ + method: "post", + path: "/organize-sources/{id}/stock", + scope: "organize.execute", + summary: "扫描存量文件", + params: IdParam, + body: organizeSources.OrganizeStockInput, + response: organizeSources.OrganizeStockResult, + }), + }, + files: { + roots: ep({ + method: "get", + path: "/files/roots", + scope: "organize.read", + summary: "可浏览的根(库根 + 下载根),含实时健康与空间", + response: files.FileRootList, + }), + list: ep({ + method: "get", + path: "/files", + scope: "organize.read", + summary: "列目录(分页/排序/过滤/截断,行内业务状态);根离线 503", + query: files.FilesListQuery, + response: files.FilesListResponse, + }), + stat: ep({ + method: "get", + path: "/files/stat", + scope: "organize.read", + summary: "单项详情:realpath/inode/nlink/sidecars/路径绑定", + query: files.FileStatQuery, + response: files.FileStat, + }), + mkdir: ep({ + method: "post", + path: "/files/mkdir", + scope: "organize.execute", + summary: "新建目录(非递归,已存在 409,仅 writable 根)", + body: files.FilesMkdirRequest, + response: files.FilesMkdirResult, + }), + rename: ep({ + method: "post", + path: "/files/rename", + scope: "organize.execute", + summary: "同目录改名(不覆盖;同步 asset_location 与 path_binding 修订;做种中 409)", + body: files.FilesRenameRequest, + response: files.FilesRenameResult, + }), + move: ep({ + method: "post", + path: "/files/move", + scope: "organize.execute", + summary: "移动/复制到目录:dryRun 返回 planId,执行回传 planId(源变更 409 plan_stale)", + body: files.FilesMoveRequest, + response: files.FilesTransferResult, + }), + copy: ep({ + method: "post", + path: "/files/copy", + scope: "organize.execute", + summary: "复制到目录(同 /files/move mode=copy)", + body: files.FilesCopyRequest, + response: files.FilesTransferResult, + }), + organize: ep({ + method: "post", + path: "/files/organize", + scope: "organize.execute", + summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", + body: files.FilesOrganizeRequest, + response: files.FilesOrganizeResult, + }), + delete: ep({ + method: "post", + path: "/files/delete", + scope: "organize.execute", + summary: "删除(默认进回收站;引用检查:做种/资产/绑定;permanent 仅 admin 会话)", + body: files.FilesDeleteRequest, + response: files.FilesDeleteResult, + }), + trash: ep({ + method: "get", + path: "/files/trash", + scope: "organize.read", + summary: "回收站列表", + query: files.FilesTrashQuery, + response: files.FilesTrashList, + }), + trashRestore: ep({ + method: "post", + path: "/files/trash/restore", + scope: "organize.execute", + summary: "从回收站还原(原位被占的项失败 conflict)", + body: files.FilesTrashRestoreRequest, + response: files.FilesTrashRestoreResult, + }), + trashPurge: ep({ + method: "post", + path: "/files/trash/purge", + scope: "admin", + auth: "session", + summary: "清空回收站(缺省只清过期项;仅管理员控制台会话,API Key 与 DEV key 一律 403)", + body: files.FilesTrashPurgeRequest, + response: files.FilesTrashPurgeResult, + }), + identify: ep({ + method: "post", + path: "/files/identify", + scope: "organize.read", + summary: "识别(只读):解析 + 本地/TMDB 召回前 5 候选,不落库", + body: files.FilesIdentifyRequest, + response: files.FilesIdentifyResult, + }), + scrape: ep({ + method: "post", + path: "/files/scrape", + scope: "organize.execute", + summary: + "刮削:对目录或视频文件就地写 NFO 与图片(按路径识别作品,不要求已登记;认不准时返回候选,选定后再提交)", + body: files.FilesScrapeRequest, + response: files.FilesScrapeResult, + }), + scrapeRecords: ep({ + method: "get", + path: "/files/scrape-records", + scope: "organize.read", + summary: "按 id 查刮削记录(目录、识别结果、逐文件结果)", + query: files.FilesScrapeRecordsQuery, + response: files.FilesScrapeRecordsResult, + }), + probe: ep({ + method: "post", + path: "/files/probe", + scope: "organize.execute", + summary: "ffprobe 摘要(≤20 项;已登记资产写回 media_info,所以要 organize.execute)", + body: files.FilesProbeRequest, + response: files.FilesProbeResult, + }), + download: ep({ + method: "get", + path: "/files/download", + scope: "admin", + auth: "session", + summary: "下载小文件(nfo/字幕/图片/文本/.torrent,≤20 MiB;流式;仅管理员控制台会话;审计)", + query: files.FilesDownloadQuery, + response: files.FilesDownloadResponse, + }), + preview: ep({ + method: "get", + path: "/files/preview", + scope: "admin", + auth: "session", + summary: + "结构化预览(文本/NFO 前 1 MiB 带编码探测、字幕前 200 条、.torrent 文件列表、图片尺寸;仅管理员控制台会话)", + query: files.FilesPreviewQuery, + response: files.FilePreview, + }), + records: ep({ + method: "get", + path: "/files/records", + scope: "organize.read", + summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)", + query: files.FilesRecordsQuery, + response: files.FilesRecordsResult, + }), + // W9-H 文件管理 v2 后端缺口 + entityFromCandidate: ep({ + method: "post", + path: "/files/entity-from-candidate", + scope: "organize.execute", + summary: "按 TMDB 候选就地建本地作品 + 外部映射(accepted/manual)+ 季集单元;幂等", + body: files.FilesEntityFromCandidateRequest, + response: files.FilesEntityFromCandidateResult, + }), + scrapeBatch: ep({ + method: "post", + path: "/files/scrape-batch", + scope: "organize.execute", + summary: "批量刷新元数据:按作品去重入队 refreshMetadata(锁定字段不变)", + body: files.FilesScrapeBatchRequest, + response: files.FilesScrapeBatchResult, + }), + tree: ep({ + method: "get", + path: "/files/tree", + scope: "organize.read", + summary: "目录摘要:上一级、子目录(是否有下级)、视频/已登记计数(树与上一级用)", + query: files.FilesTreeQuery, + response: files.FilesTreeResult, + }), + recordSources: ep({ + method: "get", + path: "/files/record-sources", + scope: "organize.read", + summary: "整理记录的源路径换算成登记目录 FileRef(重新整理用)", + query: files.FilesRecordSourcesQuery, + response: files.FilesRecordSourcesResult, + }), + suggestName: ep({ + method: "post", + path: "/files/suggest-name", + scope: "organize.read", + summary: "按命名模板(作品 > 库根 > 全局)生成推荐名;识别不可信时 name=null + 原因", + body: files.FilesSuggestNameRequest, + response: files.FilesSuggestNameResult, + }), + }, +} as const; diff --git a/packages/contracts/src/api/platform.ts b/packages/contracts/src/api/platform.ts new file mode 100644 index 00000000..f2305f5f --- /dev/null +++ b/packages/contracts/src/api/platform.ts @@ -0,0 +1,298 @@ +import { z } from "zod"; +import * as cache from "../cache.ts"; +import { WebSearchTestInput, WebSearchTestResult } from "../capabilities-io.ts"; +import { CapabilityDescriptor, KnownCapabilityId } from "../capability.ts"; +import { Id, IdParam } from "../common.ts"; +import * as mcp from "../mcp.ts"; +import * as pi from "../pi.ts"; +import * as settingsNs from "../settings.ts"; +import { GlobalSettings, GlobalSettingsUpdate } from "../settings.ts"; +import { ep, OkResult } from "./shared.ts"; + +export const platformApi = { + // ── 能力 ── + capabilities: { + list: ep({ + method: "get", + path: "/capabilities", + scope: "read", + summary: "能力注册中心描述", + response: z.object({ items: z.array(CapabilityDescriptor) }), + }), + invoke: ep({ + method: "post", + path: "/capabilities/{capabilityId}/invoke", + scope: "read", + summary: "调用能力(实际所需 scope 见描述符;服务端按能力再次鉴权)", + params: z.object({ capabilityId: KnownCapabilityId }), + body: z.object({ input: z.unknown(), preview: z.boolean().default(false) }), + response: z.object({ output: z.unknown(), taskId: Id.nullable(), planId: Id.nullable() }), + }), + stats: ep({ + method: "get", + path: "/capabilities/stats", + scope: "read", + summary: "能力调用统计:每能力调用数、失败数、平均耗时、最近调用、调用来源(内置智能体 / MCP / REST)", + query: mcp.CapabilityStatsQuery, + response: mcp.CapabilityStatsResponse, + }), + tools: ep({ + method: "get", + path: "/capabilities/tools", + scope: "read", + summary: "能力清单:描述符、工具名、输入输出 JSON Schema、当前授权状态", + response: mcp.CapabilityToolsResponse, + }), + }, + + // ── 对外 MCP 服务端(Streamable HTTP 在 /mcp,不进 OpenAPI;这里是状态与配置)── + mcp: { + status: ep({ + method: "get", + path: "/mcp/status", + scope: "admin", + summary: "MCP 服务端状态:启用、允许的 API Key、当前会话、最近调用、限速", + response: mcp.McpServerStatus, + }), + update: ep({ + method: "patch", + path: "/mcp/config", + scope: "admin", + summary: "修改 MCP 服务端配置(启用开关、允许的 API Key)", + body: mcp.McpServerConfigUpdate, + response: mcp.McpServerConfig, + }), + }, + + // ── 缓存(C2-A,docs/runbook/cache.md) ── + cache: { + tmdb: ep({ + method: "get", + path: "/cache/tmdb", + scope: "admin", + summary: "TMDB 缓存条目(请求响应与识别结果:键、过期时间、大小)与上限 / TTL", + query: cache.TmdbCacheQuery, + response: cache.TmdbCacheList, + }), + tmdbClear: ep({ + method: "post", + path: "/cache/tmdb/clear", + scope: "admin", + summary: "清空 TMDB 缓存(请求响应与识别结果)", + response: cache.CacheClearResult, + }), + images: ep({ + method: "get", + path: "/cache/images", + scope: "admin", + summary: "图片代理缓存用量", + response: cache.ImageCacheStatus, + }), + imagesClear: ep({ + method: "post", + path: "/cache/images/clear", + scope: "admin", + summary: "清空图片代理缓存", + response: cache.CacheClearResult, + }), + }, + + // ── 网页搜索(WEB-1,docs/runbook/websearch.md) ── + websearch: { + test: ep({ + method: "post", + path: "/websearch/test", + scope: "admin", + summary: "用已保存的搜索服务 Key 实际查询一次(不走缓存,计入每分钟上限)", + body: WebSearchTestInput, + response: WebSearchTestResult, + }), + }, + + // ── 设置 ── + settings: { + get: ep({ method: "get", path: "/settings", scope: "read", summary: "全局设置", response: GlobalSettings }), + update: ep({ + method: "patch", + path: "/settings", + scope: "admin", + summary: "修改全局设置", + body: GlobalSettingsUpdate, + response: GlobalSettings, + }), + }, + + // ── 整理命名模板(W9-C,docs/design/naming-template.md):保存前服务端编译校验 ── + naming: { + variables: ep({ + method: "get", + path: "/naming/variables", + scope: "read", + summary: "命名变量表(名称对齐 MP,含说明/示例值/我们特有标记)、过滤器、预览内置样本", + response: settingsNs.NamingVariablesResponse, + }), + preview: ep({ + method: "post", + path: "/naming/preview", + scope: "read", + summary: + "预览:模板 + 样本(文件名 / 资源 / 资产 / 内置)→ 相对路径、段角色、错误(行列号)、提示;已有路径绑定时目录用绑定", + body: settingsNs.NamingPreviewInput, + response: settingsNs.NamingPreviewResult, + }), + templates: ep({ + method: "get", + path: "/naming/templates", + scope: "read", + summary: "全局 / 库根 / 作品三级模板与按类型的生效结果(作品 > 库根 > 全局)", + query: settingsNs.NamingTemplatesQuery, + response: settingsNs.NamingTemplatesView, + }), + saveTemplates: ep({ + method: "put", + path: "/naming/templates", + scope: "admin", + summary: "保存全局命名模板(逐条编译校验,有错 422 不保存)", + body: settingsNs.NamingSettingsPut, + response: settingsNs.NamingSettings, + }), + libraryRoot: ep({ + method: "put", + path: "/library-roots/{id}/naming", + scope: "admin", + summary: "库根级命名覆盖(按类型;null 清除)", + params: IdParam, + body: settingsNs.LibraryRootNamingPut, + response: settingsNs.LibraryRootNamingPut, + }), + entity: ep({ + method: "put", + path: "/media-entities/{id}/naming-override", + scope: "admin", + summary: "作品级命名覆盖(按类型;null 清除)。已有路径绑定的目录不受影响,只影响之后的文件名与新季目录", + params: IdParam, + body: settingsNs.EntityNamingOverridePut, + response: settingsNs.EntityNamingOverridePut, + }), + }, + + // ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ── + pi: { + providers: ep({ + method: "get", + path: "/pi/providers", + scope: "admin", + summary: "Pi provider 能力表(认证方式、订阅登录方式与条款提示、baseUrl 覆盖、env 字段、静态模型表)", + query: pi.PiProvidersQuery, + response: pi.PiProvidersResponse, + }), + list: ep({ + method: "get", + path: "/pi/upstreams", + scope: "admin", + summary: "模型服务列表(不含密钥)", + response: pi.PiUpstreamList, + }), + get: ep({ + method: "get", + path: "/pi/upstreams/{id}", + scope: "admin", + summary: "模型服务详情", + params: IdParam, + response: pi.PiUpstream, + }), + create: ep({ + method: "post", + path: "/pi/upstreams", + scope: "admin", + auth: "session", + summary: "新建模型服务(apiKey 只写,存 secret 表)", + body: pi.PiUpstreamCreate, + response: pi.PiUpstream, + }), + update: ep({ + method: "patch", + path: "/pi/upstreams/{id}", + scope: "admin", + auth: "session", + summary: "修改模型服务(provider 不可改;makeDefault 设默认模型服务)", + params: IdParam, + body: pi.PiUpstreamUpdate, + response: pi.PiUpstream, + }), + remove: ep({ + method: "delete", + path: "/pi/upstreams/{id}", + scope: "admin", + auth: "session", + summary: "删除模型服务(连同凭据;是默认模型服务时清空默认)", + params: IdParam, + response: OkResult, + }), + test: ep({ + method: "post", + path: "/pi/upstreams/{id}/test", + scope: "admin", + auth: "session", + summary: "测试模型服务:默认只解析凭据与校验模型(不产生费用);mode=completion 发一次最小补全", + params: IdParam, + body: pi.PiUpstreamTestRequest, + response: pi.PiUpstreamTestResult, + }), + models: ep({ + method: "get", + path: "/pi/upstreams/{id}/models", + scope: "admin", + summary: "模型列表:已获取过远程列表时返回远程与静态目录的合并(远程优先),否则静态目录", + params: IdParam, + response: pi.PiUpstreamModels, + }), + modelsRefresh: ep({ + method: "post", + path: "/pi/upstreams/{id}/models/refresh", + scope: "admin", + auth: "session", + summary: "获取模型列表:用该模型服务的凭证调 provider 的模型列表接口并存库;失败或不支持时回静态目录并给出原因", + params: IdParam, + response: pi.PiUpstreamModels, + }), + modelsProbe: ep({ + method: "post", + path: "/pi/models/probe", + scope: "admin", + auth: "session", + summary: "新建模型服务前试拉模型列表(凭证只用于这一次请求,不落库)", + body: pi.PiModelsProbeRequest, + response: pi.PiUpstreamModels, + }), + oauthStart: ep({ + method: "post", + path: "/pi/upstreams/{id}/oauth/start", + scope: "admin", + auth: "session", + summary: "开始订阅登录:返回授权链接(browser)或验证码(device)", + params: IdParam, + body: pi.PiOAuthStartRequest, + response: pi.PiOAuthStartResult, + }), + oauthComplete: ep({ + method: "post", + path: "/pi/upstreams/{id}/oauth/complete", + scope: "admin", + auth: "session", + summary: "完成订阅登录:browser 粘回地址栏 URL;device 服务端轮询(本次最多等 waitMs,未完成返回 pending)", + params: IdParam, + body: pi.PiOAuthCompleteRequest, + response: pi.PiOAuthCompleteResult, + }), + oauthLogout: ep({ + method: "post", + path: "/pi/upstreams/{id}/oauth/logout", + scope: "admin", + auth: "session", + summary: "退出订阅登录:删除本地 token(不会撤销提供方侧授权)", + params: IdParam, + response: pi.PiUpstream, + }), + }, +} as const; diff --git a/packages/contracts/src/api/review.ts b/packages/contracts/src/api/review.ts new file mode 100644 index 00000000..4051eb0a --- /dev/null +++ b/packages/contracts/src/api/review.ts @@ -0,0 +1,143 @@ +import { z } from "zod"; +import * as agent from "../agent.ts"; +import { cursorPage, Id, IdParam } from "../common.ts"; +import { crud, DecisionSummary, ep, R } from "./shared.ts"; + +export const reviewApi = { + // ── 人工 / AI ── + decisions: { + ...crud( + "/decisions", + "待处理事项", + { + row: agent.Decision, + filters: { status: agent.DecisionStatus.optional(), kind: agent.DecisionKind.optional() }, + }, + R, + ), + resolve: ep({ + method: "post", + path: "/decisions/{id}/resolve", + scope: "organize.execute", + summary: "处理待处理事项", + params: IdParam, + body: agent.DecisionResolveInput, + response: agent.Decision, + }), + summary: ep({ + method: "get", + path: "/decisions/summary", + scope: "read", + summary: "未关闭待处理事项按作品与问题类型聚合", + query: z.object({ status: agent.DecisionStatus.optional() }), + response: DecisionSummary, + }), + }, + approvals: { + ...crud( + "/approvals", + "审批", + { + row: agent.Approval, + filters: { status: agent.ApprovalStatus.optional(), planId: Id.optional() }, + }, + R, + ), + // 审批的申请与决定只在控制台管理员会话里做:API Key(哪怕持 organize.execute)不能给自己的计划开审批(验收 S1) + update: ep({ + method: "patch", + path: "/approvals/{id}", + scope: "admin", + auth: "session", + summary: "批准/拒绝/撤销单次审批(只接受控制台管理员会话)", + params: IdParam, + body: agent.ApprovalUpdate, + response: agent.Approval, + }), + create: ep({ + method: "post", + path: "/approvals", + scope: "admin", + auth: "session", + summary: "为计划申请单次审批(绑定 plan id + payload hash + revision + 有效期;只接受控制台管理员会话)", + body: agent.ApprovalRequest, + response: agent.Approval, + }), + }, + automationGrants: crud( + "/automation-grants", + "持续授权", + { + row: agent.AutomationGrant, + create: agent.AutomationGrantCreate, + update: agent.AutomationGrantUpdate, + filters: { status: agent.GrantStatus.optional() }, + }, + R, + ), + /** Agent 会话(W2-D):建 run 只入队持久任务 runAgent;增量经全局 SSE(agent.*),断线按 runSeq 用 afterSeq 补读 */ + agentRuns: { + list: ep({ + method: "get", + path: "/agent/runs", + scope: "read", + summary: "智能体运行列表(新→旧)", + query: agent.AgentRunListQuery, + response: cursorPage(agent.AgentRun), + }), + get: ep({ + method: "get", + path: "/agent/runs/{id}", + scope: "read", + summary: "智能体运行详情:事件按 seq 分页、追加消息、服务端核查的目标汇报", + params: IdParam, + query: agent.AgentRunDetailQuery, + response: agent.AgentRunDetail, + }), + create: ep({ + method: "post", + path: "/agent/runs", + scope: "organize.execute", + summary: "委托目标:建 agent_run 并入队 runAgent(proposeFirst = 先看方案再执行;mode 已弃用)", + body: agent.AgentRunStartInput, + response: agent.AgentRun, + }), + message: ep({ + method: "post", + path: "/agent/runs/{id}/messages", + scope: "organize.execute", + summary: "追加用户消息(纠正/补充);运行已结束时唤醒续跑", + params: IdParam, + body: agent.AgentRunMessageInput, + response: agent.AgentRun, + }), + // 回答 agent.propose_options(会话挂起等待中):选一个方案或都不要,会话随即继续 + choose: ep({ + method: "post", + path: "/agent/runs/{id}/choice", + scope: "organize.execute", + summary: "回答方案选择:选一个方案或都不要(说明原因),挂起的会话随即继续", + params: IdParam, + body: agent.AgentRunChoiceInput, + response: agent.AgentRun, + }), + abort: ep({ + method: "post", + path: "/agent/runs/{id}/abort", + scope: "organize.execute", + summary: "中止运行(停止未来步骤,不撤销已完成的外部动作)", + params: IdParam, + body: agent.AgentRunAbortInput, + response: agent.AgentRun, + }), + }, + artifacts: crud( + "/artifacts", + "产物", + { + row: agent.Artifact, + filters: { agentRunId: Id.optional(), taskId: Id.optional(), kind: agent.ArtifactKind.optional() }, + }, + R, + ), +} as const; diff --git a/packages/contracts/src/api/rules.ts b/packages/contracts/src/api/rules.ts new file mode 100644 index 00000000..3d88882a --- /dev/null +++ b/packages/contracts/src/api/rules.ts @@ -0,0 +1,413 @@ +import { z } from "zod"; +import { cursorPage, Id, IdParam } from "../common.ts"; +import * as policy from "../policy.ts"; +import { crud, del, ep, listQuery, R } from "./shared.ts"; + +export const rulesApi = { + // ── 规则 ── + subscriptions: { + ...crud( + "/subscriptions", + "订阅", + { + row: policy.Subscription, + create: policy.SubscriptionCreate, + update: policy.SubscriptionUpdate, + filters: { + status: policy.SubscriptionStatus.optional(), + mediaEntityId: Id.optional(), + mediaKind: policy.SubscriptionMediaKind.optional(), + /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ + q: z.string().max(200).optional(), + }, + }, + R, + ), + /** 日历(静态路径,须先于 /subscriptions/{id} 注册) */ + calendar: ep({ + method: "get", + path: "/subscriptions/calendar", + scope: "read", + summary: + "订阅日历:追踪中订阅范围内集的播出日期(同日同季聚合)与入库状态,电影取上映日期;没有播出日期的列在 unknown。from/to 为 YYYY-MM-DD,跨度不超过 62 天", + query: z.object({ + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + mediaKind: policy.SubscriptionMediaKind.optional(), + }), + response: policy.SubscriptionCalendar, + }), + reset: ep({ + method: "post", + path: "/subscriptions/{id}/reset", + scope: "admin", + summary: "重置进度:取消已预留未提交、下载任务已不在的计划,清延后记录,立即重评;仍在下载的计划与已入库资产保留", + params: IdParam, + response: policy.SubscriptionResetResult, + }), + /** 总览:在 crud 列表上加分组/排序/筛选,条目带卡片派生字段,另给各分组计数(docs/design/subscriptions.md §1) */ + list: ep({ + method: "get", + path: "/subscriptions", + scope: "read", + summary: + "订阅总览(分组、进度、各版本摘要、上次更新、下次搜索、站点、规则组;cancelled 只在显式 status=cancelled 时返回)", + query: listQuery({ + status: policy.SubscriptionStatus.optional(), + mediaEntityId: Id.optional(), + mediaKind: policy.SubscriptionMediaKind.optional(), + /** 作品标题 / 原名 / 别名 / 未建档检索词,模糊匹配 */ + q: z.string().max(200).optional(), + group: policy.SubscriptionGroup.optional(), + sort: policy.SubscriptionSort.optional(), + order: z.enum(["asc", "desc"]).optional(), + siteId: Id.optional(), + ruleGroupId: Id.optional(), + origin: policy.SubscriptionOrigin.optional(), + upgrading: z.enum(["true", "false"]).optional(), + }), + response: policy.SubscriptionOverviewPage, + }), + coverage: ep({ + method: "get", + path: "/subscriptions/{id}/coverage", + scope: "read", + summary: + "覆盖矩阵:季 → 集 × 收录版本(电影:内容版本 × 收录版本),每格 have/inflight/missing/skipped;缓存 30s,refresh=true 强制重算", + params: IdParam, + query: z.object({ refresh: z.enum(["true", "false"]).optional() }), + response: policy.SubscriptionCoverage, + }), + pause: ep({ + method: "post", + path: "/subscriptions/{id}/pause", + scope: "admin", + summary: "暂停订阅(不评估、不搜索)", + params: IdParam, + response: policy.Subscription, + }), + resume: ep({ + method: "post", + path: "/subscriptions/{id}/resume", + scope: "admin", + summary: "恢复已暂停的订阅(TMDB 未就绪回到 pending),立即重评并排上搜索", + params: IdParam, + response: policy.Subscription, + }), + resubscribe: ep({ + method: "post", + path: "/subscriptions/{id}/resubscribe", + scope: "admin", + summary: "重新订阅已完成/已取消的订阅(历史保留),立即重评并搜索", + params: IdParam, + response: policy.Subscription, + }), + batch: ep({ + method: "post", + path: "/subscriptions/batch", + scope: "admin", + summary: "批量:立即搜索 / 暂停 / 恢复 / 删除(删除 = 取消,写审计)", + body: policy.SubscriptionBatch, + response: policy.SubscriptionBatchResult, + }), + fillSlot: ep({ + method: "post", + path: "/subscriptions/{id}/slots/{slotId}/fill", + scope: "admin", + summary: "手动补全某收录版本(不受自动补全开关与补全预算限制)", + params: z.object({ id: Id, slotId: Id }), + body: policy.SlotFillRequest, + response: z.object({ runId: z.string(), deduplicated: z.boolean() }), + }), + slotOrder: ep({ + method: "put", + path: "/subscriptions/{id}/slot-order", + scope: "admin", + summary: "收录版本排序(第一个为主版本)", + params: IdParam, + body: policy.SlotOrderSet, + response: z.array(policy.AcquisitionSlot), + }), + search: ep({ + method: "post", + path: "/subscriptions/{id}/search", + scope: "admin", + summary: "手动主动搜索(按标题/别名在已开启站点搜索,结果走同一识别评估链)", + params: IdParam, + response: z.object({ runId: z.string(), deduplicated: z.boolean() }), + }), + progress: ep({ + method: "get", + path: "/subscriptions/{id}/progress", + scope: "read", + summary: "订阅进度:季总集数账本、每集状态(已覆盖/在途/缺失/疑似缺集)、收录版本在途与延后", + params: IdParam, + response: policy.SubscriptionProgress, + }), + setEpisodeTotal: ep({ + method: "post", + path: "/subscriptions/{id}/episode-total", + scope: "admin", + summary: "手动设置季总集数(只升;locked 锁定后刷新不再改写)", + params: IdParam, + body: policy.EpisodeTotalSet, + response: policy.SubscriptionProgress, + }), + }, + subscriptionHistory: crud( + "/subscription-history", + "订阅完成历史", + { + row: policy.SubscriptionHistory, + filters: { mediaEntityId: Id.optional(), subscriptionId: Id.optional() }, + }, + R, + ), + policyRevisions: { + ...crud( + "/policy-revisions", + "策略版本", + { + row: policy.PolicyRevision, + create: policy.PolicyRevisionCreate, + update: policy.PolicyRevisionUpdate, + filters: { policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }, + }, + R, + ), + preview: ep({ + method: "post", + path: "/policy-revisions/{id}/preview", + scope: "read", + summary: "历史回放预览", + params: IdParam, + response: policy.PolicyRevision, + }), + publish: ep({ + method: "post", + path: "/policy-revisions/{id}/publish", + scope: "admin", + summary: "发布策略版本", + params: IdParam, + body: z.object({ expectedRevision: z.number().int().min(1) }), + response: policy.PolicyRevision, + }), + rollback: ep({ + method: "post", + path: "/policy-revisions/{id}/rollback", + scope: "admin", + summary: "回滚到该版本", + params: IdParam, + response: policy.PolicyRevision, + }), + }, + acquisitionSlots: crud( + "/acquisition-slots", + "收录版本", + { + row: policy.AcquisitionSlot, + create: policy.AcquisitionSlotCreate, + update: policy.AcquisitionSlotUpdate, + filters: { + mediaEntityId: Id.optional(), + status: policy.SlotStatus.optional(), + subscriptionId: Id.optional(), + }, + }, + R, + ), + preferenceTags: { + ...crud( + "/preference-tags", + "偏好标签", + { + row: policy.PreferenceTag, + create: policy.PreferenceTagCreate, + update: policy.PreferenceTagUpdate, + filters: { status: policy.PreferenceStatus.optional(), kind: policy.PreferenceKind.optional() }, + }, + R, + ), + /** 删除 = 标记 deleted + 写抑制记录 */ + remove: del("/preference-tags/{id}", "删除偏好标签(留抑制记录)"), + revisions: ep({ + method: "get", + path: "/preference-tags/{id}/revisions", + scope: "read", + summary: "偏好标签修订历史(新→旧;取自审计事件)", + params: IdParam, + response: policy.PreferenceTagRevisionList, + }), + }, + preferenceSuppressions: crud( + "/preference-suppressions", + "偏好抑制", + { + row: policy.PreferenceSuppression, + create: policy.PreferenceSuppressionCreate, + update: policy.PreferenceSuppressionUpdate, + filters: { active: z.enum(["true", "false"]).optional() }, + }, + R, + ), + + // ── 规则(W9-D):规则组 = 收录过滤条件 + 偏好标签合一。静态路径先于 /rules/{id} 注册 ── + rules: { + keywords: ep({ + method: "get", + path: "/rules/keywords", + scope: "read", + summary: "关键字表:内置(对齐 MP,含判定依据与上游正则)+ 自定义条件", + response: policy.RuleKeywordList, + }), + parse: ep({ + method: "post", + path: "/rules/parse", + scope: "read", + summary: "规则串 → 结构化层(`>` 分层,层内!& | 与括号);错误逐条给位置与相近名", + body: policy.RuleParseRequest, + response: policy.RuleParseResult, + }), + serialize: ep({ + method: "post", + path: "/rules/serialize", + scope: "read", + summary: "结构化层 → 规范化规则串", + body: policy.RuleSerializeRequest, + response: policy.RuleSerializeResult, + }), + defaults: ep({ + method: "get", + path: "/rules/defaults", + scope: "read", + summary: "三套默认规则组链:订阅 / 洗版 / 搜索", + response: policy.RuleDefaults, + }), + setDefaults: ep({ + method: "put", + path: "/rules/defaults", + scope: "admin", + summary: "设置默认规则组链(按数组顺序串联,pri 取最后一个带层的组)", + body: policy.RuleDefaults, + response: policy.RuleDefaults, + }), + ...crud( + "/rules", + "规则组", + { + row: policy.RuleGroup, + create: policy.RuleGroupCreate, + update: policy.RuleGroupUpdate, + filters: { + kind: policy.RuleGroupKind.optional(), + source: policy.RuleGroupSource.optional(), + enabled: z.enum(["true", "false"]).optional(), + includeDeleted: z.enum(["true", "false"]).optional(), + q: z.string().max(80).optional(), + }, + }, + R, + ), + /** 删除 = 标记 deleted_at + 写抑制记录 */ + remove: del("/rules/{id}", "删除规则组(留抑制记录;被引用时 409)"), + revisions: ep({ + method: "get", + path: "/rules/{id}/revisions", + scope: "read", + summary: "规则组修订历史(新→旧;取自审计事件)", + params: IdParam, + response: policy.RuleGroupRevisionList, + }), + preview: ep({ + method: "post", + path: "/rules/{id}/preview", + scope: "read", + summary: "试算:对最近 N 条已识别资源(或指定资源/标题)跑本组,给 pri、命中层与拒绝原因", + params: IdParam, + body: policy.RulePreviewRequest, + response: policy.RulePreviewResult, + }), + }, + ruleAtoms: { + ...crud( + "/rules/atoms", + "自定义条件", + { row: policy.RuleAtom, create: policy.RuleAtomCreate, update: policy.RuleAtomUpdate }, + R, + ), + remove: del("/rules/atoms/{id}", "删除自定义条件(被规则组引用时 409)"), + }, + /** /policy-revisions 的新路径(旧路径保留为别名,见 docs/runbook/rules.md) */ + rulePolicies: { + list: ep({ + method: "get", + path: "/rules/policies", + scope: "read", + summary: "收录策略版本列表", + query: listQuery({ policyKey: z.string().optional(), status: policy.PolicyStatus.optional() }), + response: cursorPage(policy.PolicyRevision), + }), + get: ep({ + method: "get", + path: "/rules/policies/{id}", + scope: "read", + summary: "收录策略版本", + params: IdParam, + response: policy.PolicyRevision, + }), + create: ep({ + method: "post", + path: "/rules/policies", + scope: "admin", + summary: "新建策略草案", + body: policy.PolicyRevisionCreate, + response: policy.PolicyRevision, + }), + update: ep({ + method: "patch", + path: "/rules/policies/{id}", + scope: "admin", + summary: "修改策略草案", + params: IdParam, + body: policy.PolicyRevisionUpdate, + response: policy.PolicyRevision, + }), + preview: ep({ + method: "post", + path: "/rules/policies/{id}/preview", + scope: "read", + summary: "预览策略草案", + params: IdParam, + response: policy.PolicyRevision, + }), + publish: ep({ + method: "post", + path: "/rules/policies/{id}/publish", + scope: "admin", + summary: "发布策略版本", + params: IdParam, + body: z.object({ expectedRevision: z.number().int().min(1) }), + response: policy.PolicyRevision, + }), + rollback: ep({ + method: "post", + path: "/rules/policies/{id}/rollback", + scope: "admin", + summary: "回滚到该策略版本", + params: IdParam, + response: policy.PolicyRevision, + }), + }, + /** /preference-suppressions 的新路径(旧路径保留为别名) */ + ruleSuppressions: crud( + "/rules/suppressions", + "规则抑制记录", + { + row: policy.PreferenceSuppression, + create: policy.PreferenceSuppressionCreate, + update: policy.PreferenceSuppressionUpdate, + filters: { active: z.enum(["true", "false"]).optional() }, + }, + R, + ), +} as const; diff --git a/packages/contracts/src/api/schedules.ts b/packages/contracts/src/api/schedules.ts new file mode 100644 index 00000000..ca6f3944 --- /dev/null +++ b/packages/contracts/src/api/schedules.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import * as schedules from "../schedules.ts"; +import { ep, OkResult } from "./shared.ts"; + +export const schedulesApi = { + // ── 定时任务看板(W7-C):统一注册表 + Agent 巡检;全部只接受控制台会话 ── + schedules: { + list: ep({ + method: "get", + path: "/schedules", + scope: "admin", + auth: "session", + summary: "定时任务看板:全部登记的周期任务与智能体巡检、统计、调度器是否在线", + response: schedules.ScheduleBoard, + }), + create: ep({ + method: "post", + path: "/schedules", + scope: "admin", + auth: "session", + summary: "新建自定义智能体巡检(目标模板 + 能力白名单 + 预算 + 间隔)", + body: schedules.AgentCheckCreate, + response: schedules.ScheduledJob, + }), + update: ep({ + method: "patch", + path: "/schedules/{id}", + scope: "admin", + auth: "session", + summary: "修改开关 / 间隔或 cron / 参数;reset 恢复默认开关与调度", + params: schedules.ScheduleIdParam, + body: schedules.ScheduleUpdate, + response: schedules.ScheduledJob, + }), + remove: ep({ + method: "delete", + path: "/schedules/{id}", + scope: "admin", + auth: "session", + summary: "删除自定义智能体巡检(内置任务不可删)", + params: schedules.ScheduleIdParam, + response: OkResult, + }), + runNow: ep({ + method: "post", + path: "/schedules/{id}/run-now", + scope: "admin", + auth: "session", + summary: "立即执行一次(不改下次运行时间;已在排队或运行中则合并)", + params: schedules.ScheduleIdParam, + response: schedules.ScheduleRunNowResult, + }), + skip: ep({ + method: "post", + path: "/schedules/{id}/skip", + scope: "admin", + auth: "session", + summary: "跳过下一次运行(下次运行时间顺延一个周期)", + params: schedules.ScheduleIdParam, + response: schedules.ScheduledJob, + }), + runs: ep({ + method: "get", + path: "/schedules/{id}/runs", + scope: "admin", + auth: "session", + summary: "最近运行历史(新→旧)", + params: schedules.ScheduleIdParam, + query: schedules.ScheduleRunsQuery, + response: z.object({ items: z.array(schedules.ScheduledJobRun) }), + }), + }, +} as const; diff --git a/packages/contracts/src/api/shared.ts b/packages/contracts/src/api/shared.ts new file mode 100644 index 00000000..ccc04baa --- /dev/null +++ b/packages/contracts/src/api/shared.ts @@ -0,0 +1,152 @@ +// 端点定义共用的 helper 与跨分组 schema(由 api.ts 汇总导出公开部分) +import { z } from "zod"; +import * as agent from "../agent.ts"; +import { type ApiScope, CursorQuery, cursorPage, Id, IdParam } from "../common.ts"; +import * as content from "../content.ts"; +import * as identity from "../identity.ts"; +import * as instances from "../instances.ts"; +// 所有 REST 端点的路径常量与请求/响应 schema 汇总。 +// path 相对 API_PREFIX,参数用 OpenAPI 风格 `{id}`(server 的 createRoute 直接使用); +// web 用 buildPath() 填参数。响应体统一套 apiResponse() 信封,这里的 response 是 data 部分。 + +export const API_PREFIX = "/api/v1"; + +export type HttpMethod = "get" | "post" | "patch" | "put" | "delete"; + +/** + * 鉴权方式(不写即按 scope 校验,cookie 会话与 API Key 同样适用): + * - public:免鉴权(必须与服务端 http/auth.ts 的 PUBLIC_AUTH_PATHS 一致,测试断言); + * - session:只接受控制台管理员 cookie 会话,API Key(含 admin scope)一律 403。 + */ +export type EndpointAuth = "public" | "session"; + +export interface EndpointDef { + method: HttpMethod; + path: string; + scope: ApiScope; + auth?: EndpointAuth; + summary: string; + params?: z.ZodObject; + query?: z.ZodType; + body?: z.ZodType; + response: z.ZodType; +} + +export const ep = (e: E): E => e; + +/** 列表查询:游标分页 + 资源自有过滤 */ +export const listQuery = (filters: S) => CursorQuery.extend(filters); + +/** 删除/无内容动作的响应 */ +export const OkResult = z.object({ ok: z.literal(true) }); + +export interface CrudSchemas { + row: z.ZodType; + create?: z.ZodType; + update?: z.ZodType; + filters?: z.ZodRawShape; +} + +/** 标准资源端点:list / get / create / update / remove(按给出的 schema 决定是否生成) */ +export function crud( + base: B, + name: string, + s: CrudSchemas, + scopes: { read: ApiScope; write: ApiScope }, +) { + const item = `${base}/{id}` as const; + return { + list: ep({ + method: "get", + path: base, + scope: scopes.read, + summary: `${name}列表`, + query: listQuery(s.filters ?? {}), + response: cursorPage(s.row), + }), + get: ep({ + method: "get", + path: item, + scope: scopes.read, + summary: `${name}详情`, + params: IdParam, + response: s.row, + }), + ...(s.create + ? { + create: ep({ + method: "post", + path: base, + scope: scopes.write, + summary: `新建${name}`, + body: s.create, + response: s.row, + }), + } + : {}), + ...(s.update + ? { + update: ep({ + method: "patch", + path: item, + scope: scopes.write, + summary: `修改${name}`, + params: IdParam, + body: s.update, + response: s.row, + }), + } + : {}), + }; +} + +export const R = { read: "read", write: "admin" } as const; + +export const del = (path: string, summary: string) => + ep({ method: "delete", path, scope: "admin", summary, params: IdParam, response: OkResult }); + +/** 实例连通性测试 */ +export const instanceTest = (path: string, summary: string) => + ep({ method: "post", path, scope: "admin", summary, params: IdParam, response: instances.InstanceTestResult }); + +/** 入队后台任务的受理结果(taskId = 引擎 run id) */ +export const TaskAccepted = z.object({ taskId: Id }); +export type TaskAccepted = z.infer; + +/** 人工指定来源资源身份的结果(待识别池 assign / source-releases identity) */ +export const ReleaseIdentityResult = z.object({ mediaEntityId: Id, contentUnitIds: z.array(Id) }); +export type ReleaseIdentityResult = z.infer; + +/** 身份修订历史 */ +export const IdentityRevisionList = z.object({ items: z.array(identity.IdentityRevision) }); +export type IdentityRevisionList = z.infer; + +/** 字段覆盖/取消覆盖的结果 */ +export const FieldOverrideResult = z.object({ + mediaEntityId: Id, + changedFields: z.array(z.string()), + lockedFields: z.array(z.string()), +}); +export const FieldParam = z.object({ id: Id, field: content.EntityMetadataField }); +export const UnitFieldParam = z.object({ id: Id, field: content.UnitMetadataField }); +export const UnitFieldOverrideResult = z.object({ + contentUnitId: Id, + changedFields: z.array(z.string()), + lockedFields: z.array(z.string()), +}); + +/** 引擎 run id 不是 uuid(openworkflow 自有 id) */ +export const EngineRunIdParam = z.object({ id: z.string().min(1).max(200) }); + +/** 待处理事项聚合分组 */ +export const DecisionSummaryGroup = z.object({ + kind: agent.DecisionKind, + mediaEntityId: Id.nullable(), + mediaEntityTitle: z.string().nullable(), + count: z.number().int(), + maxSeverity: z.enum(["info", "warning", "critical"]), + latestAt: z.string(), +}); +export type DecisionSummaryGroup = z.infer; +export const DecisionSummary = z.object({ total: z.number().int(), groups: z.array(DecisionSummaryGroup) }); +export type DecisionSummary = z.infer; diff --git a/packages/contracts/src/api/sources.ts b/packages/contracts/src/api/sources.ts new file mode 100644 index 00000000..ca5e4d0c --- /dev/null +++ b/packages/contracts/src/api/sources.ts @@ -0,0 +1,230 @@ +import { z } from "zod"; +import { CursorQuery, cursorPage, Id, IdParam } from "../common.ts"; +import * as identity from "../identity.ts"; +import * as instances from "../instances.ts"; +import * as sources from "../sources.ts"; +import { crud, del, ep, R, ReleaseIdentityResult, TaskAccepted } from "./shared.ts"; + +export const sourcesApi = { + // ── 来源 ── + sites: { + ...crud("/sites", "站点", { row: sources.Site, create: sources.SiteCreate, update: sources.SiteUpdate }, R), + list: ep({ + method: "get", + path: "/sites", + scope: "read", + summary: "站点列表(include=accounts 附带账号与整体账号状态)", + query: sources.SiteListQuery, + response: cursorPage(sources.SiteListItem), + }), + remove: del("/sites/{id}", "删除站点:停用后删账号与凭证;已抓到的资源和下载任务保留(标站点已关闭)"), + healthCheck: ep({ + method: "post", + path: "/sites/{id}/health-check", + scope: "admin", + summary: "检测健康:首页连通 + 登录态,同步返回并写站点健康列", + params: IdParam, + response: sources.SiteHealthResult, + }), + refreshLogin: ep({ + method: "post", + path: "/sites/{id}/refresh-login", + scope: "admin", + summary: "刷新登录:账密重新登录换 Cookie;Cookie / API Key 校验;成功后拉用户数据", + params: IdParam, + body: sources.SiteRefreshLoginInput, + response: sources.SiteRefreshLoginAccepted, + }), + userData: ep({ + method: "get", + path: "/sites/{id}/user-data", + scope: "read", + summary: "站点账号用户数据按天历史", + params: IdParam, + query: sources.SiteUserDataQuery, + response: z.object({ items: z.array(sources.SiteUserDataPoint) }), + }), + enable: ep({ + method: "post", + path: "/sites/{id}/enable", + scope: "admin", + summary: "开启站点", + params: IdParam, + response: sources.Site, + }), + disable: ep({ + method: "post", + path: "/sites/{id}/disable", + scope: "admin", + summary: "关闭站点(停止网络访问与新候选,历史资源仍可查)", + params: IdParam, + response: sources.Site, + }), + snapshot: ep({ + method: "post", + path: "/sites/{id}/snapshot", + scope: "admin", + summary: "立即同步站点快照(受站点限流)", + params: IdParam, + body: z.object({ maxPages: z.number().int().min(1).max(20).optional() }), + response: TaskAccepted, + }), + snapshots: ep({ + method: "get", + path: "/sites/{id}/snapshots", + scope: "read", + summary: "站点快照/补查记录(覆盖范围与新鲜度)", + params: IdParam, + query: CursorQuery, + response: cursorPage(sources.SiteSnapshot), + }), + requestStats: ep({ + method: "get", + path: "/sites/{id}/request-stats", + scope: "read", + summary: "站点请求统计:最近 24 小时对该站发出的请求次数(按小时)与生效的访问默认", + params: IdParam, + response: sources.SiteRequestStats, + }), + credentials: ep({ + method: "put", + path: "/sites/{id}/credentials", + scope: "admin", + summary: "设置站点账号凭证(按 kind:password / cookie / api_key / passkey;存为 secret,不回显)", + params: IdParam, + body: instances.SiteCredentialsPut, + response: instances.SiteCredentialsResult, + }), + }, + siteDefinitions: { + list: ep({ + method: "get", + path: "/site-definitions", + scope: "read", + summary: "随产品发布的站点适配器定义(含是否已登记)", + response: z.object({ items: z.array(sources.SiteDefinitionView) }), + }), + }, + /** 站点工作台(整理视图):库存叠加走 inventory.overlay */ + siteWorkbench: { + releases: ep({ + method: "get", + path: "/sites/{id}/releases", + scope: "read", + summary: "资源列表/搜索(快照优先,必要时实时补查;标注来源与更新时间)", + params: IdParam, + query: sources.WorkbenchReleaseQuery, + response: sources.WorkbenchReleasePage, + }), + release: ep({ + method: "get", + path: "/sites/{id}/releases/{rid}", + scope: "read", + summary: "资源详情(refresh=true 时实时复核详情;失败保留既有数据)", + params: z.object({ id: Id, rid: Id }), + query: z.object({ refresh: z.enum(["true", "false"]).optional() }), + response: sources.WorkbenchReleaseDetail, + }), + }, + siteAccounts: { + ...crud( + "/site-accounts", + "站点账号", + { + row: sources.SiteAccount, + create: sources.SiteAccountCreate, + update: sources.SiteAccountUpdate, + filters: { siteId: Id.optional() }, + }, + R, + ), + remove: del("/site-accounts/{id}", "删除站点账号与其凭证"), + }, + feedSources: { + ...crud( + "/feed-sources", + "Feed", + { + row: sources.FeedSource, + create: sources.FeedSourceCreate, + update: sources.FeedSourceUpdate, + filters: { siteId: Id.optional(), health: sources.FeedHealth.optional() }, + }, + R, + ), + fetchNow: ep({ + method: "post", + path: "/feed-sources/{id}/fetch", + scope: "admin", + summary: "立即抓取(仍受站点限流)", + params: IdParam, + response: z.object({ taskId: Id }), + }), + enable: ep({ + method: "post", + path: "/feed-sources/{id}/enable", + scope: "admin", + summary: "启用 Feed", + params: IdParam, + response: sources.FeedSource, + }), + disable: ep({ + method: "post", + path: "/feed-sources/{id}/disable", + scope: "admin", + summary: "停用 Feed", + params: IdParam, + response: sources.FeedSource, + }), + reset: ep({ + method: "post", + path: "/feed-sources/{id}/reset", + scope: "admin", + summary: "手动恢复:关闭熔断、清空退避(凭证已更新后使用)", + params: IdParam, + response: sources.FeedSource, + }), + }, + sourceReleases: { + ...crud( + "/source-releases", + "资源", + { + row: sources.SourceRelease, + filters: { + siteId: Id.optional(), + mediaEntityId: Id.optional(), + resolutionStatus: sources.ResolutionStatus.optional(), + q: z.string().max(200).optional(), + }, + }, + R, + ), + assignIdentity: ep({ + method: "post", + path: "/source-releases/{id}/identity", + scope: "organize.execute", + summary: "人工指定资源的作品/内容单元(加人工锁,自动识别不再改写)", + params: IdParam, + body: identity.ReleaseIdentityAssign, + response: ReleaseIdentityResult, + }), + unlock: ep({ + method: "post", + path: "/source-releases/{id}/unlock", + scope: "organize.execute", + summary: "解除资源的人工锁并重新识别", + params: IdParam, + response: TaskAccepted, + }), + }, + observations: crud( + "/observations", + "观察", + { + row: sources.Observation, + filters: { feedId: Id.optional(), status: sources.ObservationStatus.optional() }, + }, + R, + ), +} as const; diff --git a/packages/contracts/src/api/system.ts b/packages/contracts/src/api/system.ts new file mode 100644 index 00000000..47d77277 --- /dev/null +++ b/packages/contracts/src/api/system.ts @@ -0,0 +1,233 @@ +import { z } from "zod"; +import * as auth from "../auth.ts"; +import { cursorPage, HealthStatus, IdParam } from "../common.ts"; +import * as events from "../events.ts"; +import * as ops from "../ops.ts"; +import { EngineRunIdParam, ep, OkResult } from "./shared.ts"; + +export const systemApi = { + // ── 登录门禁 ── + /** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */ + auth: { + setup: ep({ + method: "post", + path: "/auth/setup", + scope: "read", + auth: "public", + summary: "首次初始化管理员", + body: auth.AdminSetupRequest, + response: auth.SessionInfo, + }), + login: ep({ + method: "post", + path: "/auth/login", + scope: "read", + auth: "public", + summary: "管理员登录", + body: auth.LoginRequest, + response: auth.LoginResult, + }), + logout: ep({ method: "post", path: "/auth/logout", scope: "read", summary: "注销当前会话", response: OkResult }), + session: ep({ + method: "get", + path: "/auth/session", + scope: "read", + auth: "public", + summary: "当前登录态(免鉴权)", + response: auth.SessionInfo, + }), + password: ep({ + method: "post", + path: "/auth/password", + scope: "admin", + auth: "session", + summary: "修改密码(吊销其它会话)", + body: auth.PasswordChangeRequest, + response: auth.PasswordChangeResult, + }), + totpStatus: ep({ + method: "get", + path: "/auth/totp", + scope: "admin", + auth: "session", + summary: "TOTP 状态", + response: auth.TotpStatus, + }), + totp: ep({ + method: "post", + path: "/auth/totp", + scope: "admin", + auth: "session", + summary: "TOTP 绑定/启用/停用/重新生成恢复码", + body: auth.TotpAction, + response: auth.TotpActionResult, + }), + totpSetup: ep({ + method: "get", + path: "/auth/totp/setup", + scope: "admin", + auth: "session", + summary: "待确认的 TOTP 密钥与 otpauth URL(前端渲染二维码)", + response: auth.TotpSetup, + }), + passkeys: ep({ + method: "get", + path: "/auth/passkeys", + scope: "admin", + auth: "session", + summary: "已登记的 Passkey", + response: auth.PasskeyList, + }), + passkeyRegisterOptions: ep({ + method: "post", + path: "/auth/passkeys/register/options", + scope: "admin", + auth: "session", + summary: "Passkey 注册挑战", + response: auth.PasskeyChallenge, + }), + passkeyRegister: ep({ + method: "post", + path: "/auth/passkeys/register/verify", + scope: "admin", + auth: "session", + summary: "校验并登记 Passkey", + body: auth.PasskeyRegisterRequest, + response: auth.PasskeyInfo, + }), + passkeyRename: ep({ + method: "patch", + path: "/auth/passkeys/{id}", + scope: "admin", + auth: "session", + summary: "重命名 Passkey", + params: IdParam, + body: auth.PasskeyRenameRequest, + response: auth.PasskeyInfo, + }), + passkeyDelete: ep({ + method: "delete", + path: "/auth/passkeys/{id}", + scope: "admin", + auth: "session", + summary: "删除 Passkey", + params: IdParam, + response: OkResult, + }), + passkeyLoginOptions: ep({ + method: "post", + path: "/auth/passkeys/login/options", + scope: "read", + auth: "public", + summary: "Passkey 登录挑战(免鉴权)", + response: auth.PasskeyChallenge, + }), + passkeyLogin: ep({ + method: "post", + path: "/auth/passkeys/login/verify", + scope: "read", + auth: "public", + summary: "Passkey 登录(免鉴权;成功即下发会话)", + body: auth.PasskeyLoginRequest, + response: auth.LoginResult, + }), + }, + + // ── 事件 ── + events: { + list: ep({ + method: "get", + path: "/events", + scope: "read", + summary: + "事件流:控制台用 SSE(text/event-stream,cookie 会话);API Key 调用者带 limit 取 JSON 补读页(after=上次 nextAfter)", + query: events.EventReplayQuery, + response: events.EventReplayPage, + }), + }, + + // ── 系统 ── + system: { + health: ep({ + method: "get", + path: "/health", + scope: "read", + summary: "服务健康状态(免鉴权)", + response: HealthStatus, + }), + healthDetail: ep({ + method: "get", + path: "/system/health", + scope: "admin", + summary: "进程与健康:进程心跳 / 事件循环延迟 / SSE 连接 / DB / 磁盘 / 挂载根 / 引擎 / 定时任务", + response: ops.SystemHealth, + }), + healthHistory: ep({ + method: "get", + path: "/system/health/history", + scope: "admin", + summary: "心跳采样时间序列(事件循环延迟、运行中 run、SSE 连接;按桶聚合)", + query: ops.SystemHealthHistoryQuery, + response: ops.SystemHealthHistory, + }), + storage: ep({ + method: "get", + path: "/system/storage", + scope: "admin", + summary: "存储:表体积前 15、库总体积、各缓存用量与预算、备份占用、DATA_DIR 分目录用量、清理任务状态", + response: ops.SystemStorage, + }), + storageCleanup: ep({ + method: "post", + path: "/system/storage/cleanup/{target}", + scope: "admin", + summary: "立即执行某个存储清理任务(排入对应定时任务,已在排队或运行中则合并)", + params: ops.StorageCleanupParam, + response: ops.StorageCleanupResult, + }), + }, + + // ── 引擎面板(仅 admin) ── + engine: { + runs: ep({ + method: "get", + path: "/engine/runs", + scope: "admin", + summary: "引擎 runs(状态/名称/时间筛选,新→旧)", + query: ops.EngineRunQuery, + response: cursorPage(ops.EngineRun), + }), + run: ep({ + method: "get", + path: "/engine/runs/{id}", + scope: "admin", + summary: "run 详情(step attempts + signals)", + params: EngineRunIdParam, + response: ops.EngineRunDetail, + }), + stats: ep({ + method: "get", + path: "/engine/stats", + scope: "admin", + summary: "按状态计数 / 最老待处理 / 活跃 worker 快照", + response: ops.EngineStats, + }), + cancel: ep({ + method: "post", + path: "/engine/runs/{id}/cancel", + scope: "admin", + summary: "取消 run", + params: EngineRunIdParam, + body: ops.EngineCancelRequest, + response: ops.EngineCancelResult, + }), + retry: ep({ + method: "post", + path: "/engine/runs/{id}/retry", + scope: "admin", + summary: "按原任务名与输入重新入队(新幂等键 retry::<序号>)", + params: EngineRunIdParam, + response: z.object({ runId: z.string(), retryOf: z.string(), attempt: z.number().int().positive() }), + }), + }, +} as const;