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