, point: HookPoint): boolean {
- return ctx.hooks?.has(point) ?? false;
-}
-
-/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */
-export async function rewriteHook(
- ctx: Pick,
- point: P,
- value: HookValue,
- context: HookContext
| (() => HookContext
),
- accept?: (v: HookValue
) => boolean,
-): Promise> {
- const h = ctx.hooks;
- if (!h?.has(point)) return value;
- return h.runRewrite(point, value, typeof context === "function" ? context() : context, accept);
-}
-
-/** 拦截:返回第一个拒绝,没有钩子或都放行时返回 null */
-export async function interceptHook(
- ctx: Pick,
- point: P,
- context: HookContext | (() => HookContext
),
-): Promise {
- const h = ctx.hooks;
- if (!h?.has(point)) return null;
- return h.runIntercept(point, typeof context === "function" ? context() : context);
-}
diff --git a/apps/server/src/hooks/points.ts b/apps/server/src/hooks/points.ts
deleted file mode 100644
index c49c8846..00000000
--- a/apps/server/src/hooks/points.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import type { HOOK_POINTS, HookPoint } from "@umt/contracts";
-import type { ParsedRelease } from "@umt/domain";
-
-// 链式钩子挂点的入参 / 返回值类型(挂点清单与语义在 contracts/hooks.ts,ADR-006)。
-// rewrite 挂点:value 是要改写的值,context 只读;intercept 挂点只有 context。
-
-/** 资源选择的候选:只能重排或过滤,不能加入新候选(按 sourceReleaseId 映射回原资源) */
-export interface SelectionCandidate {
- sourceReleaseId: string;
- title: string;
- parsed: ParsedRelease;
-}
-
-export interface HookPointTypes {
- "name.recognize": {
- value: ParsedRelease;
- context: {
- title: string;
- subtitle: string | null;
- /** 来源资源;按名称识别整理文件时为 null */
- sourceReleaseId: string | null;
- trigger: string;
- };
- };
- "organize.rename": {
- /** 目标文件名(不含目录);返回值不能含路径分隔符 */
- value: string;
- context: {
- targetPath: string;
- sourcePath: string;
- mediaEntityId: string;
- mediaType: "movie" | "tv";
- season: number | null;
- episodes: { start: number; end: number } | null;
- };
- };
- "organize.intercept": {
- value: never;
- context: {
- mediaEntityId: string;
- mediaType: "movie" | "tv";
- action: string;
- trigger: string;
- downloadTaskId: string | null;
- files: { src: string; dst: string; kind: string; role: "main" | "sidecar" }[];
- };
- };
- "resource.selection": {
- value: SelectionCandidate[];
- context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null };
- };
- "subscription.completion.check": {
- value: never;
- context: {
- subscriptionId: string;
- mediaEntityId: string | null;
- mediaKind: string;
- /** 本次判定完成的原因(slot_satisfied / download_accepted) */
- reason: string;
- progress: Record;
- };
- };
-}
-
-type KindOf = (typeof HOOK_POINTS)[P]["kind"];
-export type RewritePoint = { [P in HookPoint]: KindOf
extends "rewrite" ? P : never }[HookPoint];
-export type InterceptPoint = { [P in HookPoint]: KindOf
extends "intercept" ? P : never }[HookPoint];
-
-export type HookValue
= HookPointTypes[P]["value"];
-export type HookContext
= Readonly;
-
-type MaybePromise = T | Promise;
-/** 返回新值;返回 undefined 表示不改 */
-export type RewriteHook = (
- value: HookValue
,
- context: HookContext
,
-) => MaybePromise | undefined>;
-/** 返回拒绝原因;返回 null / undefined 表示放行 */
-export type InterceptHook = (
- context: HookContext
,
-) => MaybePromise;
diff --git a/apps/server/src/hooks/registry.ts b/apps/server/src/hooks/registry.ts
deleted file mode 100644
index 5da11441..00000000
--- a/apps/server/src/hooks/registry.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import { HOOK_POINTS, type HookPoint } from "@umt/contracts";
-import type { Logger } from "pino";
-import type { HookContext, HookValue, InterceptHook, InterceptPoint, RewriteHook, RewritePoint } from "./points.ts";
-
-// 链式钩子(ADR-006,参考 MoviePilot ChainEventType):模块启动时注册,业务节点同步调用。
-// - 按注册顺序执行(模块顺序 = SERVER_MODULES 顺序,同一模块内按调用顺序);
-// - 每个钩子单独限时(缺省 2 秒),超时或抛错只记日志、跳过这个钩子,不影响主流程;
-// - 没有注册时 has() 为 false,调用方直接用原值(零开销)。
-// 钩子可能在业务事务里被调用:只做内存里的判断与改写,不要发长耗时外部请求、不要写库。
-
-export const DEFAULT_HOOK_TIMEOUT_MS = 2_000;
-
-export interface HookOptions {
- /** 单次执行上限,缺省 DEFAULT_HOOK_TIMEOUT_MS */
- timeoutMs?: number;
- /** 日志里显示的名字,缺省 <模块>#<序号> */
- name?: string;
-}
-
-export interface HookRegistrar {
- rewrite(point: P, fn: RewriteHook
, opts?: HookOptions): void;
- intercept
(point: P, fn: InterceptHook
, opts?: HookOptions): void;
-}
-
-interface Entry {
- owner: string;
- name: string;
- timeoutMs: number;
- // biome-ignore lint/suspicious/noExplicitAny: 按挂点分表存,调用时由 run* 的类型参数约束
- fn: (...args: any[]) => unknown;
-}
-
-const TIMEOUT = Symbol("timeout");
-const FAILED = Symbol("failed");
-
-export interface HookRejection {
- reason: string;
- /** 拒绝的钩子名 */
- by: string;
-}
-
-export class HookRegistry {
- private readonly hooks = new Map();
-
- constructor(private readonly logger?: Pick) {}
-
- /** 给某个模块的注册入口(owner 进日志) */
- forModule(owner: string): HookRegistrar {
- const add = (point: HookPoint, kind: "rewrite" | "intercept", fn: Entry["fn"], opts: HookOptions = {}) => {
- if (HOOK_POINTS[point].kind !== kind)
- throw new Error(`钩子挂点 ${point} 是 ${HOOK_POINTS[point].kind} 类,不能用 ${kind} 注册`);
- const list = this.hooks.get(point) ?? [];
- list.push({
- owner,
- name: opts.name ?? `${owner}#${list.filter((e) => e.owner === owner).length + 1}`,
- timeoutMs: opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
- fn,
- });
- this.hooks.set(point, list);
- };
- return {
- rewrite: (point, fn, opts) => add(point, "rewrite", fn, opts),
- intercept: (point, fn, opts) => add(point, "intercept", fn, opts),
- };
- }
-
- has(point: HookPoint): boolean {
- return (this.hooks.get(point)?.length ?? 0) > 0;
- }
-
- /** 已注册的钩子(挂点 → 钩子名),给功能清单与诊断用 */
- list(): Record {
- return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)]));
- }
-
- /** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */
- async runRewrite(
- point: P,
- value: HookValue
,
- context: HookContext
,
- accept?: (v: HookValue
) => boolean,
- ): Promise> {
- let cur = value;
- for (const e of this.hooks.get(point) ?? []) {
- const r = await this.call(point, e, [cur, context]);
- if (r === undefined || r === TIMEOUT || r === FAILED) continue;
- if (accept && !accept(r as HookValue)) {
- this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过");
- continue;
- }
- cur = r as HookValue
;
- }
- return cur;
- }
-
- /** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */
- async runIntercept
(point: P, context: HookContext
): Promise {
- for (const e of this.hooks.get(point) ?? []) {
- const r = await this.call(point, e, [context]);
- if (typeof r === "string" && r.trim()) return { reason: r.trim(), by: e.name };
- }
- return null;
- }
-
- private async call(point: HookPoint, e: Entry, args: unknown[]): Promise {
- let timer: ReturnType | undefined;
- try {
- const r = await Promise.race([
- Promise.resolve().then(() => e.fn(...args)),
- new Promise((resolve) => {
- timer = setTimeout(() => resolve(TIMEOUT), e.timeoutMs);
- }),
- ]);
- if (r === TIMEOUT) this.logger?.warn({ point, hook: e.name, timeoutMs: e.timeoutMs }, "钩子超时,已跳过");
- return r;
- } catch (err) {
- this.logger?.warn(
- { point, hook: e.name, err: { message: err instanceof Error ? err.message : String(err) } },
- "钩子出错,已跳过",
- );
- return FAILED;
- } finally {
- clearTimeout(timer);
- }
- }
-}
diff --git a/apps/server/src/http/static.ts b/apps/server/src/http/static.ts
index 18e553e9..75f6cef4 100644
--- a/apps/server/src/http/static.ts
+++ b/apps/server/src/http/static.ts
@@ -1,5 +1,5 @@
import type { Hono } from "hono";
-import { serveStatic } from "hono/deno";
+import { serveStatic } from "hono/bun";
/**
* 托管 React Router SPA 构建产物(build/client):存在的文件直接返回,
diff --git a/apps/server/src/logger.ts b/apps/server/src/logger.ts
index 8c1dbf4f..484498a5 100644
--- a/apps/server/src/logger.ts
+++ b/apps/server/src/logger.ts
@@ -71,7 +71,7 @@ export function createLogger(opts: LoggerOptions = {}): Logger {
pino({ ...options, transport: { target: "pino-pretty", options: { translateTime: "SYS:HH:MM:ss" } } }),
);
}
- // 同步写 stdout:进程随后 Deno.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
+ // 同步写 stdout:进程随后 process.exit() 时(迁移/自检子命令、优雅退出)不丢尾部日志
return redactChildBindings(pino(options, pino.destination({ dest: 1, sync: true })));
}
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index de1fd89f..40e5aa16 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -1,5 +1,7 @@
+import "reflect-metadata";
import { parseArgs } from "node:util";
-import denoJson from "../deno.json" with { type: "json" };
+import { runtimeReadTextFile, serve } from "@umt/runtime";
+import packageJson from "../package.json" with { type: "json" };
import type { Config } from "./config.ts";
import type { MappingExport } from "./ops.ts";
@@ -34,7 +36,7 @@ type OneShotRole = (typeof OneShot)[number];
const isOneShot = (r: Role): r is OneShotRole => (OneShot as readonly Role[]).includes(r);
const { values } = parseArgs({
- args: Deno.args,
+ args: process.argv.slice(2),
options: {
role: { type: "string", default: "all" },
out: { type: "string" },
@@ -46,32 +48,32 @@ const { values } = parseArgs({
const role = values.role as Role;
if (!Roles.includes(role)) {
console.error(`--role 必须是 ${Roles.join("|")}`);
- Deno.exit(2);
+ process.exit(2);
}
// fsproxy 执行子进程:必须在加载 db/任务引擎/模块之前分派——每个文件操作起一次,只加载 fs 操作表,启动保持轻量
if (role === "fsworker") {
const { fsWorkerMain } = await import("@umt/adapters/fsworker");
await fsWorkerMain();
- Deno.exit(0);
+ process.exit(0);
}
// 沙箱子进程:同样在加载其余模块前分派,只加载沙箱任务表
if (role === "sandbox") {
const { sandboxWorkerMain } = await import("./sandbox/worker.ts");
await sandboxWorkerMain();
- Deno.exit(0);
+ process.exit(0);
}
// 健康探测:只打本机回环端口,不需要完整配置(HEALTHCHECK 用,镜像里不装 curl)
if (role === "probe") {
- const port = Deno.env.get("PORT") ?? "4380";
+ const port = process.env.PORT ?? "4380";
try {
const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(3000) });
const body = (await res.json()) as { data?: { status?: string } };
- Deno.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
+ process.exit(res.ok && body.data?.status === "ok" ? 0 : 1);
} catch {
- Deno.exit(1);
+ process.exit(1);
}
}
@@ -98,16 +100,16 @@ try {
config = loadConfig();
} catch (err) {
console.error((err as Error).message);
- Deno.exit(2);
+ process.exit(2);
}
// 运行期写入统一落在 DATA_DIR;Agent 会话目录经环境变量传给 agent 模块(它只读 AGENT_SESSION_DIR)
const paths = dataPaths(config);
-Deno.env.set("AGENT_SESSION_DIR", paths.sessionDir);
+process.env.AGENT_SESSION_DIR = paths.sessionDir;
const logger = createLogger({
level: config.LOG_LEVEL,
- pretty: config.NODE_ENV === "development" && Deno.stdout.isTerminal(),
+ pretty: config.NODE_ENV === "development" && Boolean(process.stdout.isTTY),
});
const db = createDb({ connectionString: config.DATABASE_URL });
@@ -121,7 +123,7 @@ if (isOneShot(role)) {
} finally {
await db.destroy();
}
- Deno.exit(code);
+ process.exit(code);
}
async function runOneShot(r: OneShotRole): Promise {
@@ -155,9 +157,9 @@ async function runOneShot(r: OneShotRole): Promise {
}
await auditStorageWarnings(db, storage.warnings).catch((err) => logger.warn({ err }, "存储告警写审计失败"));
// 验收开关误设提示(验收 07 S-10):只有 UMT_FS_TEST_HOOKS=1 时才真正生效
- if (Deno.env.get("UMT_FS_TEST_HANG_AFTER_BYTES"))
+ if (process.env.UMT_FS_TEST_HANG_AFTER_BYTES)
logger.warn(
- { active: Deno.env.get("UMT_FS_TEST_HOOKS") === "1" },
+ { active: process.env.UMT_FS_TEST_HOOKS === "1" },
"设置了 UMT_FS_TEST_HANG_AFTER_BYTES(fsproxy 挂死验收开关);生产环境不要设置,生效时所有数据类文件操作都会卡死到被 SIGKILL",
);
for (const p of problems) logger.error({ check: p.check }, p.message);
@@ -175,11 +177,11 @@ async function runOneShot(r: OneShotRole): Promise {
}
case "export-mappings": {
const out = values.out ?? `${paths.exportsDir}/mappings-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
- const data = await exportMappings(db, denoJson.version);
+ const data = await exportMappings(db, packageJson.version);
await writeMappingExport(out, data);
logger.info({ out, counts: data.counts }, "映射清单已导出");
if (!values.compare) return 0;
- const base = JSON.parse(await Deno.readTextFile(values.compare)) as MappingExport;
+ const base = JSON.parse(await runtimeReadTextFile(values.compare)) as MappingExport;
const diff = diffCounts(base.counts, data.counts);
if (diff.length) {
logger.error({ diff }, "映射清单行数与基准不一致");
@@ -206,7 +208,7 @@ try {
} catch (err) {
logger.error({ err }, "SSE 总线启动失败");
await db.destroy();
- Deno.exit(1);
+ process.exit(1);
}
const stops: Array<() => Promise> = [];
@@ -217,7 +219,7 @@ try {
} catch (err) {
logger.error({ err }, "启动装配失败");
await db.destroy();
- Deno.exit(1);
+ process.exit(1);
}
const { ctx, outbox, engine } = booted;
// 统一元数据缓存:LISTEN 跨进程失效(在线才启用进程内一级缓存)+ 命中统计定期落库
@@ -230,13 +232,13 @@ if (role === "api" || role === "all") {
bus,
db,
role,
- version: denoJson.version,
+ version: packageJson.version,
...(config.WEB_ROOT ? { webRoot: config.WEB_ROOT } : {}),
modules: SERVER_MODULES,
ctx,
});
const ac = new AbortController();
- const server = Deno.serve(
+ const server = serve(
{
port: config.PORT,
hostname: config.HOST,
@@ -250,7 +252,7 @@ if (role === "api" || role === "all") {
app.fetch,
);
stops.push(async () => {
- // abort 后 Deno.serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
+ // abort 后 serve 会断开 SSE 等长连接,finished 在所有连接关闭后 resolve
ac.abort();
await server.finished;
});
@@ -280,18 +282,18 @@ if (role === "worker" || role === "worker:agent" || role === "all") {
}
let stopping = false;
-async function shutdown(signal: Deno.Signal) {
+async function shutdown(signal: NodeJS.Signals) {
if (stopping) return;
stopping = true;
logger.info({ signal }, "正在退出");
// 兜底:10 秒内没退干净就强退,避免容器 stop 超时被 SIGKILL
- const force = setTimeout(() => Deno.exit(1), 10_000);
+ const force = setTimeout(() => process.exit(1), 10_000);
await Promise.allSettled(stops.map((s) => s()));
await engine.stop().catch((err) => logger.error({ err }, "任务引擎关闭失败"));
await bus.stop().catch(() => {});
await db.destroy();
clearTimeout(force);
- Deno.exit(0);
+ process.exit(0);
}
-Deno.addSignalListener("SIGINT", () => void shutdown("SIGINT"));
-Deno.addSignalListener("SIGTERM", () => void shutdown("SIGTERM"));
+process.on("SIGINT", () => void shutdown("SIGINT"));
+process.on("SIGTERM", () => void shutdown("SIGTERM"));
diff --git a/apps/server/src/modules/acquisition-organize/capabilities.ts b/apps/server/src/modules/acquisition-organize/capabilities.ts
index 0c8886cf..443fa3cb 100644
--- a/apps/server/src/modules/acquisition-organize/capabilities.ts
+++ b/apps/server/src/modules/acquisition-organize/capabilities.ts
@@ -9,11 +9,11 @@ import {
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle, transition } from "@umt/domain";
-import { coveringGrant, type PlanBinding, planBinding } from "../agent/authorize.ts";
-import { previewPolicy, publishRevision } from "./routes/rules.ts";
+import { coveringGrant, type PlanBinding, planBinding } from "../../authorization/grants.ts";
import { evaluateAcquisition, publishedAcquisitionPolicies } from "./services/acquisition.ts";
import { type AoDeps, enqueueTracked, ensureTaskRow, markTask } from "./services/common.ts";
import { type FrozenPlan, planImport } from "./services/organize.ts";
+import { previewPolicy, publishRevision } from "./services/policies.ts";
import { assignOrganizeIdentity, OrganizeDecisionError } from "./services/takeover.ts";
import { errMessage, hashJson, jsonb, toApi, toApiList } from "./util.ts";
diff --git a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts b/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts
index 2ad419de..6223b998 100644
--- a/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts
+++ b/apps/server/src/modules/acquisition-organize/routes/rule-groups.ts
@@ -16,6 +16,7 @@ import type { ModuleApp } from "../../types.ts";
import { contextOf, parsedOf } from "../services/acquisition.ts";
import type { AoDeps } from "../services/common.ts";
import {
+ assertRuleGroupIds,
checkAtomPatterns,
loadRuleCatalog,
normalizeGroupInput,
@@ -98,28 +99,15 @@ export async function checkSuppression(trx: Trx, fingerprint: string, source: st
/** 被收录版本 / 订阅显式引用的次数 */
async function referenceCount(trx: Trx, id: string): Promise {
const r = await sql<{ n: number }>`
- select (select count(*) from acquisition_slot where rule_group_ids @> ${JSON.stringify([id])}::jsonb)
+ select (select count(*) from acquisition_slot where (rule_group_ids || coalesce(upgrade_threshold->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([id])}::jsonb)
+ (select count(*) from subscription where params -> 'ruleGroupIds' @> ${JSON.stringify([id])}::jsonb)
+ + (select count(*) from policy_revision p where p.kind = 'acquisition' and p.status in ('draft', 'published')
+ and exists (select 1 from jsonb_array_elements(coalesce(p.rules->'defaultSlots', '[]'::jsonb) || coalesce(p.rules->'newTitle'->'slots', '[]'::jsonb)) t
+ where (coalesce(t->'ruleGroupIds', '[]'::jsonb) || coalesce(t->'upgradeThreshold'->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([id])}::jsonb))
as n`.execute(trx);
return Number(r.rows[0]?.n ?? 0);
}
-async function checkIdsExist(exec: Trx | AoDeps["ctx"]["db"], ids: readonly string[]) {
- if (!ids.length) return;
- const rows = await exec
- .selectFrom("rule_group")
- .select("id")
- .where("id", "in", [...ids])
- .where("deleted_at", "is", null)
- .execute();
- const have = new Set(rows.map((r) => r.id));
- const missing = ids.filter((x) => !have.has(x));
- if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
-}
-
-/** 收录版本 / 订阅引用的规则组 id 校验(供 slots 路由复用) */
-export const assertRuleGroupIds = checkIdsExist;
-
const TRACKED_FIELDS = [
"name",
"description",
@@ -204,7 +192,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.transaction()
.execute(async (trx) => {
const all = [...body.acquisition, ...body.upgrade, ...body.search];
- await checkIdsExist(trx, all);
+ await assertRuleGroupIds(trx, all);
const rows = await trx
.selectFrom("rule_group")
.select(["id", "kind"])
@@ -528,7 +516,7 @@ export function registerRuleGroupRoutes(app: ModuleApp, d: AoDeps) {
.executeTakeFirst();
if (!cur) notFound("规则组");
const refs = await referenceCount(trx, cur!.id);
- if (refs > 0) conflict(`规则组被 ${refs} 个收录版本或订阅引用,先解除引用`);
+ if (refs > 0) conflict(`规则组被 ${refs} 个收录版本、订阅或策略引用,先解除引用`);
const row = await trx
.updateTable("rule_group")
.set({
@@ -603,7 +591,9 @@ async function kickReevaluate(d: AoDeps, groupId: string) {
const slots = await d.ctx.db
.selectFrom("acquisition_slot")
.select("id")
- .where(sql`rule_group_ids @> ${JSON.stringify([groupId])}::jsonb`)
+ .where(
+ sql`(rule_group_ids || coalesce(upgrade_threshold->'ruleGroupIds', '[]'::jsonb)) @> ${JSON.stringify([groupId])}::jsonb`,
+ )
.limit(50)
.execute();
for (const s of slots)
diff --git a/apps/server/src/modules/acquisition-organize/routes/rules.ts b/apps/server/src/modules/acquisition-organize/routes/rules.ts
index a51dd5cb..4217d611 100644
--- a/apps/server/src/modules/acquisition-organize/routes/rules.ts
+++ b/apps/server/src/modules/acquisition-organize/routes/rules.ts
@@ -7,24 +7,17 @@ import {
type PreferenceTag,
} from "@umt/contracts";
import { type DB, type PreferenceSuppressionRow, type RuleGroupRow, sql, type Transaction } from "@umt/db";
-import { evaluateCandidate, type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
-import { slotSpecFromRow } from "@umt/mapping";
+import { type RuleExpr, type RuleLayer, serializeLayers } from "@umt/domain";
import type { Context } from "hono";
import type { AuthEnv } from "../../../http/auth.ts";
import { ApiException } from "../../../http/errors.ts";
-import { titleParser } from "../../sources-identity/service/identify-words-cache.ts";
import type { ModuleApp } from "../../types.ts";
import type { AoDeps, Exec } from "../services/common.ts";
-import { normalizeGroupInput } from "../services/rules.ts";
+import { previewPolicy, publishRevision } from "../services/policies.ts";
+import { assertRuleGroupIds, normalizeGroupInput, upgradeRuleGroupIds } from "../services/rules.ts";
import { actorOf, conflict, errMessage, hashJson, jsonb, notFound, toApi, toApiList } from "../util.ts";
import { mount, paginate } from "./helpers.ts";
-import {
- assertRuleGroupIds,
- checkSuppression,
- liftSuppressions,
- registerRuleGroupRoutes,
- suppressRuleGroup,
-} from "./rule-groups.ts";
+import { checkSuppression, liftSuppressions, registerRuleGroupRoutes, suppressRuleGroup } from "./rule-groups.ts";
import { registerSubscriptionRoutes } from "./subscriptions.ts";
// 规则 API:收录 Slot、策略版本(草案→发布→回滚,发布即持续授权)、偏好标签(规则组别名)与抑制记录;
@@ -151,7 +144,7 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
.where("id", "=", body.mediaEntityId)
.executeTakeFirst();
if (!e) throw new ApiException("bad_request", "mediaEntityId 不存在");
- await assertRuleGroupIds(db(), body.ruleGroupIds ?? []);
+ await assertRuleGroupIds(db(), [...(body.ruleGroupIds ?? []), ...upgradeRuleGroupIds(body.upgradeThreshold)]);
const r = await db()
.insertInto("acquisition_slot")
.values({
@@ -183,7 +176,8 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
Api.acquisitionSlots.update,
async (_c, { params, body }) => {
const { expectedRevision, ...b } = body;
- if (b.ruleGroupIds !== undefined) await assertRuleGroupIds(db(), b.ruleGroupIds);
+ if (b.ruleGroupIds !== undefined || b.upgradeThreshold !== undefined)
+ await assertRuleGroupIds(db(), [...(b.ruleGroupIds ?? []), ...upgradeRuleGroupIds(b.upgradeThreshold)]);
const r = await db()
.updateTable("acquisition_slot")
.set({
@@ -745,119 +739,3 @@ export function registerRuleRoutes(app: ModuleApp, d: AoDeps) {
// 规则组 /rules/*:含 /rules/{id},必须最后注册(静态的 /rules/policies、/rules/suppressions 在前)
registerRuleGroupRoutes(app, d);
}
-
-/** 发布:旧 published → superseded/rolled_back;吊销旧授权;新版本生成持续授权 automation_grant */
-export async function publishRevision(
- trx: Transaction,
- id: string,
- policyKey: string,
- by: string,
- oldStatus: "superseded" | "rolled_back",
-) {
- const old = await trx
- .selectFrom("policy_revision")
- .select(["id"])
- .where("policy_key", "=", policyKey)
- .where("status", "=", "published")
- .execute();
- const now = new Date();
- for (const o of old as { id: string }[]) {
- await trx.updateTable("policy_revision").set({ status: oldStatus }).where("id", "=", o.id).execute();
- await trx
- .updateTable("automation_grant")
- .set({ status: "revoked", revoked_at: now, revoked_by: by })
- .where("policy_revision_id", "=", o.id)
- .where("status", "=", "active")
- .execute();
- }
- const row = await trx
- .updateTable("policy_revision")
- .set({ status: "published", published_at: now, published_by: by })
- .where("id", "=", id)
- .returningAll()
- .executeTakeFirstOrThrow();
- const rules = (row.rules ?? {}) as { budget?: unknown };
- await trx
- .insertInto("automation_grant")
- .values({
- action: `policy.${row.kind}`,
- policy_revision_id: row.id,
- scope: jsonb(row.scope),
- budget: jsonb(rules.budget ?? {}),
- granted_by: by,
- reason: `发布策略 ${policyKey} r${row.revision}`,
- })
- .execute();
- return row;
-}
-
-/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
-export async function previewPolicy(d: AoDeps, rules: unknown) {
- const r = AcquisitionPolicyRules.safeParse(rules);
- const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
- const rels = await d.ctx.db
- .selectFrom("source_release")
- .select(["id", "title", "subtitle", "size_bytes"])
- .where("resolution_status", "=", "resolved")
- .orderBy("first_seen_at", "desc")
- .limit(200)
- .execute();
- let matched = 0;
- let excluded = 0;
- const samples: {
- sourceReleaseId: string;
- title: string;
- sizeBytes: number | null;
- template: string;
- decision: string;
- reasons: string[];
- }[] = [];
- const parse = await titleParser(d.ctx.db);
- for (const rel of rels) {
- const parsed = {
- ...parse(rel.title, rel.subtitle),
- ...(rel.size_bytes !== null ? { sizeBytes: rel.size_bytes } : {}),
- };
- for (const t of templates) {
- const spec = slotSpecFromRow({
- id: "preview",
- name: t.name,
- hardConditions: t.hardConditions ?? {},
- softPreferences: t.softPreferences ?? {},
- upgradeThreshold: t.upgradeThreshold ?? {},
- completion: t.completion ?? {},
- fallback: t.fallback ?? {},
- });
- const ev = evaluateCandidate(spec, parsed, null);
- if (ev.decision === "accept") matched++;
- else excluded++;
- if (samples.length < 20) {
- const reasons =
- ev.decision === "reject"
- ? ev.violations.map((v) => `${v.field}:${v.code}`)
- : ev.decision === "unknown_field"
- ? ev.missing.map((m) => `missing:${m}`)
- : "reasons" in ev
- ? (ev.reasons ?? [])
- : [];
- samples.push({
- sourceReleaseId: rel.id,
- title: rel.title,
- sizeBytes: rel.size_bytes,
- template: t.name,
- decision: ev.decision,
- reasons,
- });
- }
- }
- }
- return {
- evaluatedAt: new Date().toISOString(),
- sampleSize: rels.length,
- templates: templates.length,
- matched,
- excluded,
- samples,
- valid: r.success,
- };
-}
diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts
index 1eb01d0a..0c80e820 100644
--- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts
+++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts
@@ -17,7 +17,7 @@ import { ApiException } from "../../../http/errors.ts";
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
import type { ModuleApp } from "../../types.ts";
-import { ensureSubscriptionSlot } from "../services/acquisition.ts";
+import { collectionContext, ensureSubscriptionSlot } from "../services/collection-strategy.ts";
import type { AoDeps, Exec } from "../services/common.ts";
import {
buildSubscriptionModels,
@@ -352,6 +352,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
// pending:TMDB 未就绪(未建档,或剧集没有已接受的 TMDB 身份 → 总集数无来源):照常搜索/评估,不能完成
const ready = await tmdbReady(db(), body.mediaEntityId ?? null, body.mediaKind);
const now = d.ports.now();
+ const context = await collectionContext(d, body.mediaEntityId ?? null);
const row = await db()
.transaction()
.execute(async (trx) => {
@@ -376,7 +377,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.returningAll()
.executeTakeFirstOrThrow();
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
- await ensureSubscriptionSlot(trx, r);
+ await ensureSubscriptionSlot(trx, r, context);
await applyDerived(trx, r, norm, now);
// 生命周期广播(ADR-006):订阅已建;关联作品 = 作品进入关注
await emitSubscriptionEvent(d, trx, "subscription.created", r);
@@ -419,6 +420,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
seasonUnitId: cur.season_unit_id,
excludeId: cur.id,
});
+ const context = await collectionContext(d, cur.media_entity_id);
const r = await db()
.transaction()
.execute(async (trx) => {
@@ -438,7 +440,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
.returningAll()
.executeTakeFirstOrThrow();
// 旧订阅没有自己的收录版本:编辑时补建(质量预设 / 升级策略才有地方写)
- if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
+ if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r, context);
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
await emitSubscriptionChange(trx, cur, r, Object.keys(body));
diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts
index c2c3100a..2bc74d42 100644
--- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts
+++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts
@@ -1,10 +1,4 @@
-import {
- type AcquisitionPolicyRules,
- type AcquisitionPriority,
- type CandidateEvaluation,
- lifecycleKey,
- type SlotTemplate,
-} from "@umt/contracts";
+import { type AcquisitionPriority, type CandidateEvaluation, lifecycleKey } from "@umt/contracts";
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
import { sql } from "@umt/db";
import {
@@ -24,13 +18,22 @@ import {
type SlotEvaluation,
type SlotSpec,
type SlotState,
+ seasonReleaseKey,
seasonWildcard,
+ selectAcquisitionPolicy,
transition,
unitsOverlap,
+ upgradeSpec,
} from "@umt/domain";
import { toContractSlotEvaluation } from "@umt/mapping";
-import { hasHook, rewriteHook } from "../../../hooks/index.ts";
+import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
import { hashJson, jsonb, startOfDay } from "../util.ts";
+import {
+ collectionContext,
+ createSlotsFromTemplates,
+ ensureSubscriptionSlot,
+ publishedAcquisitionPolicies,
+} from "./collection-strategy.ts";
import {
type AoDeps,
automationBlock,
@@ -43,15 +46,16 @@ import {
loadInstances,
pickInstance,
recordCandidate,
+ upsertDecision,
} from "./common.ts";
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts";
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
-import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts";
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
import {
clearDeferral,
clearReleaseDeferrals,
clearSubscriptionDeferrals,
+ DEFAULT_SLOT_REEVALUATE_MS,
gateRetryDelayMs,
manualInflightPlans,
rangeOf,
@@ -67,10 +71,18 @@ import {
subSeasonOf,
TRACKING_SUB_STATUSES,
} from "./subscriptions.ts";
-import { applySlotPreset } from "./subscriptions-params.ts";
+
+export {
+ createSlotsFromTemplates,
+ ensureSubscriptionSlot,
+ publishedAcquisitionPolicies,
+} from "./collection-strategy.ts";
+
+import { missingReleaseFields } from "../../sources-identity/service/release-facts-admin.ts";
+import { seasonReleaseTarget } from "./season-release.ts";
import { decideVolume, loadVolumes } from "./volumes.ts";
-export { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
+export { contextOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
// evaluateAcquisition:release.resolved → 匹配订阅/Slot → 模式 → 确定性评估 → 预算/缓存卷 → 冻结计划并预留。
// 拒绝与被挡住的原因都写成候选评估记录(artifact),供作品详情"为什么选/不选"。
@@ -172,127 +184,30 @@ const firstReleaseDate = (e: MediaEntityRow): string | undefined => {
return e.year ? `${e.year}-01-01` : undefined;
};
-/** 已发布的收录策略(kind=acquisition) */
-export async function publishedAcquisitionPolicies(exec: Exec) {
- const rows = await exec
- .selectFrom("policy_revision")
- .selectAll()
- .where("kind", "=", "acquisition")
- .where("status", "=", "published")
- .execute();
- return rows.map((r) => ({
- row: r,
- rules: (r.rules ?? {}) as AcquisitionPolicyRules,
- scope: (r.scope ?? {}) as { mediaKinds?: string[] },
- }));
-}
-
const subKindOf = (k: MediaEntityRow["kind"]): "movie" | "series" | "variety" =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
-export async function createSlotsFromTemplates(
- exec: Exec,
- input: {
- entityId: string;
- subscriptionId: string | null;
- policyRevisionId: string | null;
- templates: readonly SlotTemplate[];
- },
-): Promise {
- // 收录版本名在作品内唯一(media_entity_id, content_unit_id, name):旧订阅留下的同名版本不挡新订阅,改名加序号
- const taken = new Set(
- (
- await exec
- .selectFrom("acquisition_slot")
- .select("name")
- .where("media_entity_id", "=", input.entityId)
- .where("content_unit_id", "is", null)
- .execute()
- ).map((r) => r.name),
- );
- let n = 0;
- for (const [i, t] of input.templates.entries()) {
- let name = t.name;
- for (let k = 2; taken.has(name) && k < 1000; k++) name = `${t.name} ${k}`;
- taken.add(name);
- const row = await exec
- .insertInto("acquisition_slot")
- .values({
- // 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
- position: i,
- media_entity_id: input.entityId,
- subscription_id: input.subscriptionId,
- policy_revision_id: input.policyRevisionId,
- name,
- hard_conditions: jsonb(t.hardConditions),
- soft_preferences: jsonb(t.softPreferences),
- fallback: jsonb(t.fallback),
- upgrade_threshold: jsonb(t.upgradeThreshold),
- completion: jsonb(t.completion),
- rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
- })
- .onConflict((oc) => oc.doNothing())
- .returning("id")
- .executeTakeFirst();
- if (row) n++;
- }
- return n;
-}
-
/** 追踪中的订阅里没有自己收录版本的,逐个补建;返回是否建了 */
async function backfillSubscriptionSlots(
- db: AoDeps["ctx"]["db"],
+ d: AoDeps,
subs: readonly SubscriptionRow[],
slots: readonly Pick[],
): Promise {
let made = 0;
for (const s of subs)
- if (s.media_entity_id && !slots.some((x) => x.subscription_id === s.id)) made += await ensureSlotLocked(db, s);
+ if (s.media_entity_id && !slots.some((x) => x.subscription_id === s.id)) made += await ensureSlotLocked(d, s);
return made > 0;
}
/** 评估路径上补建:锁订阅行,免得并发的两次评估各建一个 */
-function ensureSlotLocked(db: AoDeps["ctx"]["db"], sub: SubscriptionRow): Promise {
- return db.transaction().execute(async (trx) => {
+async function ensureSlotLocked(d: AoDeps, sub: SubscriptionRow): Promise {
+ const context = await collectionContext(d, sub.media_entity_id);
+ return d.ctx.db.transaction().execute(async (trx) => {
await trx.selectFrom("subscription").select("id").where("id", "=", sub.id).forUpdate().execute();
- return ensureSubscriptionSlot(trx, sub);
+ return ensureSubscriptionSlot(trx, sub, context);
});
}
-/** 订阅没有收录策略模板时的隐式缺省收录版本:无硬条件,表单的质量预设 / 包含排除随后由订阅参数写入 */
-export const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
-
-/**
- * 订阅总有属于自己的收录版本(H1):没有时按收录策略模板(指定的或第一个带 defaultSlots 的已发布策略)建,
- * 没有模板就建一个隐式「默认版本」,再把订阅表单的质量预设写进去。作品已有别的订阅(旧季、已取消)的
- * 收录版本不算——评估时订阅只用自己的收录版本。返回新建个数(已有则 0)。
- */
-export async function ensureSubscriptionSlot(
- exec: Exec,
- sub: Pick,
-): Promise {
- if (!sub.media_entity_id) return 0;
- const own = await exec
- .selectFrom("acquisition_slot")
- .select("id")
- .where("subscription_id", "=", sub.id)
- .executeTakeFirst();
- if (own) return 0;
- const pols = await publishedAcquisitionPolicies(exec);
- const pol =
- (sub.policy_revision_id ? pols.find((p) => p.row.id === sub.policy_revision_id) : undefined) ??
- pols.find((p) => p.rules.defaultSlots?.length);
- const fromPolicy = !!pol?.rules.defaultSlots?.length;
- const n = await createSlotsFromTemplates(exec, {
- entityId: sub.media_entity_id,
- subscriptionId: sub.id,
- policyRevisionId: fromPolicy ? pol!.row.id : null,
- templates: fromPolicy ? pol!.rules.defaultSlots! : [DEFAULT_SLOT_TEMPLATE],
- });
- if (n > 0) await applySlotPreset(exec, { subscriptionId: sub.id, prev: {}, next: subParams(sub) });
- return n;
-}
-
/**
* 预算用量。并发 = 在跑的下载任务(不含 stalled:无进展任务不占名额,否则卡住几个就把追更全挡住)
* + 已预留但还没建下载任务的计划(刚预留、等补投;防止一轮评估连续预留超过并发上限)。
@@ -392,7 +307,7 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise !seen.has(x.id)),
);
const ranked = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
- const ordered = await selectResources(c, ranked, {
- mediaEntityId: entity.id,
- subscriptionId: sub?.id ?? null,
- slotId: input.acquisitionSlotId ?? null,
- });
const scope = input.acquisitionSlotId ? { slotId: input.acquisitionSlotId } : sub ? { subscriptionId: sub.id } : {};
- for (const r of ordered) await evaluateRelease(c, r, [], scope);
+ for (const r of ranked) await evaluateRelease(c, r, [], scope);
return c.out;
}
@@ -513,33 +423,6 @@ async function rankReleases(
})).map((x) => byParsed.get(x.candidate)!);
}
-/**
- * 链式钩子 resource.selection(ADR-006):在择优排序之后调整顺序或过滤。钩子只能重排 / 删减,
- * 返回里不认识的 ID 忽略、重复的只取第一次;没有钩子时原样返回。
- */
-export async function selectResources(
- c: Ctx,
- rels: SourceReleaseRow[],
- context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null },
-): Promise {
- if (!hasHook(c.d.ctx, "resource.selection") || rels.length === 0) return rels;
- const byId = new Map(rels.map((r) => [r.id, r]));
- const picked = await rewriteHook(
- c.d.ctx,
- "resource.selection",
- rels.map((r) => ({ sourceReleaseId: r.id, title: r.title, parsed: parsedOf(r) })),
- context,
- );
- const out: SourceReleaseRow[] = [];
- for (const p of picked) {
- const r = byId.get(p.sourceReleaseId);
- if (!r) continue;
- byId.delete(p.sourceReleaseId);
- out.push(r);
- }
- return out;
-}
-
/** 评估范围:只评某个收录版本,或只评某个订阅的收录版本;缺省评作品的全部收录版本 */
interface EvalScope {
slotId?: string | undefined;
@@ -596,10 +479,14 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
.where("status", "=", "active")
.executeTakeFirst();
if (hasAsset) return skip("not_tracked");
- const pol = (await publishedAcquisitionPolicies(db)).find(
- (p) =>
- p.rules.newTitle?.enabled &&
- (!p.rules.newTitle.mediaKinds?.length || p.rules.newTitle.mediaKinds.includes(subKindOf(entity.kind))),
+ const context = await collectionContext(d, entity.id);
+ const pol = selectAcquisitionPolicy(
+ (await publishedAcquisitionPolicies(db)).filter(
+ (p) =>
+ p.rules.newTitle?.enabled &&
+ (!p.rules.newTitle.mediaKinds?.length || p.rules.newTitle.mediaKinds.includes(subKindOf(entity.kind))),
+ ),
+ { mediaKind: subKindOf(entity.kind), category: context?.category ?? null },
);
if (!pol?.rules.newTitle) return skip("not_tracked");
const m = determineMode({
@@ -687,7 +574,7 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
subs.filter((s) => (TRACKING_SUB_STATUSES as readonly string[]).includes(s.status)).map((s) => [s.id, s]),
);
// 一次性补建:旧版本建的订阅可能没有自己的收录版本(H1),首次评估时补上
- if (await backfillSubscriptionSlots(db, [...activeSubs.values()], slots))
+ if (await backfillSubscriptionSlots(d, [...activeSubs.values()], slots))
slots = await db
.selectFrom("acquisition_slot")
.selectAll()
@@ -849,8 +736,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
if (isMovie) {
if (inflight.length > 0 || BUSY.includes(slot.status)) return defer("slot_busy", []);
const current = await currentFacts(db, slot.current_asset_id);
- if (current && slot.current_asset_id && rulesLaddered(spec)) {
- const pri = await rulePriOfAsset(c, spec, slot.current_asset_id, ctx.originalLanguage);
+ if (current && slot.current_asset_id && rulesLaddered(upgradeSpec(spec))) {
+ const pri = await rulePriOfAsset(c, upgradeSpec(spec), slot.current_asset_id, ctx.originalLanguage);
if (pri !== undefined) current.pri = pri;
}
ev = evaluateCandidate(spec, parsed, slotSatisfied ? current : null, ctx);
@@ -902,11 +789,25 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
return defer("units_inflight", reservedHit);
} else {
// 都已覆盖:分集洗版,只取能提升的单元(对照 MP get_best_version_interested_episodes)
+ if (spec.upgrade?.consistency === "season_release") {
+ const washing = upgradeSpec(spec);
+ const pri = effectiveLadder(washing, parsed, ctx, ruleChainOf(washing, parsed, ctx)).pri;
+ ctx.seasonReleaseTarget = await seasonReleaseTarget(
+ db,
+ slot,
+ p.releaseSeason,
+ washing,
+ { ...parsed, pri },
+ ctx.originalLanguage,
+ );
+ }
const unitCurrent = await unitQuality(
db,
slot,
releaseKeys,
- rulesLaddered(spec) ? (assetId) => rulePriOfAsset(c, spec, assetId, ctx.originalLanguage) : undefined,
+ rulesLaddered(upgradeSpec(spec))
+ ? (assetId) => rulePriOfAsset(c, upgradeSpec(spec), assetId, ctx.originalLanguage)
+ : undefined,
);
const withCurrent = releaseKeys.filter((k) => unitCurrent[k]);
const firstCurrent = withCurrent.length
@@ -928,8 +829,30 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
}
}
const flat = toContractSlotEvaluation(ev);
- const evalExtra = { pri: flat.pri, trace: flat.trace };
+ const evalExtra = {
+ pri: flat.pri,
+ trace: flat.trace,
+ quality: { ...parsed, pri: flat.pri },
+ ...(ctx.seasonReleaseTarget
+ ? { seasonRelease: seasonReleaseKey(ctx.seasonReleaseTarget), season: p.releaseSeason }
+ : {}),
+ };
if (!flat.acceptable) {
+ if (!c.draft && ev.decision === "unknown_field") {
+ const missing = missingReleaseFields(rel, ev.missing).filter((f) =>
+ ["resolution", "codec", "source", "group", "size", "subtitles", "seeders", "promotion"].includes(f),
+ );
+ if (missing.length)
+ await upsertDecision(d, db, {
+ kind: "other",
+ subjectType: "source_release",
+ subjectId: rel.id,
+ dedupeKey: `release_facts:${rel.id}`,
+ title: `资源属性缺失:${rel.title}`,
+ summary: "查证缺失属性后重新按收录规则评估",
+ evidence: { sourceReleaseId: rel.id, mediaEntityId: p.entity.id, slotId: slot.id, missing },
+ });
+ }
await note(c, {
...base,
decision: ev.decision as "reject" | "unknown_field",
@@ -1032,7 +955,8 @@ async function evaluateForSlot(c: Ctx, p: SlotInput): Promise<{ planId: string |
savePath: gate.savePath,
unitKeys: planUnits,
multiInflight: !isMovie,
- ...(fill.action === "fill" ? { priority: fill.priority, evaluationExtra: fill.evaluation } : {}),
+ ...(fill.action === "fill" ? { priority: fill.priority } : {}),
+ evaluationExtra: { ...evalExtra, ...(fill.action === "fill" ? fill.evaluation : {}) },
});
if (r.conflict) return defer("units_inflight", r.conflict, mode);
if (r.retryAt) {
@@ -1305,10 +1229,12 @@ async function reserve(
unitKeys: p.unitKeys,
};
// 升级 = 替换已有资产;电影 Slot 缺口(无资产)的 mode 虽记 upgrade,用途仍是补缺
- const purpose = p.mode === "upgrade" && p.slot.current_asset_id ? ("upgrade" as const) : ("fill" as const);
+ const purpose = p.ev.decision === "upgrade" ? ("upgrade" as const) : ("fill" as const);
const evaluation = jsonb({
...toContractSlotEvaluation(p.ev),
mode: p.mode,
+ unitKeys: p.unitKeys,
+ ...(purpose === "upgrade" ? { automaticReplacement: true } : {}),
instanceId: p.instanceId,
volumeKey: p.volumeKey,
savePath: p.savePath,
@@ -1373,6 +1299,23 @@ async function reserve(
.executeTakeFirst();
if (!slot) return { planId: null };
const busy = BUSY.includes(slot.status);
+ if (p.evaluationExtra?.seasonRelease && typeof p.evaluationExtra.season === "number") {
+ const spec = upgradeSpec(
+ slotSpecWithRules(
+ await loadRuleCatalog(trx),
+ slot,
+ p.sub,
+ { id: slot.media_entity_id, kind: p.sub?.media_kind ?? "series" },
+ p.rel.site_id,
+ ),
+ );
+ const target = await seasonReleaseTarget(trx, slot, p.evaluationExtra.season, spec, {
+ ...parsedOf(p.rel),
+ pri: "pri" in p.ev ? p.ev.pri : undefined,
+ });
+ if (!target || seasonReleaseKey(target) !== p.evaluationExtra.seasonRelease)
+ return { planId: null, retryAt: new Date(now.getTime() + DEFAULT_SLOT_REEVALUATE_MS) };
+ }
if (p.multiInflight) {
// Slot×单元预留:在 Slot 行锁内复核与其它在途计划的单元不相交
const others = await slotInflightPlans(trx, slot.id);
diff --git a/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts b/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts
new file mode 100644
index 00000000..44cdd226
--- /dev/null
+++ b/apps/server/src/modules/acquisition-organize/services/collection-strategy.ts
@@ -0,0 +1,144 @@
+import {
+ type AcquisitionPolicyRules,
+ AcquisitionPolicyScope,
+ type SlotTemplate,
+ type SubscriptionParams,
+} from "@umt/contracts";
+import type { SubscriptionRow } from "@umt/db";
+import { selectAcquisitionPolicy } from "@umt/domain";
+import { ApiException } from "../../../http/errors.ts";
+import { jsonb } from "../util.ts";
+import { classifyEntity } from "./classification.ts";
+import type { AoDeps, Exec } from "./common.ts";
+import { applySlotPreset } from "./subscriptions-params.ts";
+
+export async function collectionContext(d: AoDeps, entityId: string | null) {
+ if (!entityId) return undefined;
+ const entity = await d.ctx.db.selectFrom("media_entity").selectAll().where("id", "=", entityId).executeTakeFirst();
+ if (!entity) return undefined;
+ return {
+ mediaKind:
+ entity.kind === "movie"
+ ? ("movie" as const)
+ : entity.kind === "variety"
+ ? ("variety" as const)
+ : ("series" as const),
+ category: (await classifyEntity(d, entity)).category,
+ };
+}
+
+/** 已发布的收录策略(kind=acquisition) */
+export async function publishedAcquisitionPolicies(exec: Exec) {
+ const rows = await exec
+ .selectFrom("policy_revision")
+ .selectAll()
+ .where("kind", "=", "acquisition")
+ .where("status", "=", "published")
+ .orderBy("published_at", "desc")
+ .orderBy("policy_key")
+ .execute();
+ return rows.map((r) => ({
+ row: r,
+ rules: (r.rules ?? {}) as AcquisitionPolicyRules,
+ id: r.id,
+ scope: AcquisitionPolicyScope.parse(r.scope ?? {}),
+ }));
+}
+
+export async function createSlotsFromTemplates(
+ exec: Exec,
+ input: {
+ entityId: string;
+ subscriptionId: string | null;
+ policyRevisionId: string | null;
+ templates: readonly SlotTemplate[];
+ },
+): Promise {
+ // 收录版本名在作品内唯一(media_entity_id, content_unit_id, name):旧订阅留下的同名版本不挡新订阅,改名加序号
+ const taken = new Set(
+ (
+ await exec
+ .selectFrom("acquisition_slot")
+ .select("name")
+ .where("media_entity_id", "=", input.entityId)
+ .where("content_unit_id", "is", null)
+ .execute()
+ ).map((r) => r.name),
+ );
+ let n = 0;
+ for (const [i, t] of input.templates.entries()) {
+ let name = t.name;
+ for (let k = 2; taken.has(name) && k < 1000; k++) name = `${t.name} ${k}`;
+ taken.add(name);
+ const row = await exec
+ .insertInto("acquisition_slot")
+ .values({
+ // 同一事务建的收录版本 created_at 相同:按模板顺序给 position,第一个为主版本
+ position: i,
+ media_entity_id: input.entityId,
+ subscription_id: input.subscriptionId,
+ policy_revision_id: input.policyRevisionId,
+ name,
+ hard_conditions: jsonb(t.hardConditions),
+ soft_preferences: jsonb(t.softPreferences),
+ fallback: jsonb(t.fallback),
+ upgrade_threshold: jsonb(t.upgradeThreshold),
+ completion: jsonb(t.completion),
+ rule_group_ids: JSON.stringify(t.ruleGroupIds ?? []),
+ })
+ .onConflict((oc) => oc.doNothing())
+ .returning("id")
+ .executeTakeFirst();
+ if (row) n++;
+ }
+ return n;
+}
+
+/** 订阅没有收录策略模板时的隐式缺省收录版本:无硬条件,表单的质量预设 / 包含排除随后由订阅参数写入 */
+export const DEFAULT_SLOT_TEMPLATE: SlotTemplate = { name: "默认版本" };
+
+/**
+ * 订阅总有属于自己的收录版本(H1):没有时按收录策略模板(显式指定、分类默认、类型默认或全局默认的已发布策略)建,
+ * 没有模板就建一个隐式「默认版本」,再把订阅表单的质量预设写进去。作品已有别的订阅(旧季、已取消)的
+ * 收录版本不算——评估时订阅只用自己的收录版本。返回新建个数(已有则 0)。
+ */
+export async function ensureSubscriptionSlot(
+ exec: Exec,
+ sub: Pick,
+ context?: { mediaKind: "movie" | "series" | "variety"; category: string | null },
+): Promise {
+ if (!sub.media_entity_id) return 0;
+ const own = await exec
+ .selectFrom("acquisition_slot")
+ .select("id")
+ .where("subscription_id", "=", sub.id)
+ .executeTakeFirst();
+ if (own) return 0;
+ const pols = await publishedAcquisitionPolicies(exec);
+ const entity = await exec
+ .selectFrom("media_entity")
+ .select("kind")
+ .where("id", "=", sub.media_entity_id)
+ .executeTakeFirstOrThrow();
+ const pol = selectAcquisitionPolicy(
+ pols.filter((p) => p.rules.defaultSlots?.length),
+ {
+ explicitId: sub.policy_revision_id,
+ mediaKind:
+ context?.mediaKind ?? (entity.kind === "movie" ? "movie" : entity.kind === "variety" ? "variety" : "series"),
+ category: context?.category ?? null,
+ },
+ );
+ if (sub.policy_revision_id && !pol)
+ throw new ApiException("precondition_failed", "所选收录策略尚未发布或没有收录版本");
+ const fromPolicy = !!pol?.rules.defaultSlots?.length;
+ const n = await createSlotsFromTemplates(exec, {
+ entityId: sub.media_entity_id,
+ subscriptionId: sub.id,
+ policyRevisionId: fromPolicy ? pol!.row.id : null,
+ templates: fromPolicy ? pol!.rules.defaultSlots! : [DEFAULT_SLOT_TEMPLATE],
+ });
+ if (n > 0)
+ await applySlotPreset(exec, { subscriptionId: sub.id, prev: {}, next: (sub.params ?? {}) as SubscriptionParams });
+ return n;
+}
diff --git a/apps/server/src/modules/acquisition-organize/services/common.ts b/apps/server/src/modules/acquisition-organize/services/common.ts
index 78542b6e..fc3368db 100644
--- a/apps/server/src/modules/acquisition-organize/services/common.ts
+++ b/apps/server/src/modules/acquisition-organize/services/common.ts
@@ -37,7 +37,11 @@ export async function upsertDecision(d: AoDeps, exec: Exec, input: DecisionInput
${input.subjectType ?? null}, ${input.subjectId ?? null}, ${input.dedupeKey},
${jsonb(input.evidence)}::jsonb, ${JSON.stringify(input.options ?? [])}::jsonb, ${input.taskId ?? null})
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
- do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
+ do update set attempts = decision.attempts + 1, evidence = coalesce(decision.evidence, '{}'::jsonb) || excluded.evidence ||
+ case when excluded.dedupe_key like 'release_facts:%' then jsonb_build_object('missing',
+ (select coalesce(jsonb_agg(distinct field), '[]'::jsonb) from jsonb_array_elements(
+ coalesce(decision.evidence->'missing', '[]'::jsonb) || coalesce(excluded.evidence->'missing', '[]'::jsonb)) field))
+ else '{}'::jsonb end, summary = excluded.summary,
subject_type = coalesce(excluded.subject_type, decision.subject_type),
subject_id = coalesce(excluded.subject_id, decision.subject_id),
-- 重新判定时选项随最新证据刷新(如接管识别的候选);没给选项的保留原有(如 Agent 补的"重新校验")
@@ -49,7 +53,13 @@ export async function upsertDecision(d: AoDeps, exec: Exec, input: DecisionInput
await d.ctx.outbox.emit(
exec,
"decision.created",
- { decisionId: row.id, kind: input.kind, severity: input.severity ?? "warning", title: input.title },
+ {
+ decisionId: row.id,
+ kind: input.kind,
+ severity: input.severity ?? "warning",
+ title: input.title,
+ dedupeKey: input.dedupeKey,
+ },
{ idempotencyKey: `decision.created:${row.id}` },
);
}
diff --git a/apps/server/src/modules/acquisition-organize/services/coverage.ts b/apps/server/src/modules/acquisition-organize/services/coverage.ts
index 5cbdcb40..7b8501f2 100644
--- a/apps/server/src/modules/acquisition-organize/services/coverage.ts
+++ b/apps/server/src/modules/acquisition-organize/services/coverage.ts
@@ -1,5 +1,4 @@
import type {
- AcquisitionSlot,
CoverageColumn,
SubscriptionCoverage,
SubscriptionGroup,
@@ -14,15 +13,18 @@ import {
coverageRow,
episodeKey,
parseUnitKey,
+ type QualityFacts,
resolveAutoFill,
+ type SlotSpec,
+ seasonReleaseKey,
+ selectSeasonRelease,
subscriptionGroup,
type UnitRange,
+ upgradeSpec,
} from "@umt/domain";
-import { slotSpecFromRow } from "@umt/mapping";
-import { toApi } from "../util.ts";
import type { Exec } from "./common.ts";
-import { defaultChainIds, loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
-import { INFLIGHT_PLAN_STATUSES, rangeOf, subParams } from "./subscriptions.ts";
+import { loadRuleCatalog, type RuleCatalog, resolveRuleChain } from "./rules.ts";
+import { INFLIGHT_PLAN_STATUSES, rangeOf, slotSpecWithRules, subParams } from "./subscriptions.ts";
// W9-E 覆盖矩阵与订阅总览(docs/design/subscriptions.md §1、§2.1、§5):
// 一批订阅一次取齐 收录版本 / 资产 / 覆盖 / 分集账本 / 在途计划 / 内容单元 / 延后(固定 8 条查询,与订阅数无关),
@@ -41,6 +43,7 @@ interface SlotModel {
ruleGroupIds: string[];
upgrade: { enabled: boolean; targetPri: number };
requiredEditions: string[];
+ spec: SlotSpec;
}
interface RowModel {
@@ -85,14 +88,16 @@ const segOf = (u: { segment: string } | undefined) =>
const editionKey = (e: string) => `edition:${e}`;
const ymd = (d: Date) => d.toISOString().slice(0, 10);
-/** 订阅有效自动补全:params.autoFill → 规则链上后者优先的 auto_fill → 系统缺省关 */
+/** 订阅有效自动补全:params.autoFill → 主版本收录链上后者优先的 auto_fill → 系统缺省关 */
export function subscriptionAutoFill(
cat: RuleCatalog,
sub: Pick,
+ primarySlot?: Pick,
): SubscriptionOverviewItem["autoFill"] & { chainIds: string[] } {
const p = subParams(sub);
const chain = resolveRuleChain(cat, {
kind: "acquisition",
+ slotRuleGroupIds: primarySlot?.rule_group_ids as string[] | undefined,
subRuleGroupIds: p.ruleGroupIds,
subscriptionId: sub.id,
mediaKind: sub.media_kind,
@@ -175,9 +180,10 @@ export async function buildSubscriptionModels(
unit_keys: unknown;
plan_slot_id: string | null;
}>`
- select a.id, a.media_entity_id, a.content_edition, a.media_info->'_umt'->'quality' as quality,
+ select a.id, a.media_entity_id, a.content_edition, coalesce(r.parsed, '{}'::jsonb) || coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) as quality,
a.media_info->'_umt'->'unitKeys' as unit_keys, p.slot_id as plan_slot_id
from media_asset a
+ left join source_release r on r.id = a.source_release_id
left join download_task dt on dt.id = a.download_task_id
left join acquisition_plan p on p.id = dt.acquisition_plan_id
where a.status = 'active' and a.parent_asset_id is null
@@ -205,7 +211,15 @@ export async function buildSubscriptionModels(
.leftJoin("download_task as dt", (j) =>
j.onRef("dt.acquisition_plan_id", "=", "p.id").on("dt.removed_at", "is", null),
)
- .select(["p.id", "p.slot_id", "p.status", "p.unit_keys", "dt.status as dt_status"])
+ .leftJoin("source_release as r", "r.id", "p.source_release_id")
+ .select([
+ "p.id",
+ "p.slot_id",
+ "p.status",
+ "p.unit_keys",
+ "dt.status as dt_status",
+ sql`coalesce(p.evaluation->'quality', r.parsed, '{}'::jsonb)`.as("quality"),
+ ])
.where("p.slot_id", "in", slotIds)
.where("p.status", "in", [...INFLIGHT_PLAN_STATUSES])
.execute(),
@@ -251,6 +265,9 @@ export async function buildSubscriptionModels(
? "verifying"
: "downloading";
if (!r.slot_id) continue;
+ const resolutions = ((slots.find((s) => s.id === r.slot_id)?.hard_conditions ?? {}) as { resolutions?: string[] })
+ .resolutions;
+ if (resolutions?.length && !resolutions.includes(r.quality.resolution ?? "")) continue;
const list = inflightBySlot.get(r.slot_id) ?? [];
const prev = list.find((x) => x.id === r.id);
if (!prev) list.push({ id: r.id, stage, unitKeys: r.unit_keys });
@@ -268,10 +285,11 @@ export async function buildSubscriptionModels(
/** 资产集合 → 覆盖事实(单元键 → 资产/质量) */
const coveredOf = (assetSet: Iterable, ledger: typeof ledgerRows): Map => {
const m = new Map();
+ const allowedAssets = new Set(assetSet);
const put = (k: string, f: CoveredFact, override = false) => {
if (override || !m.has(k)) m.set(k, f);
};
- for (const id of assetSet) {
+ for (const id of allowedAssets) {
const a = assetById.get(id);
if (!a) continue;
const q = qualityOf(a.quality);
@@ -290,6 +308,7 @@ export async function buildSubscriptionModels(
}
// 分集账本优先(质量与 pri 按收录版本记)
for (const r of ledger) {
+ if (!r.media_asset_id || !allowedAssets.has(r.media_asset_id) || !assetById.has(r.media_asset_id)) continue;
const f = { assetId: r.media_asset_id, quality: qualityOf(r.quality), pri: r.pri };
if (r.kind === "episode" && r.episode_number !== null)
put(episodeKey(r.season_number ?? 1, r.episode_number), f, true);
@@ -300,36 +319,49 @@ export async function buildSubscriptionModels(
for (const sub of subs) {
const entity = entities.find((e) => e.id === sub.media_entity_id) ?? null;
- const af = subscriptionAutoFill(cat, sub);
const p = subParams(sub);
const explicitGroups = p.ruleGroupIds ?? [];
const subSlots = entity ? slotsOfSub(slotsByEntity.get(entity.id) ?? [], sub) : [];
+ const af = subscriptionAutoFill(cat, sub, subSlots[0]);
const entitySlotCount = entity ? (slotsByEntity.get(entity.id) ?? []).length : 0;
const slotRuleIds = [...new Set(subSlots.flatMap((s) => (s.rule_group_ids ?? []) as string[]))];
- const ruleGroupIds = explicitGroups.length
- ? explicitGroups
- : slotRuleIds.length
- ? slotRuleIds
- : defaultChainIds(cat, "acquisition");
+ const visibleChainIds = (slot?: AcquisitionSlotRow) =>
+ resolveRuleChain(cat, {
+ kind: "acquisition",
+ slotRuleGroupIds: slot?.rule_group_ids as string[] | undefined,
+ subRuleGroupIds: p.ruleGroupIds,
+ subscriptionId: sub.id,
+ mediaKind: sub.media_kind,
+ entityId: sub.media_entity_id ?? undefined,
+ }).ids.filter((gid) => !(cat.groups.get(gid)?.scope as { subscriptionId?: string } | null)?.subscriptionId);
+ const ruleGroupIds = [...new Set(subSlots.length ? subSlots.flatMap(visibleChainIds) : visibleChainIds())];
const models: SlotModel[] = subSlots.map((s, i) => {
- const spec = slotSpecFromRow(toApi(s));
+ const spec = upgradeSpec(slotSpecWithRules(cat, s, sub, entity!));
const cuts = (spec.completion && spec.completion.kind === "movie" ? spec.completion.cuts : undefined) ?? [];
return {
row: s,
primary: i === 0,
autoFill: i === 0 || af.enabled,
- ruleGroupIds: (s.rule_group_ids ?? []) as string[],
+ ruleGroupIds: visibleChainIds(s),
upgrade: { enabled: !!spec.upgrade?.enabled, targetPri: spec.upgrade?.targetPri ?? 100 },
requiredEditions: cuts.length ? cuts.map(String) : ["default"],
+ spec,
};
});
const assetSetOf = (s: AcquisitionSlotRow): Set => {
const set = new Set();
+ const resolutions = ((s.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
+ const allowed = (id: string) => {
+ const a = assetById.get(id);
+ return !!a && (!resolutions?.length || resolutions.includes((a.quality as QualityFacts)?.resolution ?? ""));
+ };
for (const a of assets.rows) {
+ if (!allowed(a.id)) continue;
if (a.plan_slot_id === s.id || a.id === s.current_asset_id) set.add(a.id);
else if (entitySlotCount <= 1 && a.media_entity_id === s.media_entity_id) set.add(a.id);
}
- for (const r of ledgerBySlot.get(s.id) ?? []) if (r.media_asset_id) set.add(r.media_asset_id);
+ for (const r of ledgerBySlot.get(s.id) ?? [])
+ if (r.media_asset_id && allowed(r.media_asset_id)) set.add(r.media_asset_id);
return set;
};
const entityAssets = entity ? assets.rows.filter((a) => a.media_entity_id === entity.id).map((a) => a.id) : [];
@@ -542,6 +574,30 @@ export async function buildSubscriptionModels(
today,
upgrade: m.upgrade,
});
+ if (!isMovie && m.spec.upgrade?.consistency === "season_release") {
+ const targets = new Map();
+ for (const season of seasons) {
+ if (season.seasonNumber === null) continue;
+ const n = season.seasonNumber;
+ const versions = r.cells
+ .filter((c) => parseUnitKey(c.unitKey)?.season === n && c.assetId)
+ .map((c) => ({
+ ...((assetById.get(c.assetId!)?.quality ?? {}) as QualityFacts),
+ ...(c.pri !== null ? { pri: c.pri } : {}),
+ }));
+ const live = inflightRows.find(
+ (p) => p.slot_id === m.row.id && p.unit_keys.some((k) => parseUnitKey(k)?.season === n),
+ );
+ targets.set(n, seasonReleaseKey(live?.quality ?? selectSeasonRelease(m.spec, versions) ?? {}));
+ }
+ for (const cell of r.cells) {
+ const season = parseUnitKey(cell.unitKey)?.season;
+ if (season === undefined || !cell.assetId) continue;
+ const target = targets.get(season);
+ if (!target || seasonReleaseKey((assetById.get(cell.assetId)?.quality ?? {}) as QualityFacts) !== target)
+ cell.upgradable = true;
+ }
+ }
if (isMovie) {
// 电影:该收录版本不要求的内容版本不算缺
for (const c of r.cells)
diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts
index 93605659..bc6d7302 100644
--- a/apps/server/src/modules/acquisition-organize/services/downloads.ts
+++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts
@@ -1011,6 +1011,19 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats,
continue;
}
await db.transaction().execute(async (trx) => {
+ const latest = await trx
+ .selectFrom("download_task")
+ .selectAll()
+ .where("id", "=", row.id)
+ .forUpdate()
+ .executeTakeFirst();
+ if (
+ !latest?.managed ||
+ latest.removed_at ||
+ latest.status === "removed" ||
+ (latest.last_synced_at && latest.last_synced_at > now)
+ )
+ return;
if (still) {
// 管理标签被移除:停止后续自动动作,保留历史与资产
await trx
@@ -1018,21 +1031,25 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats,
.set({ managed: false, tags: still.tags, last_synced_at: now, missing_since: null, missing_count: 0 })
.where("id", "=", row.id)
.execute();
- } else if (row.missing_count + 1 < MISSING_CONFIRM_ROUNDS) {
+ } else if (latest.missing_count + 1 < MISSING_CONFIRM_ROUNDS) {
// 列表与按 hash 都查不到:先记缺席(qB 刚重启、任务还没加载完时会短暂返回空 / 部分列表)
await trx
.updateTable("download_task")
- .set({ missing_since: row.missing_since ?? now, missing_count: row.missing_count + 1, last_synced_at: now })
+ .set({
+ missing_since: latest.missing_since ?? now,
+ missing_count: latest.missing_count + 1,
+ last_synced_at: now,
+ })
.where("id", "=", row.id)
.execute();
} else {
await trx
.updateTable("download_task")
- .set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: row.missing_count + 1 })
+ .set({ status: "removed", removed_at: now, last_synced_at: now, missing_count: latest.missing_count + 1 })
.where("id", "=", row.id)
.execute();
- await emitStatus(d, trx, row.id, row.status, "removed", null);
- if (row.acquisition_plan_id) await releaseRemovedPlan(d, trx, row.acquisition_plan_id);
+ await emitStatus(d, trx, row.id, latest.status, "removed", null);
+ if (latest.acquisition_plan_id) await releaseRemovedPlan(d, trx, latest.acquisition_plan_id);
stats.removed++;
}
});
@@ -1052,7 +1069,9 @@ async function observe(d: AoDeps, seen: DownloadTaskRow, t: QbTorrent, now: Date
.where("id", "=", seen.id)
.forUpdate()
.executeTakeFirst();
- if (!row || row.removed_at || row.status === "removed") return false;
+ // 多轮同步可交错;行锁之外取得的旧 qB 快照不能覆盖新一轮的标签或状态。
+ if (!row || row.removed_at || row.status === "removed" || (row.last_synced_at && row.last_synced_at > now))
+ return false;
return observeLocked(d, trx, row, t, now, stats);
});
}
diff --git a/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts b/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts
index 194294ef..f444597b 100644
--- a/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts
+++ b/apps/server/src/modules/acquisition-organize/services/evaluate-autofill.ts
@@ -84,7 +84,7 @@ export async function autoFillCheck(
if (!primary || slots.length <= 1 || primary.id === slot.id) return { action: "normal", planUnits };
if (input.force) return { action: "normal", planUnits };
- const af = subscriptionAutoFill(input.catalog, sub);
+ const af = subscriptionAutoFill(input.catalog, sub, primary);
if (!af.enabled)
return {
action: "defer",
diff --git a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts
index fd7de519..b81b6a82 100644
--- a/apps/server/src/modules/acquisition-organize/services/organize-exec.ts
+++ b/apps/server/src/modules/acquisition-organize/services/organize-exec.ts
@@ -20,16 +20,10 @@ import { errMessage, jsonb } from "../util.ts";
import { type AoDeps, upsertDecision } from "./common.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
-import { finalizeRecord, removeMovedDownload } from "./organize-finalize.ts";
+import { finalizeRecord, postprocessRecord, removeMovedDownload } from "./organize-finalize.ts";
import { emitRecord } from "./organize-plan.ts";
import type { FrozenPlan } from "./organize-types.ts";
-import {
- cleanupReplacement,
- quarantineNotVideo,
- settleSamePathReplacement,
- stagedReplacementBackup,
-} from "./replacement.ts";
-import { scrapeAfterImport } from "./scrape.ts";
+import { stagedReplacementBackup } from "./replacement.ts";
// ── 执行 ──
@@ -86,17 +80,8 @@ export async function executeRecord(
.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);
- await quarantineNotVideo(d, rec.plan as unknown as FrozenPlan, recordId);
const frozen = rec.plan as unknown as FrozenPlan;
- // 与 finalizeRecord 同一口径(按设置 / 本次选项);排队按整理记录幂等,重放不会重复刮
- if (["succeeded", "partial"].includes(rec.status)) {
- await scrapeAfterImport(d, frozen.entityId, recordId, {
- force: frozen.metadataMode === "local",
- enabled: frozen.scrapeAfterImport,
- });
- }
+ await postprocessRecord(d, frozen, recordId, rec.status);
// Move 成功但移除 qB 任务没成功(qB 暂时连不上 / 收尾后进程退出):重放时补做
if (rec.action === "move" && rec.status === "succeeded" && rec.download_task_id) {
const dt = await db
@@ -329,7 +314,7 @@ export async function executeRecord(
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 回退
+ // 执行方式:subprocess(workerKind script=bun / self=编译二进制 --role fsworker)或 inprocess 回退
fsproxy: fsr.proxy.stats(),
},
"organize.transfer",
@@ -515,7 +500,7 @@ export async function trashReplacedOld(
}
await rmdir(dirname(old.path)).catch(() => undefined);
}
- const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
+ const days = Number(process.env.FILES_TRASH_RETENTION_DAYS ?? 7) || 7;
await d.ctx.db
.insertInto("trash_item")
.values({
@@ -569,7 +554,7 @@ export function fsRuntime(d: AoDeps): FsRuntime {
if (hit) return hit;
const rt: FsRuntime = {
proxy: new FsProxy({
- mode: c.FS_PROXY_MODE ?? "auto",
+ mode: c.FS_PROXY_MODE ?? "subprocess",
hangTimeoutMs: c.FS_HANG_TIMEOUT_MS ?? 120_000,
quickTimeoutMs: c.FS_QUICK_TIMEOUT_MS ?? 30_000,
progressIntervalMs: c.FS_PROGRESS_INTERVAL_MS ?? 1000,
diff --git a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts
index 0d51a2dc..e4458e5e 100644
--- a/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts
+++ b/apps/server/src/modules/acquisition-organize/services/organize-finalize.ts
@@ -435,7 +435,12 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise a.assetId === old)
+ ) {
// 升级:旧版本保留,处置只记待处理,不删
await upsertDecision(d, trx, {
kind: "other",
@@ -525,17 +530,7 @@ export async function finalizeRecord(d: AoDeps, recordId: string): Promise {
+ await settleSamePathReplacement(d, frozen, recordId);
+ await cleanupReplacement(d, frozen, recordId);
+ await quarantineNotVideo(d, frozen, recordId);
+ if (status === "succeeded" || status === "partial") {
+ await scrapeAfterImport(d, frozen.entityId ?? null, recordId, {
+ force: frozen.metadataMode === "local",
+ enabled: frozen.scrapeAfterImport,
+ });
+ }
+}
+
/**
* Move 成功后移除 qB 任务(不删数据)并结束纳管。失败只告警:executeRecord 重放已结束记录、
* 整理巡检(recoverStalledRecords)会对"move 成功但下载任务未移除"的记录再试。
diff --git a/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts b/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts
deleted file mode 100644
index 7c3348d9..00000000
--- a/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { basename, dirname, extname, join } from "node:path";
-import type { OrganizePlan } from "@umt/domain";
-import { hasHook, interceptHook, rewriteHook } from "../../../hooks/index.ts";
-import type { ServerContext } from "../../types.ts";
-
-// 整理计划上的链式钩子(ADR-006):organize.rename 改写主视频目标文件名、organize.intercept 拒绝整理。
-// 都在计划生成之后、写整理记录之前调用;没有注册钩子时什么都不做。
-
-const BAD_NAME = /[\\/\0]/;
-const usableName = (n: unknown): boolean =>
- typeof n === "string" && n.trim() !== "" && !BAD_NAME.test(n) && n !== "." && n !== "..";
-
-/**
- * organize.rename:逐个主视频把模板生成的目标文件名交给钩子改写,同主干的附属文件(字幕、NFO 等)跟着换主干。
- * 只改文件名不改目录;返回带路径分隔符或空名的钩子结果跳过。改名后撞名的项转 hold + duplicate_target(与保留原名同一处理)。
- * 改名后的目标是否已存在由执行阶段按冲突处理(计划期的存在性探测在改名之前)。
- */
-export async function applyRenameHook(
- ctx: Pick,
- plan: OrganizePlan,
- base: { mediaEntityId: string; mediaType: "movie" | "tv" },
-): Promise {
- if (!hasHook(ctx, "organize.rename")) return 0;
- const renamed = new Map();
- for (const main of plan.files.filter((f) => f.role === "main")) {
- const name = basename(main.dst);
- // 返回带路径分隔符或空名的钩子结果跳过(记日志),其余钩子照常
- const next = await rewriteHook(
- ctx,
- "organize.rename",
- name,
- {
- targetPath: main.dst,
- sourcePath: main.src,
- mediaEntityId: base.mediaEntityId,
- mediaType: base.mediaType,
- season: main.season ?? null,
- episodes: main.episodes ?? null,
- },
- usableName,
- );
- if (next === name) continue;
- const oldStem = name.slice(0, name.length - extname(name).length);
- const newStem = next.slice(0, next.length - extname(next).length);
- const to = join(dirname(main.dst), next);
- renamed.set(main.dst, to);
- main.dst = to;
- for (const f of plan.files) {
- if (f.mainSrc !== main.src) continue;
- const side = basename(f.dst);
- if (!side.startsWith(oldStem)) continue;
- const nd = join(dirname(f.dst), newStem + side.slice(oldStem.length));
- renamed.set(f.dst, nd);
- f.dst = nd;
- }
- }
- if (renamed.size === 0) return 0;
- for (const c of plan.conflicts) if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!;
- for (const pc of plan.preconditions)
- if (pc.kind === "target_absent" || pc.kind === "verify_after_copy")
- pc.paths = pc.paths.map((p) => renamed.get(p) ?? p);
- const byDst = new Map();
- for (const f of plan.files) byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]);
- for (const [dst, fs] of byDst) {
- if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue;
- for (const f of fs) f.action = "hold";
- plan.conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: fs.map((f) => f.src).join(" | ") });
- }
- plan.requiresApproval = plan.conflicts.some((c) => c.blocking);
- return renamed.size;
-}
-
-/** organize.intercept:返回拒绝原因(含拒绝的钩子名),放行返回 null */
-export async function organizeIntercept(
- ctx: Pick,
- plan: OrganizePlan,
- base: {
- mediaEntityId: string;
- mediaType: "movie" | "tv";
- action: string;
- trigger: string;
- downloadTaskId: string | null;
- },
-) {
- return interceptHook(ctx, "organize.intercept", () => ({
- ...base,
- files: plan.files.map((f) => ({ src: f.src, dst: f.dst, kind: f.kind, role: f.role })),
- }));
-}
diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts
index 8f065a00..155c3568 100644
--- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts
+++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts
@@ -22,6 +22,7 @@ import {
replacementScope,
resolveTargetDirectory,
type SourceFile,
+ selectAcquisitionPolicy,
sidecarStem,
titleMismatches,
} from "@umt/domain";
@@ -43,9 +44,10 @@ import {
} from "../../sources-identity/service/episode-resolution.ts";
import type { Layout } from "../../sources-identity/service/episode-structure.ts";
import { wordsOption } from "../../sources-identity/service/identify-words-cache.ts";
+import { parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
import { errMessage, hashJson, jsonb, toApi } from "../util.ts";
-import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
import { classifyEntity } from "./classification.ts";
+import { collectionContext, publishedAcquisitionPolicies } from "./collection-strategy.ts";
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
import { applySlotEvent } from "./downloads.ts";
import { emitOrganizationFinished } from "./lifecycle-events.ts";
@@ -53,7 +55,6 @@ import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion
import { resolveOrganizeSource } from "./organize-config.ts";
import { PathGuardError } from "./organize-guard.ts";
import { successfulOrganizeCounts } from "./organize-history.ts";
-import { applyRenameHook, organizeIntercept } from "./organize-hooks.ts";
import { planTransfer } from "./organize-transfer.ts";
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
@@ -77,9 +78,11 @@ import {
type TakeoverIdentify,
takeoverSummary,
} from "./takeover.ts";
+import { automaticReplacementAssets } from "./upgrade-replacement.ts";
export const qualityOf = (p: ParsedRelease): QualityFacts => ({
...(p.resolution ? { resolution: p.resolution } : {}),
+ ...(p.videoCodec ? { videoCodec: p.videoCodec } : {}),
...(p.source ? { source: p.source } : {}),
hdr: p.hdr,
...(p.releaseGroup ? { releaseGroup: p.releaseGroup } : {}),
@@ -365,6 +368,9 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise !overrideFor(req.fileOverrides, f.path) && fileKindOf(basename(f.path)) === "video"),
);
const words = await wordsOption(d.ctx.db);
+ const releaseQuality = rel ? qualityOf(parsedOf(rel)) : undefined;
for (const f of files) {
let parsed = parseReleasePath(segmentsWithin(f.path, sourceRoot), words);
if (parsed.fileKind !== "video") continue;
@@ -722,7 +759,15 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise,
+ id: string,
+ policyKey: string,
+ by: string,
+ oldStatus: "superseded" | "rolled_back",
+) {
+ const current = await trx
+ .selectFrom("policy_revision")
+ .select(["kind", "scope", "rules"])
+ .where("id", "=", id)
+ .executeTakeFirstOrThrow();
+ if (current.kind === "acquisition") {
+ const scope = AcquisitionPolicyScope.safeParse(current.scope);
+ const rules = AcquisitionPolicyRules.safeParse(current.rules);
+ if (!scope.success || !rules.success) throw new ApiException("validation_failed", "收录策略范围或规则结构不合法");
+ await assertRuleGroupIds(
+ trx,
+ [...(rules.data.defaultSlots ?? []), ...(rules.data.newTitle?.slots ?? [])].flatMap((t) => [
+ ...(t.ruleGroupIds ?? []),
+ ...upgradeRuleGroupIds(t.upgradeThreshold),
+ ]),
+ );
+ }
+ const old = await trx
+ .selectFrom("policy_revision")
+ .select(["id"])
+ .where("policy_key", "=", policyKey)
+ .where("status", "=", "published")
+ .execute();
+ const now = new Date();
+ for (const o of old as { id: string }[]) {
+ await trx.updateTable("policy_revision").set({ status: oldStatus }).where("id", "=", o.id).execute();
+ await trx
+ .updateTable("automation_grant")
+ .set({ status: "revoked", revoked_at: now, revoked_by: by })
+ .where("policy_revision_id", "=", o.id)
+ .where("status", "=", "active")
+ .execute();
+ }
+ const row = await trx
+ .updateTable("policy_revision")
+ .set({ status: "published", published_at: now, published_by: by })
+ .where("id", "=", id)
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ const rules = (row.rules ?? {}) as { budget?: unknown };
+ await trx
+ .insertInto("automation_grant")
+ .values({
+ action: `policy.${row.kind}`,
+ policy_revision_id: row.id,
+ scope: jsonb(row.scope),
+ budget: jsonb(rules.budget ?? {}),
+ granted_by: by,
+ reason: `发布策略 ${policyKey} r${row.revision}`,
+ })
+ .execute();
+ return row;
+}
+
+/** 预览:用规则里的 Slot 模板评估最近已识别的发布(确定性,不产生副作用) */
+export async function previewPolicy(d: AoDeps, rules: unknown) {
+ const r = AcquisitionPolicyRules.safeParse(rules);
+ const templates = r.success ? [...(r.data.defaultSlots ?? []), ...(r.data.newTitle?.slots ?? [])] : [];
+ const rels = await d.ctx.db
+ .selectFrom("source_release")
+ .selectAll()
+ .where("resolution_status", "=", "resolved")
+ .orderBy("first_seen_at", "desc")
+ .limit(200)
+ .execute();
+ let matched = 0;
+ let excluded = 0;
+ const samples: {
+ sourceReleaseId: string;
+ title: string;
+ sizeBytes: number | null;
+ template: string;
+ decision: string;
+ reasons: string[];
+ }[] = [];
+ const cat = await loadRuleCatalog(d.ctx.db);
+ const entityIds = [...new Set(rels.map((r) => r.media_entity_id).filter((id): id is string => !!id))];
+ const entities = new Map(
+ (entityIds.length
+ ? await d.ctx.db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", entityIds).execute()
+ : []
+ ).map((e) => [e.id, e]),
+ );
+ const languages = new Map();
+ for (const rel of rels) {
+ const parsed = parsedOf(rel);
+ const entity = rel.media_entity_id ? entities.get(rel.media_entity_id) : undefined;
+ const ctx = contextOf(rel);
+ if (entity) {
+ if (!languages.has(entity.id)) languages.set(entity.id, await entityOriginalLanguage(d.ctx.db, entity.id));
+ ctx.originalLanguage = languages.get(entity.id);
+ }
+ for (const t of templates) {
+ const spec = slotSpecFromRow({
+ id: "preview",
+ name: t.name,
+ hardConditions: t.hardConditions ?? {},
+ softPreferences: t.softPreferences ?? {},
+ upgradeThreshold: t.upgradeThreshold ?? {},
+ completion: t.completion ?? {},
+ fallback: t.fallback ?? {},
+ });
+ const chain = resolveRuleChain(cat, {
+ kind: "acquisition",
+ slotRuleGroupIds: t.ruleGroupIds,
+ upgrade: false,
+ mediaKind: entity?.kind === "movie" ? "movie" : entity?.kind === "variety" ? "variety" : "series",
+ entityId: entity?.id,
+ siteId: rel.site_id,
+ });
+ spec.hard = mergeHard(spec.hard, chain.hard);
+ if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
+ const ev = evaluateCandidate(spec, parsed, null, ctx);
+ if (ev.decision === "accept") matched++;
+ else excluded++;
+ if (samples.length < 20) {
+ const reasons =
+ ev.decision === "reject"
+ ? ev.violations.map((v) => `${v.field}:${v.code}`)
+ : ev.decision === "unknown_field"
+ ? ev.missing.map((m) => `missing:${m}`)
+ : "reasons" in ev
+ ? (ev.reasons ?? [])
+ : [];
+ samples.push({
+ sourceReleaseId: rel.id,
+ title: rel.title,
+ sizeBytes: rel.size_bytes,
+ template: t.name,
+ decision: ev.decision,
+ reasons,
+ });
+ }
+ }
+ }
+ return {
+ evaluatedAt: new Date().toISOString(),
+ sampleSize: rels.length,
+ templates: templates.length,
+ matched,
+ excluded,
+ samples,
+ valid: r.success,
+ };
+}
diff --git a/apps/server/src/modules/acquisition-organize/services/replacement.ts b/apps/server/src/modules/acquisition-organize/services/replacement.ts
index 4d4e75fd..462b152f 100644
--- a/apps/server/src/modules/acquisition-organize/services/replacement.ts
+++ b/apps/server/src/modules/acquisition-organize/services/replacement.ts
@@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto";
import { basename, dirname, join, relative } from "node:path";
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
import { sql } from "@umt/db";
-import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
+import {
+ type QualityFacts,
+ type ReplacementScope,
+ replacementCovers,
+ replacementScope,
+ seasonReleaseKey,
+} from "@umt/domain";
import { mkdir, readdir, realpath, rm } from "../../../storage/local-fs.ts";
import {
isRemotePath,
@@ -66,6 +72,7 @@ export interface ReplacementAsset {
export interface FrozenReplacement {
mode: "replace";
assets: ReplacementAsset[];
+ automatic?: { slotId: string; quality: QualityFacts };
}
export async function freezeReplacement(
@@ -235,6 +242,31 @@ export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId
),
];
});
+ // 分次洗版:本次核验成功后,可以用同保留版本、同套且已核验的文件补齐旧合集覆盖。
+ const automatic = frozen.replacement.automatic;
+ const targetKey = automatic ? seasonReleaseKey(automatic.quality) : null;
+ if (automatic && targetKey && successful.length && frozen.mediaType === "tv") {
+ const existing = await sql<{ quality: QualityFacts; path: string; season: number; episode: number }>`
+ select coalesce(r.parsed, '{}'::jsonb) || coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) as quality, l.relative_path as path, u.season_number as season, u.episode_number as episode
+ from media_asset a
+ left join source_release r on r.id = a.source_release_id
+ join asset_location l on l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available'
+ join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
+ join content_unit u on u.id = c.content_unit_id
+ where a.media_entity_id = ${frozen.entityId!} and a.status = 'active'
+ and a.id not in (${sql.join(frozen.replacement.assets.map((a) => a.assetId))})
+ and u.season_number is not null and u.episode_number is not null
+ and (exists (select 1 from slot_unit su where su.slot_id = ${automatic.slotId} and su.media_asset_id = a.id)
+ or exists (select 1 from download_task dt join acquisition_plan p on p.id = dt.acquisition_plan_id
+ where dt.id = a.download_task_id and p.slot_id = ${automatic.slotId} and p.status = 'completed'))
+ and exists (select 1 from organization_file_result f join organization_record rec on rec.id = f.organization_record_id
+ where f.media_asset_id = a.id and f.role = 'main' and a.verified_at is not null and (f.status = 'succeeded' or (f.status = 'skipped' and f.skip_reason = 'already_in_library')) and rec.status in ('succeeded', 'partial'))`.execute(
+ db,
+ );
+ for (const file of existing.rows)
+ if (seasonReleaseKey(file.quality ?? {}) === targetKey)
+ scopes.push(replacementScope(file.path, "tv", { season: file.season, episodes: [file.episode] }));
+ }
for (const old of frozen.replacement.assets) {
if (successful.some((f) => f.media_asset_id === old.assetId)) continue;
if (!replacementCovers(old.scope, scopes)) {
@@ -595,7 +627,7 @@ export async function quarantineNotVideo(d: AoDeps, frozen: FrozenPlan, recordId
const trash = join(rootReal, ".umt-trash", now.toISOString().slice(0, 10).replace(/-/g, ""), id, rel);
const m = await fsr.move(abs, trash);
if (m.status !== "moved") throw new Error(`移入回收站失败:${m.status}`);
- const days = Number(Deno.env.get("FILES_TRASH_RETENTION_DAYS") ?? 7) || 7;
+ const days = Number(process.env.FILES_TRASH_RETENTION_DAYS ?? 7) || 7;
await db
.insertInto("trash_item")
.values({
diff --git a/apps/server/src/modules/acquisition-organize/services/rules.ts b/apps/server/src/modules/acquisition-organize/services/rules.ts
index f005c003..82d1609f 100644
--- a/apps/server/src/modules/acquisition-organize/services/rules.ts
+++ b/apps/server/src/modules/acquisition-organize/services/rules.ts
@@ -384,3 +384,25 @@ export async function previewRuleGroup(
items,
};
}
+
+/** 所有引用入口共享存在性校验,洗版引用与补齐引用使用相同口径。 */
+export async function assertRuleGroupIds(exec: Exec, ids: readonly string[]) {
+ if (!ids.length) return;
+ const rows = await exec
+ .selectFrom("rule_group")
+ .select("id")
+ .where("id", "in", [...ids])
+ .where("deleted_at", "is", null)
+ .execute();
+ const have = new Set(rows.map((r) => r.id));
+ const missing = ids.filter((id) => !have.has(id));
+ if (missing.length) throw new ApiException("bad_request", `规则组不存在:${missing.join(", ")}`);
+}
+
+export function upgradeRuleGroupIds(upgrade: unknown): string[] {
+ const ids = (upgrade as { ruleGroupIds?: unknown } | null)?.ruleGroupIds;
+ if (ids === undefined) return [];
+ if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string" || !id))
+ throw new ApiException("validation_failed", "洗版规则组引用必须是 ID 列表");
+ return ids;
+}
diff --git a/apps/server/src/modules/acquisition-organize/services/season-release.ts b/apps/server/src/modules/acquisition-organize/services/season-release.ts
new file mode 100644
index 00000000..3d5fa07e
--- /dev/null
+++ b/apps/server/src/modules/acquisition-organize/services/season-release.ts
@@ -0,0 +1,74 @@
+import type { AcquisitionSlotRow, SourceReleaseRow } from "@umt/db";
+import { sql } from "@umt/db";
+import {
+ currentPri,
+ effectiveLadder,
+ parseUnitKey,
+ type QualityFacts,
+ ruleChainOf,
+ type SlotSpec,
+ selectSeasonRelease,
+} from "@umt/domain";
+import { contextOf, parsedOf } from "../../sources-identity/service/release-facts.ts";
+import type { Exec } from "./common.ts";
+
+/** 有成套洗版在途时保持其冻结规格;结束后才允许选择更高档的新套。 */
+export async function seasonReleaseTarget(
+ exec: Exec,
+ slot: AcquisitionSlotRow,
+ season: number,
+ spec: SlotSpec,
+ candidate?: QualityFacts,
+ originalLanguage?: string,
+): Promise {
+ const allowed = (q: QualityFacts) =>
+ !spec.hard.resolutions?.length || (!!q.resolution && spec.hard.resolutions.includes(q.resolution));
+ const inflight = await exec
+ .selectFrom("acquisition_plan as p")
+ .innerJoin("source_release as r", "r.id", "p.source_release_id")
+ .selectAll("r")
+ .select(["p.unit_keys as planned_units", "p.evaluation"])
+ .where("p.slot_id", "=", slot.id)
+ .where("p.status", "in", ["reserved", "submitted"])
+ .orderBy("p.created_at")
+ .orderBy("p.id")
+ .execute();
+ const inSeason = (keys: string[]) => keys.some((k) => parseUnitKey(k)?.season === season);
+ for (const plan of inflight) {
+ const evaluation = plan.evaluation as { seasonRelease?: string; quality?: QualityFacts };
+ if (inSeason(plan.planned_units) && evaluation.seasonRelease && evaluation.quality && allowed(evaluation.quality))
+ return evaluation.quality;
+ }
+ const assets = await sql<{ quality: QualityFacts; source_release_id: string | null }>`
+ select distinct coalesce(a.media_info->'_umt'->'quality', '{}'::jsonb) || coalesce(su.quality, '{}'::jsonb) as quality, a.source_release_id
+ from media_asset a
+ join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
+ join content_unit u on u.id = c.content_unit_id
+ left join slot_unit su on su.media_asset_id = a.id and su.content_unit_id = u.id and su.slot_id = ${slot.id}
+ left join download_task dt on dt.id = a.download_task_id
+ left join acquisition_plan p on p.id = dt.acquisition_plan_id
+ where a.status = 'active' and u.season_number = ${season}
+ and (su.slot_id = ${slot.id} or p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
+ or (a.media_entity_id = ${slot.media_entity_id} and (select count(*) from acquisition_slot where media_entity_id = ${slot.media_entity_id}) = 1))`.execute(
+ exec,
+ );
+ const relIds = [...new Set(assets.rows.map((a) => a.source_release_id).filter((id): id is string => !!id))];
+ const rels = new Map(
+ (relIds.length ? await exec.selectFrom("source_release").selectAll().where("id", "in", relIds).execute() : []).map(
+ (r) => [r.id, r],
+ ),
+ );
+ const facts = (r: SourceReleaseRow, delivered?: QualityFacts): QualityFacts => {
+ const parsed = { ...parsedOf(r), ...delivered };
+ const ctx = { ...contextOf(r), ...(originalLanguage ? { originalLanguage } : {}) };
+ return { ...parsed, pri: effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri };
+ };
+ const versions: QualityFacts[] = candidate ? [candidate] : [];
+ for (const a of assets.rows) {
+ const rel = a.source_release_id ? rels.get(a.source_release_id) : undefined;
+ const q = rel ? facts(rel, a.quality) : { ...a.quality, pri: undefined };
+ versions.push({ ...q, pri: currentPri(spec, q) });
+ }
+ for (const r of inflight) if (inSeason(r.planned_units)) versions.push(facts(r));
+ return selectSeasonRelease(spec, versions.filter(allowed));
+}
diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts
index a24ba6d2..8c70e962 100644
--- a/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts
+++ b/apps/server/src/modules/acquisition-organize/services/subscriptions-params.ts
@@ -226,7 +226,7 @@ export async function applySlotPreset(
if (sameJson(a, b)) return 0;
const slots = await exec
.selectFrom("acquisition_slot")
- .select(["id", "hard_conditions", "soft_preferences", "revision"])
+ .select(["id", "hard_conditions", "soft_preferences", "revision", "policy_revision_id"])
.where("subscription_id", "=", input.subscriptionId)
.execute();
let n = 0;
@@ -238,7 +238,17 @@ export async function applySlotPreset(
for (const s of slots) {
const curHard = (s.hard_conditions ?? {}) as Record;
const curSoft = (s.soft_preferences ?? {}) as Record;
- const hard = merge(curHard, a.hard, b.hard);
+ // 策略模板的分辨率范围是保留版本边界;统一预设只作用于隐式默认版本。
+ const fromHard = { ...a.hard };
+ const toHard = { ...b.hard };
+ if (
+ curHard.resolutions &&
+ (s.policy_revision_id || slots.length > 1 || !sameJson(curHard.resolutions, a.hard.resolutions))
+ ) {
+ delete fromHard.resolutions;
+ delete toHard.resolutions;
+ }
+ const hard = merge(curHard, fromHard, toHard);
const soft = merge(curSoft, a.soft, b.soft);
if (sameJson(hard, curHard) && sameJson(soft, curSoft)) continue;
await exec
diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts
index ed3041c3..32cfd5e3 100644
--- a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts
+++ b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts
@@ -22,15 +22,17 @@ import {
type SeasonProgress,
type SlotSpec,
seasonProgress,
+ seasonReleaseKey,
seasonWildcard,
+ selectSeasonRelease,
type UnitRange,
+ upgradeSpec,
} from "@umt/domain";
import { slotSpecFromRow } from "@umt/mapping";
-import { interceptHook } from "../../../hooks/index.ts";
+import { contextOf, parsedOf, unitKeysOf } from "../../sources-identity/service/release-facts.ts";
import { errMessage, jsonb, toApi } from "../util.ts";
import { type AoDeps, type Exec, upsertDecision } from "./common.ts";
import { emitSubscriptionEvent } from "./lifecycle-events.ts";
-import { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
// W5-C 订阅业务:季总集数账本、缺集/在途、完成判定与历史、Slot 重评调度、分集质量账本、主动搜索调度。
@@ -212,9 +214,14 @@ export async function applyEpisodeTotal(
/** Slot 已覆盖的内容单元:该 Slot 计划产出资产 + 当前资产 + 分集账本(content_unit id 与合成键两种口径) */
export async function slotCoveredUnits(
exec: Exec,
- slot: Pick,
+ slot: Pick &
+ Partial>,
opts: { entityWide?: boolean } = {},
): Promise<{ keys: Set; episodes: Map> }> {
+ const allowed = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions ?? [];
+ const resolutionFilter = allowed.length
+ ? sql`and coalesce(a.media_info->'_umt'->'quality'->>'resolution', r.parsed->>'resolution') in (${sql.join(allowed)})`
+ : sql``;
const r = await sql<{
unit_id: string;
season_number: number | null;
@@ -224,9 +231,10 @@ export async function slotCoveredUnits(
}>`
with assets as (
select a.id from media_asset a
+ left join source_release r on r.id = a.source_release_id
left join download_task dt on dt.id = a.download_task_id
left join acquisition_plan p on p.id = dt.acquisition_plan_id
- where a.status = 'active' and a.parent_asset_id is null
+ where a.status = 'active' and a.parent_asset_id is null ${resolutionFilter}
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
or a.id in (select media_asset_id from slot_unit where slot_id = ${slot.id})
${opts.entityWide ? sql`or a.media_entity_id = ${slot.media_entity_id}` : sql``})
@@ -237,6 +245,7 @@ export async function slotCoveredUnits(
join content_unit u on u.id = c.content_unit_id
union
select u.id, u.season_number, u.episode_number, u.kind, to_char(u.air_date, 'YYYYMMDD') from slot_unit su
+ join assets on assets.id = su.media_asset_id
join content_unit u on u.id = su.content_unit_id
where su.slot_id = ${slot.id} and su.media_asset_id is not null`.execute(exec);
const keys = new Set();
@@ -257,9 +266,10 @@ export async function slotCoveredUnits(
const legacy = await sql<{ keys: string[] | null }>`
select array(select jsonb_array_elements_text(coalesce(a.media_info->'_umt'->'unitKeys', '[]'::jsonb))) as keys
from media_asset a
+ left join source_release r on r.id = a.source_release_id
left join download_task dt on dt.id = a.download_task_id
left join acquisition_plan p on p.id = dt.acquisition_plan_id
- where a.status = 'active' and a.parent_asset_id is null
+ where a.status = 'active' and a.parent_asset_id is null ${resolutionFilter}
and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id})`.execute(exec);
for (const row of legacy.rows)
for (const k of row.keys ?? []) {
@@ -376,11 +386,13 @@ export function slotSpecWithRules(
siteId?: string,
): SlotSpec {
const spec = slotSpecFromRow(toApi(slot));
+ if (entity.kind === "movie" && spec.upgrade?.consistency) spec.upgrade = { ...spec.upgrade, consistency: undefined };
+ const baseHard = spec.hard;
const chain = resolveRuleChain(cat, {
kind: "acquisition",
slotRuleGroupIds: (slot.rule_group_ids ?? []) as string[],
subRuleGroupIds: subParams(sub).ruleGroupIds,
- upgrade: !!spec.upgrade?.enabled,
+ upgrade: false,
mediaKind: ruleMediaKind(entity.kind),
entityId: entity.id,
siteId,
@@ -388,6 +400,22 @@ export function slotSpecWithRules(
});
spec.hard = mergeHard(spec.hard, chain.hard);
if (chain.groups.length) spec.rules = { groups: chain.groups, atoms: chain.atoms };
+ if (spec.upgrade?.enabled) {
+ const washing = resolveRuleChain(cat, {
+ kind: "acquisition",
+ slotRuleGroupIds: spec.upgrade.ruleGroupIds?.length
+ ? spec.upgrade.ruleGroupIds
+ : ((slot.rule_group_ids ?? []) as string[]),
+ subRuleGroupIds: subParams(sub).ruleGroupIds,
+ upgrade: !spec.upgrade.ruleGroupIds?.length,
+ mediaKind: ruleMediaKind(entity.kind),
+ entityId: entity.id,
+ siteId,
+ subscriptionId: sub?.id,
+ });
+ spec.upgradeHard = mergeHard(baseHard, washing.hard);
+ spec.upgradeRules = { groups: washing.groups, atoms: washing.atoms };
+ }
return spec;
}
@@ -396,8 +424,10 @@ export function slotSpecWithRules(
* (同 evaluateCandidate 的 at_ceiling);按来源等级比较的升级没有「最高档」,返回 null(覆盖齐即完成)。
*/
export function upgradeTargetPri(spec: SlotSpec): number | null {
+ spec = upgradeSpec(spec);
const up = spec.upgrade;
if (!up?.enabled) return null;
+ if (up.consistency === "season_release") return up.targetPri ?? 100;
const tiered = !!spec.soft?.tiers?.length || !!spec.rules?.groups.some((g) => g.layers.length > 0);
return (up.basis ?? (tiered ? "priority" : "tier")) === "priority" ? (up.targetPri ?? 100) : null;
}
@@ -413,6 +443,8 @@ interface PriVersion {
/** 覆盖的单元:季 → 集号集合;null = 整季(季包通配) */
units: Map | null>;
pri: number;
+ releaseKey: string | null;
+ quality: QualityFacts;
}
/**
@@ -441,13 +473,13 @@ async function upgradeReached(
kind: sub?.media_kind ?? (input.mediaType === "movie" ? "movie" : "series"),
};
const lang = await entityOriginalLanguage(exec, slot.media_entity_id);
- const relPri = (rel: SourceReleaseRow) => {
- const spec = slotSpecWithRules(cat, slot, sub, entity, rel.site_id);
- const parsed = parsedOf(rel);
+ const relPri = (rel: SourceReleaseRow, quality?: QualityFacts) => {
+ const spec = upgradeSpec(slotSpecWithRules(cat, slot, sub, entity, rel.site_id));
+ const parsed = { ...parsedOf(rel), ...quality };
const ctx = { ...contextOf(rel), ...(lang ? { originalLanguage: lang } : {}) };
return effectiveLadder(spec, parsed, ctx, ruleChainOf(spec, parsed, ctx)).pri;
};
- const baseSpec = slotSpecWithRules(cat, slot, sub, entity);
+ const baseSpec = upgradeSpec(slotSpecWithRules(cat, slot, sub, entity));
const versions: PriVersion[] = [];
const assetIds = [...new Set([...input.newAssets, ...(slot.current_asset_id ? [slot.current_asset_id] : [])])];
const assets = await exec
@@ -515,8 +547,17 @@ async function upgradeReached(
: [];
for (const x of assets) {
const rel = x.source_release_id ? rels.get(x.source_release_id) : undefined;
+ const quality = {
+ ...(rel ? parsedOf(rel) : {}),
+ ...((x.media_info as { _umt?: { quality?: QualityFacts } })?._umt?.quality ?? {}),
+ };
+ if (
+ baseSpec.hard.resolutions?.length &&
+ (!quality.resolution || !baseSpec.hard.resolutions.includes(quality.resolution))
+ )
+ continue;
const pri =
- (rel ? relPri(rel) : undefined) ??
+ (rel ? relPri(rel, quality) : undefined) ??
evalPri(x.plan_evaluation) ??
currentPri(
baseSpec,
@@ -530,12 +571,18 @@ async function upgradeReached(
set.add(c.episode_number);
units.set(s, set);
}
- versions.push({ units, pri });
+ versions.push({ units, pri, quality: { ...quality, pri }, releaseKey: seasonReleaseKey(quality) });
}
for (const p of plans) {
const rel = p.source_release_id ? rels.get(p.source_release_id) : undefined;
const pri = (rel ? relPri(rel) : undefined) ?? evalPri(p.evaluation);
- if (pri === undefined) continue;
+ const quality: QualityFacts = rel ? parsedOf(rel) : {};
+ if (
+ pri === undefined ||
+ (baseSpec.hard.resolutions?.length &&
+ (!quality.resolution || !baseSpec.hard.resolutions.includes(quality.resolution)))
+ )
+ continue;
const units = new Map | null>();
for (const k of p.unit_keys) {
const u = parseUnitKey(k);
@@ -544,9 +591,33 @@ async function upgradeReached(
else if (u.episode !== undefined && units.get(u.season) !== null)
units.set(u.season, (units.get(u.season) ?? new Set()).add(u.episode));
}
- versions.push({ units, pri });
+ versions.push({ units, pri, quality: { ...quality, pri }, releaseKey: seasonReleaseKey(quality) });
}
if (input.mediaType === "movie") return versions.some((v) => v.pri >= input.target);
+ if (baseSpec.upgrade?.consistency === "season_release") {
+ for (const [season, eps] of input.covered) {
+ const targetKey = seasonReleaseKey(
+ selectSeasonRelease(
+ baseSpec,
+ versions.filter((v) => v.units.has(season)).map((v) => v.quality),
+ ) ?? {},
+ );
+ if (
+ !targetKey ||
+ !eps.every((e) =>
+ versions.some(
+ (v) =>
+ v.releaseKey === targetKey &&
+ v.pri >= input.target &&
+ v.units.has(season) &&
+ (v.units.get(season) === null || v.units.get(season)!.has(e)),
+ ),
+ )
+ )
+ return false;
+ }
+ return true;
+ }
for (const [season, eps] of input.covered)
for (const e of eps) {
const best = Math.max(
@@ -603,16 +674,21 @@ export async function slotComplete(
opts: { includeSubmitted?: boolean } = {},
): Promise {
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
- const accepted = opts.includeSubmitted
+ const allowedResolutions = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
+ const acceptedRows = opts.includeSubmitted
? await exec
.selectFrom("acquisition_plan as p")
.innerJoin("source_release as r", "r.id", "p.source_release_id")
- .select(["p.id as plan_id", "p.unit_keys", "r.parsed", "r.title"])
+ .selectAll("r")
+ .select(["p.id as plan_id", "p.unit_keys"])
.where("p.slot_id", "=", slot.id)
.where("p.status", "in", ["submitted", "completed"])
.where("p.status_reason", "!=", "accepted_unconfirmed")
.execute()
: [];
+ const accepted = acceptedRows.filter(
+ (r) => !allowedResolutions?.length || allowedResolutions.includes(parsedOf(r).resolution ?? ""),
+ );
const sub = slot.subscription_id
? ((await exec.selectFrom("subscription").selectAll().where("id", "=", slot.subscription_id).executeTakeFirst()) ??
null)
@@ -735,7 +811,7 @@ export async function afterSlotVerified(
const plan = input.planId
? await trx
.selectFrom("acquisition_plan")
- .select(["id", "purpose", "unit_keys"])
+ .select(["id", "purpose", "unit_keys", "evaluation"])
.where("id", "=", input.planId)
.executeTakeFirst()
: undefined;
@@ -757,7 +833,11 @@ export async function afterSlotVerified(
const planUnits = new Set(plan?.unit_keys ?? []);
for (const r of cov.rows) {
const q = qualityOf(r.media_info);
- const pri = priorityLadder(spec, { raw: "", hdr: [], isEncode: false, ...q } as unknown as ParsedRelease).pri;
+ const plannedPri = (plan?.evaluation as { pri?: unknown })?.pri;
+ const pri =
+ typeof plannedPri === "number"
+ ? plannedPri
+ : priorityLadder(spec, { raw: "", hdr: [], isEncode: false, ...q } as unknown as ParsedRelease).pri;
const inPlan = planUnits.has(r.unit_id) || (r.ep !== null && planUnits.has(episodeKey(r.season ?? 1, r.ep)));
const replace = plan?.purpose === "upgrade" ? inPlan : true;
const values = {
@@ -876,18 +956,6 @@ export async function completeSubscription(
reason: string,
progress: Record = {},
): Promise {
- // 链式钩子 subscription.completion.check(ADR-006):追加完成条件,拒绝时订阅继续追踪
- const veto = await interceptHook(d.ctx, "subscription.completion.check", () => ({
- subscriptionId: sub.id,
- mediaEntityId: sub.media_entity_id,
- mediaKind: sub.media_kind,
- reason,
- progress,
- }));
- if (veto) {
- d.ctx.logger.info({ subscriptionId: sub.id, reason, veto }, "订阅完成被钩子拦下,继续追踪");
- return false;
- }
const done = await trx
.updateTable("subscription")
.set({ status: "completed", status_reason: reason, next_search_at: null })
diff --git a/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts b/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts
new file mode 100644
index 00000000..237487c0
--- /dev/null
+++ b/apps/server/src/modules/acquisition-organize/services/upgrade-replacement.ts
@@ -0,0 +1,49 @@
+import type { AcquisitionSlotRow } from "@umt/db";
+import { sql } from "@umt/db";
+import { episodeKey, type QualityFacts, unitsOverlap } from "@umt/domain";
+import type { Exec } from "./common.ts";
+
+/** 自动洗版只选择本保留版本的旧文件;合集可以先冻结,完整覆盖后再回收。 */
+export async function automaticReplacementAssets(
+ exec: Exec,
+ slot: AcquisitionSlotRow,
+ units: readonly string[],
+ candidate: QualityFacts,
+): Promise {
+ const rows = await sql<{
+ id: string;
+ resolution: string | null;
+ cut: string | null;
+ season: number | null;
+ episode: number | null;
+ }>`
+ select distinct a.id, coalesce(a.media_info->'_umt'->'quality'->>'resolution', r.parsed->>'resolution') as resolution,
+ a.content_edition as cut, u.season_number as season, u.episode_number as episode
+ from media_asset a
+ left join source_release r on r.id = a.source_release_id
+ left join download_task dt on dt.id = a.download_task_id
+ left join acquisition_plan p on p.id = dt.acquisition_plan_id
+ left join asset_unit_coverage c on c.media_asset_id = a.id and c.coverage = 'full'
+ left join content_unit u on u.id = c.content_unit_id
+ where a.media_entity_id = ${slot.media_entity_id} and a.status = 'active' and a.kind in ('video', 'bluray_dir')
+ and a.parent_asset_id is null
+ and (p.slot_id = ${slot.id} or a.id = ${slot.current_asset_id}
+ or exists (select 1 from slot_unit where slot_id = ${slot.id} and media_asset_id = a.id)
+ or (select count(*) from acquisition_slot where media_entity_id = ${slot.media_entity_id}) = 1)
+ and exists (select 1 from asset_location l join library_root lr on lr.id = l.library_root_id
+ where l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available' and lr.management_mode = 'managed')
+ and not exists (select 1 from asset_location l join library_root lr on lr.id = l.library_root_id
+ where l.media_asset_id = a.id and l.location_kind = 'library' and l.status = 'available' and lr.management_mode <> 'managed')`.execute(
+ exec,
+ );
+ const allowed = ((slot.hard_conditions ?? {}) as { resolutions?: string[] }).resolutions;
+ const selected = new Set();
+ for (const r of rows.rows) {
+ if (allowed?.length && (!r.resolution || !allowed.includes(r.resolution))) continue;
+ if (!units.length) {
+ if ((r.cut ?? "default") === (candidate.cut ?? "default")) selected.add(r.id);
+ } else if (r.season !== null && r.episode !== null && unitsOverlap(units, [episodeKey(r.season, r.episode)]).length)
+ selected.add(r.id);
+ }
+ return [...selected];
+}
diff --git a/apps/server/src/modules/agent/authorize.ts b/apps/server/src/modules/agent/authorize.ts
index 8271d792..de1f0832 100644
--- a/apps/server/src/modules/agent/authorize.ts
+++ b/apps/server/src/modules/agent/authorize.ts
@@ -1,7 +1,7 @@
-import { createHash } from "node:crypto";
import type { AuthorizationChecker, AuthorizationDecision } from "@umt/agent";
import type { CapabilitySpec } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
+import { APPROVAL_PLAN, coveringGrant, type PlanBinding, planBinding } from "../../authorization/grants.ts";
import type { SettingsReader } from "../types.ts";
import { inlineApproval, optionApproval, proposeFirstBlock } from "./interaction.ts";
import { investigationDecisionId } from "./runner.ts";
@@ -15,172 +15,9 @@ import { exceedsAncestors } from "./subagents.ts";
// 模型参数里的"用户已同意"不会被读取;输入里的 approvalId 也只是指针,必须在库里核对。
// 控制台管理员从 Web 手动调用(source=web 且 actor=user:*)本身就是人工授权;API Key / Agent / 后台任务一律要授权记录。
-/** 能力 → 可覆盖它的授权动作(策略发布生成 policy. 持续授权;"*" 全部) */
-export const GRANT_ACTIONS: Record = {
- "identity.resolve": ["identity.resolve", "policy.identity"],
- "download.submit": ["download.submit", "policy.acquisition"],
- "organize.execute": ["organize.execute", "policy.acquisition", "policy.organize"],
- "download.recheck": ["download.recheck", "policy.automation"],
- "task.retry": ["task.retry", "task.*"],
- "task.cancel": ["task.cancel", "task.*"],
- "policy.apply": ["policy.apply"],
- // 集数映射兜底(批次 5):只认专门的持续授权,不随身份识别策略放开
- "episode_mapping.apply_rule": ["episode_mapping.apply_rule"],
- // VIS-1:坐标操作与视觉子流程和 browser.interact 同一档(操作原站页面),同一条持续授权覆盖
- "browser.click_at": ["browser.click_at", "browser.interact"],
- "browser.type_at": ["browser.type_at", "browser.interact"],
- "browser.scroll": ["browser.scroll", "browser.interact"],
- "browser.solve_challenge": ["browser.solve_challenge", "browser.interact"],
-};
-
/** 视觉子流程内由 solve_challenge 授权覆盖的执行类坐标动作 */
const CHALLENGE_STEP_CAPS = new Set(["browser.click_at", "browser.type_at", "browser.scroll"]);
-/** 单次审批绑定的计划类型与输入里的计划 ID 字段 */
-export const APPROVAL_PLAN: Record = {
- "download.submit": { planType: "acquisition_plan", field: "planId" },
- "organize.execute": { planType: "organize_plan", field: "planId" },
- "policy.apply": { planType: "policy_revision", field: "policyRevisionId" },
-};
-
-export interface PlanBinding {
- payloadHash: string;
- revision: number;
- mediaEntityId: string | null;
- mediaKind: string | null;
- bytes: number | null;
-}
-
-const sha = (s: string) => createHash("sha256").update(s, "utf8").digest("hex");
-
-/** 读计划当前的 payload hash / revision / 目标作品(审批绑定与范围校验共用) */
-export async function planBinding(db: Kysely, planType: string, planId: string): Promise {
- if (planType === "acquisition_plan") {
- const r = await db
- .selectFrom("acquisition_plan as p")
- .leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
- .leftJoin("media_entity as e", (j) =>
- j.onRef("e.id", "=", sql`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
- )
- .select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
- .where("p.id", "=", planId)
- .executeTakeFirst();
- return r
- ? {
- payloadHash: r.payload_hash,
- revision: r.expected_revision,
- mediaEntityId: r.entity_id,
- mediaKind: r.kind,
- bytes: Number(r.reserved_bytes),
- }
- : null;
- }
- if (planType === "organize_plan") {
- const r = await db
- .selectFrom("artifact")
- .select(["content_hash", "content"])
- .where("id", "=", planId)
- .where("kind", "=", "plan")
- .executeTakeFirst();
- if (!r) return null;
- const c = (r.content ?? {}) as { mediaEntityId?: string | null; mediaKind?: string | null };
- return {
- payloadHash: r.content_hash,
- revision: 1,
- mediaEntityId: c.mediaEntityId ?? null,
- mediaKind: c.mediaKind ?? null,
- bytes: null,
- };
- }
- if (planType === "policy_revision") {
- const r = await db
- .selectFrom("policy_revision")
- .select(["revision", "rules", "scope"])
- .where("id", "=", planId)
- .executeTakeFirst();
- return r
- ? {
- payloadHash: sha(JSON.stringify({ rules: r.rules, scope: r.scope })),
- revision: r.revision,
- mediaEntityId: null,
- mediaKind: null,
- bytes: null,
- }
- : null;
- }
- return null;
-}
-
-/**
- * 持续授权是否覆盖本能力(不含单次审批与会话内审批):action 覆盖、active 未过期、绑定策略仍发布、
- * 对象范围与每日预算。download.plan 用它判定 requiresApproval,authorize 用它放行。
- */
-export async function coveringGrant(
- db: Kysely,
- capabilityId: string,
- target: PlanBinding | null,
- now: Date,
-): Promise<{ grantId: string } | { reasons: string[] }> {
- const d = { id: capabilityId };
- const actions = [...(GRANT_ACTIONS[d.id] ?? [d.id]), "*"];
- const grants = await db
- .selectFrom("automation_grant as g")
- .leftJoin("policy_revision as p", "p.id", "g.policy_revision_id")
- .select(["g.id", "g.action", "g.scope", "g.budget", "g.policy_revision_id", "p.status as policy_status"])
- .where("g.status", "=", "active")
- .where("g.action", "in", actions)
- .where((eb) => eb.or([eb("g.expires_at", "is", null), eb("g.expires_at", ">", now)]))
- .orderBy("g.created_at", "desc")
- .execute();
- const reasons: string[] = [];
- for (const g of grants) {
- if (g.policy_revision_id && g.policy_status !== "published") {
- reasons.push(`授权 ${g.id} 绑定的策略版本已不是发布状态`);
- continue;
- }
- const scope = (g.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[]; capabilities?: string[] };
- if (scope.capabilities?.length && !scope.capabilities.includes(d.id)) {
- reasons.push(`授权 ${g.id} 不含能力 ${d.id}`);
- continue;
- }
- if (
- scope.mediaEntityIds?.length &&
- (!target?.mediaEntityId || !scope.mediaEntityIds.includes(target.mediaEntityId))
- ) {
- reasons.push(`授权 ${g.id} 的对象范围不含目标作品`);
- continue;
- }
- if (scope.mediaKinds?.length && target?.mediaKind && !kindCovered(scope.mediaKinds, target.mediaKind)) {
- reasons.push(`授权 ${g.id} 的媒体类型范围不含 ${target.mediaKind}`);
- continue;
- }
- const budget = (g.budget ?? {}) as { maxInvocationsPerDay?: number; maxBytesPerDay?: number };
- if (budget.maxInvocationsPerDay !== undefined || budget.maxBytesPerDay !== undefined) {
- const day = new Date(now);
- day.setHours(0, 0, 0, 0);
- const used = await sql<{ n: number }>`
- select count(*)::int as n from capability_invocation
- where grant_id = ${g.id} and status = 'succeeded' and created_at >= ${day}`.execute(db);
- if (budget.maxInvocationsPerDay !== undefined && (used.rows[0]?.n ?? 0) >= budget.maxInvocationsPerDay) {
- reasons.push(`授权 ${g.id} 今日次数预算已用尽`);
- continue;
- }
- if (budget.maxBytesPerDay !== undefined && d.id === "download.submit") {
- const bytes = await sql<{ b: string }>`
- select coalesce(sum(p.reserved_bytes), 0)::bigint as b
- from capability_invocation ci join acquisition_plan p on p.id::text = ci.input->>'planId'
- where ci.grant_id = ${g.id} and ci.status = 'succeeded' and ci.created_at >= ${day}`.execute(db);
- if (Number(bytes.rows[0]?.b ?? 0) + (target?.bytes ?? 0) > budget.maxBytesPerDay) {
- reasons.push(`授权 ${g.id} 今日字节预算不足`);
- continue;
- }
- }
- }
- return { grantId: g.id };
- }
- return { reasons };
-}
-
export function createAuthorizer(deps: {
db: Kysely;
settings: SettingsReader;
@@ -284,6 +121,16 @@ export function createAuthorizer(deps: {
// 集数映射:持续授权的对象范围按事项的作品核对
if (d.id === "episode_mapping.apply_rule" && typeof inp.decisionId === "string")
target = await episodeTarget(db, inp.decisionId);
+ if (d.id === "release.apply_facts" && typeof inp.sourceReleaseId === "string") {
+ const release = await db
+ .selectFrom("source_release as r")
+ .innerJoin("media_entity as e", "e.id", "r.media_entity_id")
+ .select(["e.id", "e.kind"])
+ .where("r.id", "=", inp.sourceReleaseId)
+ .executeTakeFirst();
+ if (!release) return { allowed: false, reason: "资源尚未关联作品,无法核对属性补全授权范围" };
+ target = { payloadHash: "", revision: 0, mediaEntityId: release.id, mediaKind: release.kind, bytes: null };
+ }
// 2) 持续授权
const covered = await coveringGrant(db, d.id, target, now());
@@ -330,10 +177,3 @@ async function episodeTarget(db: Kysely, decisionId: string): Promise /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
-
-/** 策略范围用订阅口径(movie / series / variety),作品用 media_entity.kind(movie / tv / variety …) */
-function kindCovered(kinds: readonly string[], entityKind: string): boolean {
- if (kinds.includes(entityKind)) return true;
- if (entityKind !== "movie" && entityKind !== "variety" && kinds.includes("series")) return true;
- return false;
-}
diff --git a/apps/server/src/modules/agent/capabilities-download.ts b/apps/server/src/modules/agent/capabilities-download.ts
index dcda184c..56ecd6ce 100644
--- a/apps/server/src/modules/agent/capabilities-download.ts
+++ b/apps/server/src/modules/agent/capabilities-download.ts
@@ -1,8 +1,8 @@
import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
+import { planBinding } from "../../authorization/grants.ts";
import { downloaderClientFor } from "../acquisition-organize/ports.ts";
import type { ServerContext } from "../types.ts";
-import { planBinding } from "./authorize.ts";
import type { AgentModuleOptions } from "./runner.ts";
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
diff --git a/apps/server/src/modules/agent/capabilities-metadata.ts b/apps/server/src/modules/agent/capabilities-metadata.ts
index 73ea5307..12c2f316 100644
--- a/apps/server/src/modules/agent/capabilities-metadata.ts
+++ b/apps/server/src/modules/agent/capabilities-metadata.ts
@@ -37,7 +37,7 @@ export interface MetadataCapabilityOptions {
/** 模型调用(测试注入 fake);返回 null = 按规则抽取 */
complete?: EnrichDeps["complete"];
/** 读 robots.txt 用的 fetch / DNS 解析(测试注入 mock) */
- fetch?: typeof fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
resolveHost?: HostResolver;
}
@@ -51,7 +51,11 @@ const ROBOTS_TTL_SEC = 86400;
export async function robotsAllowedFor(
ctx: Pick,
url: string,
- opts: { fetch?: typeof fetch; resolveHost?: HostResolver; signal?: AbortSignal } = {},
+ opts: {
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
+ resolveHost?: HostResolver;
+ signal?: AbortSignal;
+ } = {},
): Promise {
let origin: string;
try {
diff --git a/apps/server/src/modules/agent/interaction.ts b/apps/server/src/modules/agent/interaction.ts
index a552e9b3..9f117c5d 100644
--- a/apps/server/src/modules/agent/interaction.ts
+++ b/apps/server/src/modules/agent/interaction.ts
@@ -21,8 +21,8 @@ import {
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, type DB, type Kysely, sql } from "@umt/db";
+import { APPROVAL_PLAN, type PlanBinding, planBinding } from "../../authorization/grants.ts";
import type { ServerContext } from "../types.ts";
-import { APPROVAL_PLAN, type PlanBinding, planBinding } from "./authorize.ts";
import {
AwaitSuspendedError,
type AwaitSuspension,
diff --git a/apps/server/src/modules/agent/investigate.ts b/apps/server/src/modules/agent/investigate.ts
index 57f938e7..3a04e9ff 100644
--- a/apps/server/src/modules/agent/investigate.ts
+++ b/apps/server/src/modules/agent/investigate.ts
@@ -1,7 +1,8 @@
import type { DecisionOption, DownloadStatus } from "@umt/contracts";
import { type DecisionRow, sql } from "@umt/db";
+import { GRANT_ACTIONS } from "../../authorization/grants.ts";
+import { missingReleaseFields } from "../sources-identity/service/release-facts-admin.ts";
import type { ServerContext } from "../types.ts";
-import { GRANT_ACTIONS } from "./authorize.ts";
import {
type DriveResult,
INVESTIGATE_CAPABILITIES,
@@ -138,6 +139,8 @@ async function relatedTask(ctx: ServerContext, d: DecisionRow) {
}
function suggestionFor(d: DecisionRow, reason: string): string {
+ if (d.dedupe_key.startsWith("release_facts:"))
+ return `${reason}。可查看资源详情补齐属性,或为 release.apply_facts 设置持续授权。`;
if (d.dedupe_key.startsWith("organize_identity:")) return `${reason}。可在整理记录里指定作品或重新整理。`;
if (d.kind === "identity_ambiguous")
return `${reason}。可在媒体库「未识别」里手动指定作品,或为 identity.resolve 发放持续授权。`;
@@ -183,7 +186,8 @@ export async function autoInvestigate(
const d = await ctx.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirst();
if (d?.dedupe_key.startsWith("organize_identity:")) return identifyOrganize(ctx, d, opts.manual === true);
if (d?.status !== "open") return { runId: null, created: false };
- if (!(KINDS as readonly string[]).includes(d.kind)) return { runId: null, created: false };
+ const releaseFacts = d.dedupe_key.startsWith("release_facts:");
+ if (!(KINDS as readonly string[]).includes(d.kind) && !releaseFacts) return { runId: null, created: false };
// Agent 自己引起的事项不再触发 Agent(防止递归自触发)
if (d.subject_type === "agent_run") return { runId: null, created: false };
const s = await ctx.settings.get("agent");
@@ -228,10 +232,12 @@ export async function autoInvestigate(
);
return { runId: null, created: false, skipped: "attempts_exhausted" };
}
- const grants = await recoveryGrants(ctx, ACTIONS_FOR[d.kind] ?? []);
+ const grants = await recoveryGrants(ctx, releaseFacts ? ["release.apply_facts"] : (ACTIONS_FOR[d.kind] ?? []));
if (grants.length === 0) {
mode = "investigate";
- degraded = { reason: `没有覆盖 ${(ACTIONS_FOR[d.kind] ?? []).join(" / ")} 的有效持续授权` };
+ degraded = {
+ reason: `没有覆盖 ${(releaseFacts ? ["release.apply_facts"] : (ACTIONS_FOR[d.kind] ?? [])).join(" / ")} 的有效持续授权`,
+ };
}
}
@@ -252,23 +258,41 @@ export async function autoInvestigate(
d.subject_type && d.subject_id ? `对象:${d.subject_type} ${d.subject_id}。` : "",
dt ? `下载任务 ${dt.id}「${dt.name ?? ""}」当前 ${dt.status}(qB 状态 ${dt.raw_state ?? "未知"})。` : "",
taskId ? `关联任务 ${taskId}。` : "",
- ep
- ? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
+ releaseFacts
+ ? `资源 ${d.subject_id},缺失属性 ${JSON.stringify((d.evidence as { missing?: string[] })?.missing ?? [])}。先用 release.inspect(refreshDetail:true) 看详情、MediaInfo 和文件证据;` +
(mode === "recover"
- ? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
- "模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
- "拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
- : "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
- "模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
- : mode === "recover"
- ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
- "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
- : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
+ ? `有证据时用 release.apply_facts(decisionId:${d.id},sourceReleaseId:${d.subject_id}) 只补缺失事实,原规则自动重评;`
+ : "只调查并给出有出处的补全建议;") +
+ "不修改收录范围、保留目标或组合优先级,不直接选资源替代规则判断。"
+ : ep
+ ? `作品 ${String(ep.mediaEntityId ?? d.subject_id ?? "")},来源季 ${ep.season ?? "未知"},发布组 ${ep.releaseGroup ?? "无"}。` +
+ (mode === "recover"
+ ? "先用 episode_mapping.inspect(decisionId)看上下文,再用 episode_mapping.simulate 试一行作品识别词(如 S02 @发布组 >> S01 EP+12);" +
+ "模拟里事项样本全部解决、且没有让已对上的资源变成待确认时,才用 episode_mapping.apply_rule 把这一行写进作品识别词一次(带理由);" +
+ "拿不准就不写,只把模拟通过的那一行留作建议。不逐集处理,是否解决由系统核实。"
+ : "只做只读调查:用 episode_mapping.inspect(decisionId)看上下文,用 episode_mapping.simulate(带 decisionId 和理由)试一行作品识别词;" +
+ "模拟通过的那一行会作为智能体建议留在事项上,由用户确认;不写规则,拿不准就只说明原因。")
+ : mode === "recover"
+ ? "在授权范围内做有界恢复:先用 task.inspect 等只读能力查证,再按需调用 download.recheck(强制重新校验 / 恢复暂停)、" +
+ "task.retry(重试失败任务)或 identity.resolve,每种最多一次;不换源、不删除、不清理;恢复结果由系统核实。"
+ : "只做只读调查:查证据,给出结论与处理建议;不提交下载,不改文件。",
].join("");
const { run, created } = await startRun(ctx, {
goal,
mode: mode === "recover" ? "execute" : "plan_only",
- capabilityAllowlist: mode === "recover" ? [...RECOVER_CAPABILITIES] : [...INVESTIGATE_CAPABILITIES],
+ capabilityAllowlist: releaseFacts
+ ? [
+ "release.inspect",
+ "catalog.lookup",
+ "inventory.query",
+ "decision.list",
+ "web.search",
+ "web.fetch",
+ ...(mode === "recover" ? ["release.apply_facts"] : []),
+ ]
+ : mode === "recover"
+ ? [...RECOVER_CAPABILITIES]
+ : [...INVESTIGATE_CAPABILITIES],
trigger: "event",
createdBy: mode === "recover" ? RECOVER_ACTOR : AUTO_ACTOR,
dedupeKey: `investigate:${d.id}`,
@@ -460,6 +484,23 @@ export async function checkRecovery(ctx: ServerContext, actions: RecoveryAction[
: undefined;
facts[`source_release:${id}`] = r ? { resolutionStatus: r.resolution_status } : null;
states.push(r?.resolution_status === "resolved" && r.media_entity_id ? "recovered" : "pending");
+ } else if (a.capabilityId === "release.apply_facts") {
+ const id = String(a.input.decisionId ?? "");
+ const decision = await ctx.db.selectFrom("decision").selectAll().where("id", "=", id).executeTakeFirst();
+ const release = decision?.subject_id
+ ? await ctx.db.selectFrom("source_release").selectAll().where("id", "=", decision.subject_id).executeTakeFirst()
+ : undefined;
+ const remaining = release
+ ? missingReleaseFields(release, (decision?.evidence as { missing?: string[] })?.missing ?? [])
+ : [];
+ facts[`decision:${id}`] = { status: decision?.status ?? null, remaining, releaseId: release?.id ?? null };
+ states.push(
+ !decision || !release
+ ? "failed"
+ : decision.status === "resolved" && remaining.length === 0
+ ? "recovered"
+ : "pending",
+ );
} else if (a.capabilityId === "organize.assign_identity") {
// 指定作品后事项随即关闭、重新整理已入队;事项关闭即视为恢复(整理结果看整理记录)
const id = String(a.input.decisionId ?? "");
diff --git a/apps/server/src/modules/agent/routes.ts b/apps/server/src/modules/agent/routes.ts
index a9c534a1..caadfb28 100644
--- a/apps/server/src/modules/agent/routes.ts
+++ b/apps/server/src/modules/agent/routes.ts
@@ -7,12 +7,12 @@ import {
type KnownCapabilityId,
} from "@umt/contracts";
import { type AgentRunRow, sql } from "@umt/db";
+import { planBinding } from "../../authorization/grants.ts";
import { hasScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
-import { planBinding } from "./authorize.ts";
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
import { route } from "./http.ts";
import { afterApprovalDecided, answerOptions } from "./interaction.ts";
diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts
index 09f95718..2530bba7 100644
--- a/apps/server/src/modules/agent/runner.ts
+++ b/apps/server/src/modules/agent/runner.ts
@@ -91,6 +91,7 @@ export const RECOVER_CAPABILITIES = [
"task.retry",
"download.recheck",
"episode_mapping.apply_rule",
+ "release.apply_facts",
] as const;
/**
* 整理没识别出作品时的智能体识别:查作品(TMDB / 媒体库 / 网页)+ 指定作品并重新整理。
@@ -116,6 +117,7 @@ export function investigationDecisionId(dedupeKey: string | null | undefined): s
}
/** 被视为"恢复动作"的能力(结果需系统核实) */
export const RECOVERY_ACTIONS = [
+ "release.apply_facts",
"task.retry",
"download.recheck",
"identity.resolve",
@@ -158,10 +160,7 @@ export function scopeFor(mode: "execute" | "plan_only", allowlist?: readonly str
*/
export function sessionDirOf(env: AgentEnv, configured?: string | null): string {
return resolve(
- env.opts.sessionDir ??
- configured ??
- Deno.env.get("AGENT_SESSION_DIR") ??
- join(Deno.cwd(), "data", "agent-sessions"),
+ env.opts.sessionDir ?? configured ?? process.env.AGENT_SESSION_DIR ?? join(process.cwd(), "data", "agent-sessions"),
);
}
diff --git a/apps/server/src/modules/agent/tasks.ts b/apps/server/src/modules/agent/tasks.ts
index 4e8a89ba..12e2c449 100644
--- a/apps/server/src/modules/agent/tasks.ts
+++ b/apps/server/src/modules/agent/tasks.ts
@@ -266,7 +266,11 @@ export async function executeResolution(ctx: ServerContext, decisionId: string)
export const agentOutboxRoutes: Record = {
"decision.created": (ev) => {
const p = ev.payload as EventPayload<"decision.created">;
- if (!["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind)) return null;
+ if (
+ !["identity_ambiguous", "download_failed", "download_stalled", "episode_mapping"].includes(p.kind) &&
+ !p.dedupeKey?.startsWith("release_facts:")
+ )
+ return null;
return { task: "resolveDecision", input: { decisionId: p.decisionId, trigger: "created" } };
},
"decision.updated": (ev) => {
diff --git a/apps/server/src/modules/browser/keepalive.ts b/apps/server/src/modules/browser/keepalive.ts
index 80368f3c..05011524 100644
--- a/apps/server/src/modules/browser/keepalive.ts
+++ b/apps/server/src/modules/browser/keepalive.ts
@@ -1,3 +1,4 @@
+import { runtimeUnrefTimer } from "@umt/runtime";
// AG-4 浏览器会话保活:camofox 的标签页空闲 TAB_INACTIVITY_MS(缺省 5 分钟)没有 API 调用就回收,
// 会话空闲 SESSION_TIMEOUT_MS 回收,全部会话没了之后 BROWSER_IDLE_TIMEOUT_MS 退出浏览器。
// 桌面模式里人的键鼠直接进 Selkies,不经 API;原站视图的截图轮询也不算标签页活动——接管期间页面会被关掉。
@@ -151,7 +152,7 @@ export class BrowserKeepalive {
this.timer = setInterval(() => void this.tick(), this.opts.intervalMs);
// 不拖住进程退出(测试、优雅停机)
try {
- Deno.unrefTimer(this.timer as unknown as number);
+ runtimeUnrefTimer(this.timer as unknown as number);
} catch {
/* 非 Deno 运行时 */
}
diff --git a/apps/server/src/modules/browser/service.ts b/apps/server/src/modules/browser/service.ts
index 87927d4d..6f757884 100644
--- a/apps/server/src/modules/browser/service.ts
+++ b/apps/server/src/modules/browser/service.ts
@@ -98,7 +98,7 @@ export type Actor = {
export interface BrowserModuleOptions {
/** camofox 请求用的 fetch(测试指向 mock) */
- fetch?: typeof globalThis.fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
/** 站点限流器所在的运行时(与快照/实时搜索共享) */
runtime?: (ctx: ServerContext) => SourcesRuntime;
now?: () => Date;
@@ -122,7 +122,7 @@ export interface BrowserModuleOptions {
export interface ProviderDeps {
secrets: BrowserSecretResolver;
sink: DownloadSink;
- fetch?: typeof globalThis.fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
downloadPollMs?: number;
}
diff --git a/apps/server/src/modules/cache/images.ts b/apps/server/src/modules/cache/images.ts
index b9a881c8..ea8e0e05 100644
--- a/apps/server/src/modules/cache/images.ts
+++ b/apps/server/src/modules/cache/images.ts
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { promises as fs } from "node:fs";
import { dirname, extname, join } from "node:path";
-import { TMDB_IMAGE_SIZES } from "@umt/contracts";
+import { externalImageReferer, TMDB_IMAGE_SIZES } from "@umt/contracts";
// TMDB 图片代理与文件缓存,移植 MP app/application/image.py(ImageHelper)+ api/endpoints/system.py(fetch_image):
// - 缓存路径 = SecurityUtils.sanitize_url_path(url):URL 路径去掉开头 "/" 后百分号编码,超过 120 字符换成
@@ -63,14 +63,14 @@ interface ImageFile {
export interface ImageCacheOptions {
/** DATA_DIR/cache/images */
dir: string;
- fetch?: typeof globalThis.fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
timeoutMs?: number;
/** 上游基址(测试指向 mock);缺省 https://image.tmdb.org/t/p */
upstreamBase?: string;
}
export class ImageCache {
- private readonly fetchImpl: typeof globalThis.fetch;
+ private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise;
constructor(private readonly opts: ImageCacheOptions) {
this.fetchImpl = opts.fetch ?? globalThis.fetch;
@@ -94,8 +94,18 @@ export class ImageCache {
/** fetch_image:取图(可读缓存)→ ETag / 304 */
async serve(size: string, file: string, useCache: boolean, ifNoneMatch: string | null): Promise {
- const url = this.upstream(size, file);
- const { content, mime } = await this.fetchImage(url, useCache);
+ return this.respond(await this.fetchImage(this.upstream(size, file), useCache), ifNoneMatch);
+ }
+
+ /** 豆瓣 / Bangumi 封面(白名单域名):带来源站 Referer 取图,缓存按域名分目录 */
+ async serveExternal(url: string, useCache: boolean, ifNoneMatch: string | null): Promise {
+ const referer = externalImageReferer(url);
+ if (!referer) throw new ImageProxyError(400, "只代理豆瓣与 Bangumi 的图片");
+ const host = new URL(url).hostname.toLowerCase();
+ return this.respond(await this.fetchImage(url, useCache, { referer, cacheDir: join("ext", host) }), ifNoneMatch);
+ }
+
+ private respond({ content, mime }: { content: Uint8Array; mime: string }, ifNoneMatch: string | null): Response {
const etag = createHash("md5").update(content).digest("hex");
const headers = new Headers({
etag,
@@ -116,8 +126,13 @@ export class ImageCache {
}
/** ImageHelper.fetch_image_with_mime_type */
- private async fetchImage(url: string, useCache: boolean): Promise<{ content: Uint8Array; mime: string }> {
- const path = join(this.opts.dir, imageCachePath(url));
+ private async fetchImage(
+ url: string,
+ useCache: boolean,
+ ext: { referer: string; cacheDir: string } | null = null,
+ ): Promise<{ content: Uint8Array; mime: string }> {
+ const path = join(this.opts.dir, ext?.cacheDir ?? "", imageCachePath(url));
+ const what = ext ? "图片" : "TMDB 图片";
if (useCache) {
const cached = await fs.readFile(path).catch(() => null);
const mime = cached?.length ? imageMimeType(cached) : null;
@@ -130,19 +145,19 @@ export class ImageCache {
try {
res = await this.fetchImpl(url, {
signal: AbortSignal.timeout(this.opts.timeoutMs ?? 20_000),
- headers: { accept: "image/avif,image/webp,image/apng,*/*" },
+ headers: { accept: "image/avif,image/webp,image/apng,*/*", ...(ext ? { referer: ext.referer } : {}) },
});
} catch (e) {
const reason = (e as Error).name === "TimeoutError" ? "超时" : (e as Error).message;
- throw new ImageProxyError(502, `拉取 TMDB 图片失败:${reason}`);
+ throw new ImageProxyError(502, `拉取${what}失败:${reason}`);
}
if (res.status !== 200) {
await res.body?.cancel().catch(() => {});
- throw new ImageProxyError(502, `TMDB 图片返回 HTTP ${res.status}`);
+ throw new ImageProxyError(502, `${what}返回 HTTP ${res.status}`);
}
const content = new Uint8Array(await res.arrayBuffer());
const mime = imageMimeType(content);
- if (!mime) throw new ImageProxyError(502, "TMDB 返回的不是图片");
+ if (!mime) throw new ImageProxyError(502, `${what}地址返回的不是图片`);
await fs.mkdir(dirname(path), { recursive: true });
const tmp = `${path}.${process.pid}.tmp`;
await fs.writeFile(tmp, content);
diff --git a/apps/server/src/modules/cache/index.ts b/apps/server/src/modules/cache/index.ts
index a5e9f294..d382464b 100644
--- a/apps/server/src/modules/cache/index.ts
+++ b/apps/server/src/modules/cache/index.ts
@@ -38,7 +38,7 @@ import { ImageCache, ImageProxyError } from "./images.ts";
*/
export interface CacheModuleOptions {
/** 图片上游 fetch(测试注入) */
- imageFetch?: typeof globalThis.fetch;
+ imageFetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
/** 图片上游基址(测试指向 mock) */
imageUpstreamBase?: string;
/** 图片缓存目录;缺省 DATA_DIR/cache/images */
@@ -130,6 +130,18 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
}
});
+ // 豆瓣 / Bangumi 封面:浏览器直连豆瓣图床拿不到(不带 Referer 回 418),经这里带 Referer 取;总是读写磁盘缓存
+ app.get("/img/ext", requireScope("read"), async (c) => {
+ try {
+ return await images(ctx).serveExternal(c.req.query("u") ?? "", true, c.req.header("if-none-match") ?? null);
+ } catch (e) {
+ if (e instanceof ImageProxyError) {
+ return c.json(errorBody(e.status === 400 ? "bad_request" : "upstream_unavailable", e.message), e.status);
+ }
+ throw e;
+ }
+ });
+
route(app, Api.cache.tmdb, async ({ query }) => {
const cache = sharedCache(ctx);
const policy = await tmdbCachePolicy(ctx);
diff --git a/apps/server/src/modules/desktop/desktop.ts b/apps/server/src/modules/desktop/desktop.ts
index 7a8f37e2..c3982674 100644
--- a/apps/server/src/modules/desktop/desktop.ts
+++ b/apps/server/src/modules/desktop/desktop.ts
@@ -1,5 +1,6 @@
import { createHmac } from "node:crypto";
import type { DB, Kysely } from "@umt/db";
+import { upgradeWebSocket } from "@umt/runtime";
import type { Context } from "hono";
import type { AuthEnv } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
@@ -61,7 +62,7 @@ export interface DesktopDeps {
checkSession(sessionId: string, holder: string): Promise;
/** 控制台会话是否仍有效(WS 长连接期间复查) */
consoleSessionValid(sessionRowId: string): Promise;
- fetch?: typeof fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
/** 角色复查间隔 */
roleCheckMs?: number;
/** 桌面连接建立(AG-4):返回连接断开时调用的函数。用于连接期间保活浏览器会话 */
@@ -325,11 +326,10 @@ function bridgeWebSocket(req: Request, target: string, role: DesktopRole, o: Bri
.split(",")
.map((s) => s.trim())
.filter(Boolean);
- const { socket: client, response } = Deno.upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
+ const { socket: client, response } = upgradeWebSocket(req, protocols[0] ? { protocol: protocols[0] } : {});
const onClose = o.onOpen?.();
client.binaryType = "arraybuffer";
- // Deno 的客户端 WebSocket 支持自定义请求头;不带 Origin(Selkies 对无 Origin 的非浏览器客户端放行)
- // (tsc 按 DOM 类型只认 protocols 参数;Deno 运行时接受 { headers, protocols })
+ // Bun 的客户端 WebSocket 支持自定义认证头;不带 Origin(Selkies 对无 Origin 的客户端放行)
const wsOptions = { headers: { authorization: o.auth(role) }, ...(protocols.length ? { protocols } : {}) };
const upstream = new WebSocket(target, wsOptions as unknown as string[]);
upstream.binaryType = "arraybuffer";
diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts
index 8f1256c1..9dd2642a 100644
--- a/apps/server/src/modules/desktop/index.ts
+++ b/apps/server/src/modules/desktop/index.ts
@@ -17,7 +17,7 @@ export function createDesktopModule(): ServerModule {
const r = registerDesktopRoutes(app, {
upstream: desktopUpstream({
providerUrl: ctx.config?.BROWSER_PROVIDER_URL,
- desktopUrl: Deno.env.get("BROWSER_DESKTOP_URL") || undefined,
+ desktopUrl: process.env.BROWSER_DESKTOP_URL || undefined,
}),
accessKey: ctx.config?.BROWSER_ACCESS_KEY ?? null,
...dbSessionChecks(ctx.db),
diff --git a/apps/server/src/modules/file-manager/common.ts b/apps/server/src/modules/file-manager/common.ts
index bdf4a9ed..cfdeb899 100644
--- a/apps/server/src/modules/file-manager/common.ts
+++ b/apps/server/src/modules/file-manager/common.ts
@@ -54,7 +54,7 @@ export interface FmDeps {
}
const envNum = (k: string, dflt: number) => {
- const v = Number(Deno.env.get(k));
+ const v = Number(process.env[k]);
return Number.isFinite(v) && v > 0 ? v : dflt;
};
@@ -66,7 +66,7 @@ export function defaultOptions(ctx: ServerContext): FmOptions {
trashCrossMaxBytes: envNum("FILES_TRASH_CROSS_MAX_BYTES", 2 * 1024 ** 3),
planTtlMs: envNum("FILES_PLAN_TTL_SEC", 1800) * 1000,
identifyMaxVideos: 200,
- allowPermanentDelete: Deno.env.get("FILES_ALLOW_PERMANENT_DELETE") === "true",
+ allowPermanentDelete: process.env.FILES_ALLOW_PERMANENT_DELETE === "true",
healthTimeoutMs: 2000,
purgeIntervalMs: 24 * 3600_000,
backgroundLoops: true,
diff --git a/apps/server/src/modules/file-manager/download.ts b/apps/server/src/modules/file-manager/download.ts
index e136ab7c..8dc1a3ac 100644
--- a/apps/server/src/modules/file-manager/download.ts
+++ b/apps/server/src/modules/file-manager/download.ts
@@ -1,5 +1,6 @@
import { basename, extname } from "node:path";
import type { FilesDownloadQuery } from "@umt/contracts";
+import { runtimeOpen } from "@umt/runtime";
import type { Principal } from "../../http/auth.ts";
import { remote, remoteError } from "../../storage/vfs.ts";
import { FilesError, type FmDeps, isAdminSession, recheck, resolveIn } from "./common.ts";
@@ -57,7 +58,7 @@ export async function downloadFile(
throw new FilesError("forbidden", `只能下载 NFO、字幕、图片、文本和 .torrent,这个文件是 ${kind}`);
const real = await recheck(r.abs, r.rootReal);
if (r.def.storageId) return downloadRemote(d, principal, q, r.rel, real, name, kind, pk);
- const file = await Deno.open(real, { read: true });
+ const file = await runtimeOpen(real, { read: true });
let size: number;
try {
const st = await file.stat();
diff --git a/apps/server/src/modules/file-manager/probe.ts b/apps/server/src/modules/file-manager/probe.ts
index 432aecae..cac48316 100644
--- a/apps/server/src/modules/file-manager/probe.ts
+++ b/apps/server/src/modules/file-manager/probe.ts
@@ -9,6 +9,7 @@ import type {
FilesScrapeBatchResult,
} from "@umt/contracts";
import { sql } from "@umt/db";
+import { runtimeRename } from "@umt/runtime";
import type { Principal } from "../../http/auth.ts";
import { mkdir, readFile, writeFile } from "../../storage/local-fs.ts";
import { vlstat } from "../../storage/vfs.ts";
@@ -84,7 +85,7 @@ export async function probeFiles(d: FmDeps, body: FilesProbeRequest): Promise undefined);
const tmp = `${cacheFile}.${randomUUID()}.tmp`;
await writeFile(tmp, JSON.stringify({ at: Date.now(), result }))
- .then(() => Deno.rename(tmp, cacheFile))
+ .then(() => runtimeRename(tmp, cacheFile))
.catch(() => undefined);
}
items.push({
diff --git a/apps/server/src/modules/mcp/index.ts b/apps/server/src/modules/mcp/index.ts
index 0236cbe7..ad7e856d 100644
--- a/apps/server/src/modules/mcp/index.ts
+++ b/apps/server/src/modules/mcp/index.ts
@@ -1,5 +1,5 @@
import { featureManifest } from "@umt/contracts";
-import denoJson from "../../../deno.json" with { type: "json" };
+import packageJson from "../../../package.json" with { type: "json" };
import type { ServerModule } from "../types.ts";
import { createMcpConfigStore } from "./config-store.ts";
import { registerMcpRoutes } from "./routes.ts";
@@ -20,7 +20,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule &
registerMcpRoutes(app, ctx, {
table,
config: createMcpConfigStore(() => ctx.db),
- version: opts.version ?? denoJson.version,
+ version: opts.version ?? packageJson.version,
});
},
registerTasks() {},
diff --git a/apps/server/src/modules/mcp/sessions.ts b/apps/server/src/modules/mcp/sessions.ts
index 9229e583..49d0a4a8 100644
--- a/apps/server/src/modules/mcp/sessions.ts
+++ b/apps/server/src/modules/mcp/sessions.ts
@@ -3,6 +3,7 @@ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
// @ts-types="@modelcontextprotocol/sdk/server/webStandardStreamableHttp"
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import type { McpSessionInfo } from "@umt/contracts";
+import { runtimeUnrefTimer } from "@umt/runtime";
// MCP 会话表(进程内):会话与建立它的 API Key 绑定,换 Key 复用会话一律拒绝。
// 空闲超时回收;每个 Key 的会话数与每分钟工具调用数有上限。
@@ -128,6 +129,6 @@ export class McpSessionTable {
this.timer = setInterval(() => void this.sweep(), 60_000);
// 不因为回收定时器挂住进程退出
(this.timer as unknown as { unref?: () => void }).unref?.();
- if (typeof Deno !== "undefined") Deno.unrefTimer(this.timer as unknown as number);
+ runtimeUnrefTimer(this.timer as unknown as number);
}
}
diff --git a/apps/server/src/modules/mcp/stats.ts b/apps/server/src/modules/mcp/stats.ts
index f59646cd..af3e57a8 100644
--- a/apps/server/src/modules/mcp/stats.ts
+++ b/apps/server/src/modules/mcp/stats.ts
@@ -13,7 +13,7 @@ import {
} from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
import { z } from "zod";
-import { GRANT_ACTIONS } from "../agent/authorize.ts";
+import { GRANT_ACTIONS } from "../../authorization/grants.ts";
import type { SettingsReader } from "../types.ts";
// 能力调用统计与能力清单。统计口径是 audit_event(registry.invoke 每次调用都写一条,含只读能力与拒绝);
diff --git a/apps/server/src/modules/pi/oauth.ts b/apps/server/src/modules/pi/oauth.ts
index 8c814a88..8405e6e7 100644
--- a/apps/server/src/modules/pi/oauth.ts
+++ b/apps/server/src/modules/pi/oauth.ts
@@ -48,7 +48,7 @@ interface Session {
/** manual_code 挂起的解析函数 */
paste: ((v: string) => void) | null;
pasteWaiters: (() => void)[];
- firstEvent: PromiseWithResolvers;
+ firstEvent: ReturnType>;
done: Promise;
timer: ReturnType;
}
diff --git a/apps/server/src/modules/sources-identity/capabilities.ts b/apps/server/src/modules/sources-identity/capabilities.ts
index 7a663e1b..3a113c86 100644
--- a/apps/server/src/modules/sources-identity/capabilities.ts
+++ b/apps/server/src/modules/sources-identity/capabilities.ts
@@ -6,7 +6,9 @@ import { registerEpisodeMappingCapabilities } from "./capabilities-episode.ts";
import { registerSiteCapabilities } from "./capabilities-sites.ts";
import { toApi, toApiList } from "./rows.ts";
import type { SourcesRuntime } from "./runtime.ts";
+import { loadDetailPage } from "./service/detail-page.ts";
import { assignReleaseIdentity } from "./service/identity-admin.ts";
+import { applyReleaseFacts } from "./service/release-facts-admin.ts";
import { resolveRelease } from "./service/resolve.ts";
import { refreshReleaseDetail } from "./service/snapshot.ts";
import { liveSearch } from "./service/workbench.ts";
@@ -113,7 +115,7 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
registry.register(capabilitySpec("release.inspect"), async (input) => {
const r = rt();
- if (input.refreshDetail) await refreshReleaseDetail(r, input.sourceReleaseId);
+ const refreshError = input.refreshDetail ? await refreshReleaseDetail(r, input.sourceReleaseId) : null;
const row = await r.ctx.db
.selectFrom("source_release")
.selectAll()
@@ -128,9 +130,18 @@ export function registerSourcesCapabilities(registry: CapabilityRegistry, rt: ()
.where("media_entity_id", "=", row.media_entity_id)
.execute()
: [];
- return { release: toApi(row), inventory: toApiList(inventory) } as never;
+ return {
+ release: toApi(row),
+ inventory: toApiList(inventory),
+ page: refreshError ? null : await loadDetailPage(r, input.sourceReleaseId),
+ refreshError,
+ } as never;
});
+ registry.register(capabilitySpec("release.apply_facts"), (input, ctx) =>
+ applyReleaseFacts(rt().ctx, input, ctx.agentRunId),
+ );
+
registry.register(capabilitySpec("identity.resolve"), async (input, ctx) => {
const r = rt();
let srId = input.sourceReleaseId ?? null;
diff --git a/apps/server/src/modules/sources-identity/runtime.ts b/apps/server/src/modules/sources-identity/runtime.ts
index cef5cb14..af31fde2 100644
--- a/apps/server/src/modules/sources-identity/runtime.ts
+++ b/apps/server/src/modules/sources-identity/runtime.ts
@@ -28,7 +28,7 @@ import { readBrowserUserAgent, resolveUserAgent, type UserAgentDefaults } from "
export interface RuntimeOverrides {
/** 站点与 RSS 请求使用的 fetch(测试指向 mock) */
- fetch?: typeof globalThis.fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
/** TMDB 客户端工厂;返回 null 表示未配置(只做本地召回) */
tmdb?: (() => TmdbClient | null) | undefined;
now?: () => Date;
@@ -39,7 +39,7 @@ export interface RuntimeOverrides {
/** false = 站点限流只用进程内令牌桶(缺省走 PG 跨进程令牌桶 rate_limit_bucket) */
sharedRateLimit?: boolean;
/** 豆瓣 / bgm.tv 请求用的 fetch;null = 不在线查(测试缺省不出外网) */
- metaFetch?: typeof globalThis.fetch | null;
+ metaFetch?: ((input: RequestInfo | URL, init?: RequestInit) => Promise) | null;
}
interface CachedAdapter {
@@ -78,9 +78,9 @@ export class SourcesRuntime {
* 未配置时回退环境变量 TMDB_API_KEY / TMDB_ACCESS_TOKEN / TMDB_BASE_URL。设置变化(引用/地址)时重建。
*/
/** 豆瓣 / bgm.tv 请求:注入优先;测试进程(VITEST)缺省不出外网;否则按环境变量代理出外网 */
- metaFetch(): typeof globalThis.fetch | null {
+ metaFetch(): ((input: RequestInfo | URL, init?: RequestInit) => Promise) | null {
if (this.overrides.metaFetch !== undefined) return this.overrides.metaFetch;
- return Deno.env.get("VITEST") ? null : externalFetch;
+ return process.env.VITEST ? null : externalFetch;
}
async tmdb(): Promise {
@@ -110,7 +110,7 @@ export class SourcesRuntime {
if (!cfg) return null;
let n = 0;
const base = cfg.fetch ?? externalFetch;
- const counted: typeof fetch = (input, init) => {
+ const counted: (input: RequestInfo | URL, init?: RequestInit) => Promise = (input, init) => {
n++;
return base(input, init);
};
@@ -122,7 +122,7 @@ export class SourcesRuntime {
private async tmdbConfig(): Promise<{ sig: string; cfg: Parameters[0] | null }> {
const meta = await this.ctx.settings.get("metadata").catch(() => null);
const policy = await tmdbCachePolicy(this.ctx);
- const env = (k: string) => Deno.env.get(k);
+ const env = (k: string) => process.env[k];
const ref = meta?.tmdbApiKeySecretRef ?? null;
const baseUrl = meta?.tmdbBaseUrl ?? env("TMDB_BASE_URL");
const sig = JSON.stringify([
@@ -189,7 +189,7 @@ export class SourcesRuntime {
site: Pick & Partial>,
defaults: SitesSettings = this.siteDefaultsCache,
ua?: string | (() => Promise),
- ): typeof globalThis.fetch {
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise {
const base = this.transportFetch(site, defaults);
const egress = this.egressKeyOf(site, defaults);
const siteId = site.id;
@@ -214,7 +214,7 @@ export class SourcesRuntime {
return base(input, { ...init, headers: h, ...(signal ? { signal } : {}) });
},
);
- }) as typeof globalThis.fetch;
+ }) as (input: RequestInfo | URL, init?: RequestInit) => Promise;
}
/** 站点 HTTP 出口键(direct / proxy:<主机>),与浏览器会话的出口同一口径 */
@@ -223,7 +223,10 @@ export class SourcesRuntime {
}
/** 不加 UA 的底层 fetch(测试注入 / 按站点出口:代理或明确直连) */
- private transportFetch(site: Pick, defaults: SitesSettings): typeof globalThis.fetch {
+ private transportFetch(
+ site: Pick,
+ defaults: SitesSettings,
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise {
if (this.overrides.fetch) return this.overrides.fetch;
return siteFetchFor(siteEgress(site.proxy, defaults.proxyUrl));
}
diff --git a/apps/server/src/modules/sources-identity/service/image-proxy.ts b/apps/server/src/modules/sources-identity/service/image-proxy.ts
index 9866a466..4380c37f 100644
--- a/apps/server/src/modules/sources-identity/service/image-proxy.ts
+++ b/apps/server/src/modules/sources-identity/service/image-proxy.ts
@@ -21,7 +21,7 @@ export function imageMime(b: Uint8Array): string | null {
}
export async function fetchPublicImage(
- doFetch: typeof fetch,
+ doFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise,
url: string,
opts: { maxBytes: number; label: string; referer?: string },
): Promise {
diff --git a/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts b/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts
index 66292063..16631577 100644
--- a/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts
+++ b/apps/server/src/modules/sources-identity/service/metadata-prewarm.ts
@@ -26,9 +26,9 @@ export interface PrewarmResult {
}
/** 图片预热用的 fetch:测试注入优先;测试进程没注入就不出外网 */
-function imageFetch(rt: SourcesRuntime): typeof globalThis.fetch | null {
+function imageFetch(rt: SourcesRuntime): ((input: RequestInfo | URL, init?: RequestInit) => Promise) | null {
if (rt.overrides.fetch) return rt.overrides.fetch;
- return Deno.env.get("VITEST") ? null : externalFetch;
+ return process.env.VITEST ? null : externalFetch;
}
export async function prewarmMetadata(rt: SourcesRuntime, mediaEntityId: string): Promise {
diff --git a/apps/server/src/modules/sources-identity/service/release-facts-admin.ts b/apps/server/src/modules/sources-identity/service/release-facts-admin.ts
new file mode 100644
index 00000000..0a24a910
--- /dev/null
+++ b/apps/server/src/modules/sources-identity/service/release-facts-admin.ts
@@ -0,0 +1,138 @@
+import type { ReleaseApplyFactsInput, ReleaseStaticFacts } from "@umt/contracts";
+import type { z } from "zod";
+import { ApiException } from "../../../http/errors.ts";
+import { investigationDecisionId } from "../../agent/runner.ts";
+import type { ServerContext } from "../../types.ts";
+import { contextOf, parsedOf } from "./release-facts.ts";
+
+export function missingReleaseFields(row: Parameters[0], fields: readonly string[]): string[] {
+ const p = parsedOf(row);
+ const c = contextOf(row);
+ const values: Record = {
+ resolution: p.resolution,
+ codec: p.videoCodec,
+ group: p.releaseGroup,
+ source: p.source,
+ size: p.sizeBytes,
+ subtitles: p.subtitles,
+ seeders: c.seeders,
+ promotion: c.downloadVolumeFactor,
+ site: c.siteId,
+ };
+ return fields.filter((f) => values[f] === undefined || values[f] === null);
+}
+
+/** 只填事项正在等待的事实;不接受策略、排名、身份或季集字段。 */
+export async function applyReleaseFacts(
+ ctx: ServerContext,
+ input: z.infer,
+ runId?: string,
+) {
+ if (runId) {
+ const run = await ctx.db
+ .selectFrom("agent_run")
+ .select(["dedupe_key", "trigger"])
+ .where("id", "=", runId)
+ .executeTakeFirstOrThrow();
+ if (run.trigger !== "user" && investigationDecisionId(run.dedupe_key) !== input.decisionId)
+ throw new ApiException("forbidden", "只能补全当前调查事项的资源属性");
+ }
+ return ctx.db.transaction().execute(async (trx) => {
+ const decision = await trx
+ .selectFrom("decision")
+ .selectAll()
+ .where("id", "=", input.decisionId)
+ .forUpdate()
+ .executeTakeFirst();
+ if (
+ decision?.subject_type !== "source_release" ||
+ decision.subject_id !== input.sourceReleaseId ||
+ decision.dedupe_key !== `release_facts:${input.sourceReleaseId}`
+ )
+ throw new ApiException("bad_request", "资源属性事项与本次资源不一致");
+ const row = await trx
+ .selectFrom("source_release")
+ .selectAll()
+ .where("id", "=", input.sourceReleaseId)
+ .forUpdate()
+ .executeTakeFirstOrThrow();
+ const fields = ((decision.evidence ?? {}) as { missing?: string[] }).missing ?? [];
+ if (decision.status === "resolved") return { resolved: true, remaining: missingReleaseFields(row, fields) };
+ if (!["open", "investigating", "awaiting_user"].includes(decision.status))
+ throw new ApiException("precondition_failed", "该资源属性事项已关闭");
+ const allowed: Record = {
+ resolution: "resolution",
+ videoCodec: "codec",
+ source: "source",
+ releaseGroup: "group",
+ };
+ const missing = new Set(missingReleaseFields(row, fields));
+ const raw = (row.raw ?? {}) as Record;
+ const old = (raw._facts ?? {}) as { values?: ReleaseStaticFacts; evidence?: Record };
+ const facts: ReleaseStaticFacts = {};
+ for (const key of Object.keys(input.facts) as (keyof ReleaseStaticFacts)[]) {
+ if (missing.has(allowed[key])) Object.assign(facts, { [key]: input.facts[key] });
+ else if (!fields.includes(allowed[key]) || old.values?.[key] !== input.facts[key])
+ throw new ApiException("bad_request", `只能补全事项里仍未知的属性:${key}`);
+ }
+ const updated = {
+ ...raw,
+ _facts: {
+ values: { ...old.values, ...facts },
+ evidence: {
+ ...old.evidence,
+ ...Object.fromEntries(
+ Object.keys(facts).map((field) => [
+ field,
+ {
+ text: input.evidence,
+ url: input.evidenceUrl ?? null,
+ runId: runId ?? null,
+ at: new Date().toISOString(),
+ },
+ ]),
+ ),
+ },
+ },
+ };
+ await trx
+ .updateTable("source_release")
+ .set({ raw: JSON.stringify(updated) })
+ .where("id", "=", row.id)
+ .execute();
+ const remaining = missingReleaseFields({ ...row, raw: updated }, fields);
+ if (remaining.length === 0) {
+ await trx
+ .updateTable("decision")
+ .set({
+ status: "resolved",
+ resolution: JSON.stringify({ factsApplied: true, evidence: input.evidence }),
+ resolved_at: new Date(),
+ })
+ .where("id", "=", decision.id)
+ .execute();
+ await ctx.outbox.emit(
+ trx,
+ "decision.updated",
+ { decisionId: decision.id, status: "resolved" },
+ { idempotencyKey: `decision.updated:${decision.id}:facts` },
+ );
+ }
+ if (row.media_entity_id)
+ await ctx.outbox.emit(
+ trx,
+ "release.resolved",
+ {
+ sourceReleaseId: row.id,
+ observationId: null,
+ mediaEntityId: row.media_entity_id,
+ contentUnitIds: row.content_unit_id ? [row.content_unit_id] : [],
+ evidenceLevel: "needs_review",
+ },
+ {
+ idempotencyKey: `release.resolved:${row.id}:facts:${decision.id}:${JSON.stringify(updated._facts.values)}:${JSON.stringify([...remaining].sort())}`,
+ },
+ );
+ return { resolved: remaining.length === 0, remaining };
+ });
+}
diff --git a/apps/server/src/modules/acquisition-organize/services/release-facts.ts b/apps/server/src/modules/sources-identity/service/release-facts.ts
similarity index 83%
rename from apps/server/src/modules/acquisition-organize/services/release-facts.ts
rename to apps/server/src/modules/sources-identity/service/release-facts.ts
index 208f4b7f..81bb8f82 100644
--- a/apps/server/src/modules/acquisition-organize/services/release-facts.ts
+++ b/apps/server/src/modules/sources-identity/service/release-facts.ts
@@ -1,3 +1,4 @@
+import { ReleaseStaticFacts } from "@umt/contracts";
import type { SourceReleaseRow } from "@umt/db";
import { type EvaluationContext, type ParsedRelease, parseRelease } from "@umt/domain";
import { fromParsedColumns } from "@umt/mapping";
@@ -29,6 +30,15 @@ export function parsedOf(r: SourceReleaseRow): ParsedRelease {
p = { ...rest, season: c.season, episodes: { ...c.episodes }, seasonInferred: false };
}
}
+ const facts = ReleaseStaticFacts.safeParse((r.raw as { _facts?: { values?: unknown } } | null)?._facts?.values).data;
+ if (facts)
+ p = {
+ ...p,
+ ...(!p.resolution && facts.resolution ? { resolution: facts.resolution } : {}),
+ ...(!p.videoCodec && facts.videoCodec ? { videoCodec: facts.videoCodec } : {}),
+ ...(!p.source && facts.source ? { source: facts.source, isEncode: facts.source === "BluRay" } : {}),
+ ...(!p.releaseGroup && facts.releaseGroup ? { releaseGroup: { name: facts.releaseGroup, source: "known" } } : {}),
+ };
return r.size_bytes !== null ? { ...p, sizeBytes: r.size_bytes } : p;
}
diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts
index 6849d4bd..4308ab11 100644
--- a/apps/server/src/modules/sources-identity/service/resolve.ts
+++ b/apps/server/src/modules/sources-identity/service/resolve.ts
@@ -20,7 +20,6 @@ import {
toParsedColumns,
toReleaseResolutionStatus,
} from "@umt/mapping";
-import { rewriteHook } from "../../../hooks/index.ts";
import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts";
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
@@ -484,18 +483,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
mediaEntityId: release.media_entity_id,
};
}
- // 链式钩子 name.recognize(ADR-006):可改写解析结果;没有钩子时原样
- const parsed = await rewriteHook(
- ctx,
- "name.recognize",
- await parseTitle(ctx.db, release.title, release.subtitle),
- () => ({
- title: release.title,
- subtitle: release.subtitle ?? null,
- sourceReleaseId: release.id,
- trigger: input.trigger ?? (input.force ? "review" : "observed"),
- }),
- );
+ const parsed = await parseTitle(ctx.db, release.title, release.subtitle);
const cols = toParsedColumns(parsed);
// 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分
const siteIds = siteExplicitIds(release.raw);
@@ -1184,13 +1172,7 @@ export async function identifyParsed(
const { ctx } = rt;
const none = { mediaEntityId: null, contentUnitIds: [] as string[], candidates: [], created: false };
const trigger = opts.trigger ?? "takeover";
- // 链式钩子 name.recognize(ADR-006):整理 / 接管按名称识别时同样可改写解析结果
- const parsed = await rewriteHook(ctx, "name.recognize", input, () => ({
- title: input.raw,
- subtitle: null,
- sourceReleaseId: null,
- trigger,
- }));
+ const parsed = input;
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
const local = await localCandidates(ctx.db, parsed, null);
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
diff --git a/apps/server/src/modules/system/heartbeat.ts b/apps/server/src/modules/system/heartbeat.ts
index 860f52d3..a48e24e7 100644
--- a/apps/server/src/modules/system/heartbeat.ts
+++ b/apps/server/src/modules/system/heartbeat.ts
@@ -1,8 +1,9 @@
import { hostname } from "node:os";
import type { ProcessKind } from "@umt/contracts";
import { type DB, type Kysely, sql } from "@umt/db";
+import { runtimeHostname, runtimeUnrefTimer } from "@umt/runtime";
import type { Logger } from "pino";
-import serverDeno from "../../../deno.json" with { type: "json" };
+import serverPackage from "../../../package.json" with { type: "json" };
import { createProcessCpuMeter } from "./resources.ts";
// 进程心跳写入(W9-I):每个常驻进程每 15s upsert process_heartbeat 一行 + 追加一条采样。
@@ -19,7 +20,7 @@ export const HEARTBEAT_ROW_RETENTION_MS = 24 * 3600_000;
/** 当前实例判定窗口:最近这段时间内有心跳的 host 视为当前在跑的容器 */
export const CURRENT_HOST_WINDOW_MS = 10 * 60_000;
-export const SERVER_VERSION: string = serverDeno.version;
+export const SERVER_VERSION: string = serverPackage.version;
/** 事件循环延迟采样器:每 intervalMs 测一次 setInterval 实际触发偏差;window 在每次心跳读取后重置 */
export function createLagSampler(intervalMs = 1000) {
@@ -36,7 +37,7 @@ export function createLagSampler(intervalMs = 1000) {
if (lag > lifetimeMax) lifetimeMax = lag;
}, intervalMs);
try {
- Deno.unrefTimer(timer);
+ runtimeUnrefTimer(timer);
} catch {
// 非 Deno 运行时(vitest 走 deno,理论上不会到这里)
}
@@ -80,7 +81,7 @@ export function instanceIdOf(kind: ProcessKind, host: string, pid: number) {
function memory(): Record {
try {
- const m = Deno.memoryUsage();
+ const m = process.memoryUsage();
return { rssBytes: m.rss, heapUsedBytes: m.heapUsed };
} catch {
return {};
@@ -159,7 +160,7 @@ export interface HeartbeatWriter {
/** 起一个周期写入器:立即写一次,之后每 intervalMs 写一次;stop 时把本行标 stopped_at */
export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter {
const host = o.host ?? safeHostname();
- const pid = o.pid ?? Deno.pid;
+ const pid = o.pid ?? process.pid;
const startedAt = o.startedAt ?? new Date();
const version = o.version ?? SERVER_VERSION;
const instanceId = instanceIdOf(o.kind, host, pid);
@@ -214,7 +215,7 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
void beat();
const timer = setInterval(() => void beat(), intervalMs);
try {
- Deno.unrefTimer(timer);
+ runtimeUnrefTimer(timer);
} catch {
// ignore
}
@@ -237,7 +238,7 @@ export function startHeartbeatWriter(o: HeartbeatWriterOptions): HeartbeatWriter
export function safeHostname(): string {
try {
- return Deno.hostname();
+ return runtimeHostname();
} catch {
return hostname();
}
diff --git a/apps/server/src/modules/system/index.ts b/apps/server/src/modules/system/index.ts
index 50eff73f..3517cb93 100644
--- a/apps/server/src/modules/system/index.ts
+++ b/apps/server/src/modules/system/index.ts
@@ -121,7 +121,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
if (opts.resourceSampler) sampler = opts.resourceSampler;
else if (opts.resourceSampler !== false && !sampler) sampler = createResourceSampler();
const selfKind = ctx.role === "all" ? "all" : ctx.role === "api" ? "api" : null;
- const selfId = selfKind ? instanceIdOf(selfKind, safeHostname(), Deno.pid) : undefined;
+ const selfId = selfKind ? instanceIdOf(selfKind, safeHostname(), process.pid) : undefined;
const paths = (): StoragePaths => {
if (opts.storagePaths) return opts.storagePaths;
const p = dataPaths(ctx.config);
diff --git a/apps/server/src/modules/system/resources.ts b/apps/server/src/modules/system/resources.ts
index 543b27d6..618b11dc 100644
--- a/apps/server/src/modules/system/resources.ts
+++ b/apps/server/src/modules/system/resources.ts
@@ -1,6 +1,7 @@
import { availableParallelism, cpus } from "node:os";
import process from "node:process";
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
+import { runtimeUnrefTimer } from "@umt/runtime";
import { readProcText } from "../../proc-read.ts";
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
@@ -366,7 +367,7 @@ export function createResourceSampler(
void tick();
timer = setInterval(() => void tick(), intervalMs);
try {
- Deno.unrefTimer(timer);
+ runtimeUnrefTimer(timer);
} catch {
// ignore
}
diff --git a/apps/server/src/modules/types.ts b/apps/server/src/modules/types.ts
index df6838a6..27798195 100644
--- a/apps/server/src/modules/types.ts
+++ b/apps/server/src/modules/types.ts
@@ -12,13 +12,11 @@ import type { DB, Kysely, Transaction } from "@umt/db";
import type { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks";
import type { Logger } from "pino";
import type { Config } from "../config.ts";
-import type { HookRegistrar, HookRegistry } from "../hooks/registry.ts";
import type { AuthEnv } from "../http/auth.ts";
import type { EventBusPort } from "../http/sse.ts";
-// 服务端模块骨架(W1 审查 B6;任务书 §5)。
-// 每个 W2 子任务是一个 ServerModule,只改 modules/<自己的目录>/ 与 contracts/db 中本域的文件。
-// 跨模块只通过 DB 表 + outbox_event(事件类型在 contracts/events.ts)+ contracts 类型耦合,不互相 import。
+// 服务端模块装配。同步协作使用明确的业务服务导出,事后通知走 outbox;
+// 路由和能力处理器依赖服务,业务服务不反向依赖 HTTP 路由或 Agent 会话。
export type ServerRole = "api" | "worker" | "worker:agent" | "all";
@@ -49,8 +47,8 @@ export interface OutboxPort extends OutboxWriter {
}
/**
- * 凭证存取(secret 表 + SECRET_MASTER_KEY 加密)。W2-A 在 modules/auth-settings/provided.ts 实现,
- * 当前占位实现一律抛"未实现"。其它模块只存/传 secret_ref,需要明文时现场 resolve,不缓存、不打日志。
+ * 凭证存取(secret 表 + SECRET_MASTER_KEY 加密),由 modules/auth-settings/provided.ts 实现。
+ * 其它模块只存/传 secret_ref,需要明文时现场 resolve,不缓存、不打日志。
*/
export interface SecretStore {
resolve(secretRef: string): Promise;
@@ -80,11 +78,6 @@ export interface ServerContext {
settings: SettingsReader;
/** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */
capabilities: CapabilityRegistry;
- /**
- * 链式钩子(ADR-006):bootstrap 装配,模块在 registerHooks 里注册;业务节点经 hooks/index.ts 的
- * rewriteHook / interceptHook 调用。测试里手搭的上下文可以没有(等同于没有注册)。
- */
- hooks?: HookRegistry;
}
export interface ServerModule {
@@ -94,7 +87,7 @@ export interface ServerModule {
registerRoutes(app: ModuleApp, ctx: ServerContext): void;
/**
* 所有角色:注册持久任务处理器(api 角色也要注册,enqueue 时按定义校验输入)。
- * 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。
+ * 同名任务只能由一个模块注册;未注册的任务明确报错。
*/
registerTasks(registry: TaskRegistry, ctx: ServerContext): void;
/**
@@ -104,8 +97,6 @@ export interface ServerModule {
outboxRoutes?(ctx: ServerContext): Record;
/** 外围功能的说明(功能清单只给智能体看,不做开关);取 contracts 的 featureManifest(id) */
manifest?: FeatureManifest;
- /** 所有角色:注册链式钩子(同步、按注册顺序、可改写或拦截;见 hooks/registry.ts) */
- registerHooks?(hooks: HookRegistrar, ctx: ServerContext): void;
/** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */
registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void;
/** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */
diff --git a/apps/server/src/modules/websearch/service.ts b/apps/server/src/modules/websearch/service.ts
index 8122a7a1..631c9e78 100644
--- a/apps/server/src/modules/websearch/service.ts
+++ b/apps/server/src/modules/websearch/service.ts
@@ -28,7 +28,7 @@ import type { ServerContext } from "../types.ts";
export interface WebSearchModuleOptions {
/** 搜索与网页读取用的 fetch(测试注入 mock) */
- fetch?: typeof fetch;
+ fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise;
/** Brave 基址(测试指向 mock) */
braveBaseUrl?: string;
/** DNS 解析(测试注入);缺省系统解析 */
diff --git a/apps/server/src/ops.ts b/apps/server/src/ops.ts
index fd67cc0a..0a162413 100644
--- a/apps/server/src/ops.ts
+++ b/apps/server/src/ops.ts
@@ -1,6 +1,7 @@
import { dirname } from "node:path";
import type { DB, Kysely } from "@umt/db";
import { sql } from "@umt/db";
+import { runtimeMkdir, runtimeRename, runtimeWriteTextFile } from "@umt/runtime";
// 运维一次性子命令(备份/恢复脚本调用):映射清单导出、恢复后打开只读对账。
// 映射清单是"路径不携带完整身份"前提下的关键资产(需求 §8.5):数据库之外再留一份可读 JSON,
@@ -112,10 +113,10 @@ export async function exportMappings(db: Kysely, appVersion: string): Promis
/** 写 JSON(先写临时文件再改名,避免半截文件) */
export async function writeMappingExport(path: string, data: MappingExport): Promise {
- await Deno.mkdir(dirname(path), { recursive: true });
+ await runtimeMkdir(dirname(path), { recursive: true });
const tmp = `${path}.partial`;
- await Deno.writeTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
- await Deno.rename(tmp, path);
+ await runtimeWriteTextFile(tmp, `${JSON.stringify(data, null, 2)}\n`);
+ await runtimeRename(tmp, path);
}
/** 两份清单逐表比行数,返回不一致的表 */
diff --git a/apps/server/src/preflight.ts b/apps/server/src/preflight.ts
index fa6378f1..2b8c2aab 100644
--- a/apps/server/src/preflight.ts
+++ b/apps/server/src/preflight.ts
@@ -1,6 +1,7 @@
import { join } from "node:path";
import type { DB, Kysely } from "@umt/db";
import { sql } from "@umt/db";
+import { runtimeMkdir, runtimeReadDir, runtimeRemove, runtimeStat, runtimeWriteTextFile } from "@umt/runtime";
import type { Config } from "./config.ts";
export interface PreflightProblem {
@@ -19,7 +20,11 @@ export interface PreflightDeps {
const PLACEHOLDER = /change[-_]?me|replace[-_]?me|dev[-_]?only|example/i;
/** 启动自检:返回问题列表,空数组表示通过。不打印变量值。 */
-export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDeps): Promise {
+export async function runPreflight({
+ config,
+ db,
+ uid = process.getuid?.(),
+}: PreflightDeps): Promise {
const problems: PreflightProblem[] = [];
const prod = config.NODE_ENV === "production";
@@ -36,7 +41,7 @@ export async function runPreflight({ config, db, uid = Deno.uid() }: PreflightDe
}
if (config.WEB_ROOT) {
try {
- await Deno.stat(join(config.WEB_ROOT, "index.html"));
+ await runtimeStat(join(config.WEB_ROOT, "index.html"));
} catch {
problems.push({ check: "web", message: `WEB_ROOT 下没有 index.html: ${config.WEB_ROOT}` });
}
@@ -66,9 +71,9 @@ const errText = (e: unknown) => (e as { code?: string }).code ?? (e as Error).me
/** 在目录里写一个探针文件再删掉;失败抛错 */
async function probeWrite(dir: string): Promise {
- const probe = join(dir, `.umt-write-probe-${Deno.pid}-${crypto.randomUUID().slice(0, 8)}`);
- await Deno.writeTextFile(probe, "probe\n");
- await Deno.remove(probe);
+ const probe = join(dir, `.umt-write-probe-${process.pid}-${crypto.randomUUID().slice(0, 8)}`);
+ await runtimeWriteTextFile(probe, "probe\n");
+ await runtimeRemove(probe);
}
/** 读出已启用的库根与下载器路径映射在本进程视角下的路径 */
@@ -109,7 +114,7 @@ export async function checkStorage(opts: {
if (!dirs.includes(opts.sessionDir)) dirs.push(opts.sessionDir);
for (const dir of dirs) {
try {
- await Deno.mkdir(dir, { recursive: true });
+ await runtimeMkdir(dir, { recursive: true });
await probeWrite(dir);
} catch (e) {
// copy-allow: 括号里是系统错误原文
@@ -118,9 +123,9 @@ export async function checkStorage(opts: {
}
for (const m of opts.mounts) {
try {
- const st = await Deno.stat(m.path);
+ const st = await runtimeStat(m.path);
if (!st.isDirectory) throw Object.assign(new Error("不是目录"), { code: "ENOTDIR" });
- for await (const _ of Deno.readDir(m.path)) break;
+ for await (const _ of await runtimeReadDir(m.path)) break;
} catch (e) {
warnings.push({ ...m, reason: `不可读:${errText(e)}` });
continue;
@@ -146,7 +151,7 @@ export async function auditStorageWarnings(db: Kysely, warnings: StorageWarn
action: "preflight.path_unavailable",
target_type: w.kind,
target_id: w.id,
- payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: Deno.uid() }),
+ payload: JSON.stringify({ name: w.name, path: w.path, reason: w.reason, uid: process.getuid?.() }),
})),
)
.execute();
diff --git a/apps/server/src/proc-read.ts b/apps/server/src/proc-read.ts
index 66f13d75..70f1477f 100644
--- a/apps/server/src/proc-read.ts
+++ b/apps/server/src/proc-read.ts
@@ -1,29 +1,9 @@
-// 读 /proc 与 /sys 的伪文件。
-// Deno 2 把这两处当特权路径:不带 --allow-all 时 Deno.readTextFile 一律 NotCapable(--allow-read 及按路径授权都不行)。
-// 开发 / 测试用 -A 能直接读;编译产物按最小权限运行,只能借 `cat` 子进程读(编译与 start 任务的 --allow-run 已含 cat)。
-// 第一次遇到 NotCapable 后记住,后续直接走 cat。
-
-let viaCat = false;
-
-async function catText(path: string): Promise {
+import { readFile } from "node:fs/promises";
+/** Linux 资源监控和子进程 RSS;权限不足或进程已退出时返回 null。 */
+export async function readProcText(path: string): Promise {
try {
- const o = await new Deno.Command("cat", { args: [path], stdout: "piped", stderr: "null" }).output();
- return o.success ? new TextDecoder().decode(o.stdout) : null;
+ return await readFile(path, "utf8");
} catch {
return null;
}
}
-
-/** 读不到(不存在、无权限、cat 不可用)返回 null */
-export async function readProcText(path: string): Promise {
- if (viaCat) return await catText(path);
- try {
- return await Deno.readTextFile(path);
- } catch (e) {
- if (e instanceof Deno.errors.NotCapable) {
- viaCat = true;
- return await catText(path);
- }
- return null;
- }
-}
diff --git a/apps/server/src/sandbox/archives.ts b/apps/server/src/sandbox/archives.ts
index dae2cb10..251ef66a 100644
--- a/apps/server/src/sandbox/archives.ts
+++ b/apps/server/src/sandbox/archives.ts
@@ -1,8 +1,10 @@
// 压缩包解压(沙箱内):zip 用 fflate,rar 用 node-unrar-js(WASM),7z 用 7z-wasm。
// WASM 库要读自己包里的 .wasm 文件:prepareArchives() 在撤销权限前预热(载入内存),之后只在内存里解压。
import * as SevenZipModule from "7z-wasm";
+import sevenWasmPath from "7z-wasm/7zz.wasm" with { type: "file" };
import { unzipSync } from "fflate";
import { createExtractorFromData } from "node-unrar-js";
+import unrarWasmPath from "node-unrar-js/dist/js/unrar.wasm" with { type: "file" };
/** 用到的 7z-wasm 接口(Emscripten 实例:内存文件系统 + callMain) */
interface Seven {
@@ -17,6 +19,7 @@ interface Seven {
}
// 7z-wasm 是 CJS 包:tsc 与 deno check 对默认导出的理解不同,运行时取 default 或模块本身
const SevenZip = ((SevenZipModule as unknown as { default?: unknown }).default ?? SevenZipModule) as unknown as (opts: {
+ wasmBinary: Uint8Array;
print: (s: string) => void;
printErr: (s: string) => void;
}) => Promise;
@@ -27,11 +30,18 @@ export async function prepareArchives(): Promise {
if (warmed) return;
warmed = true;
try {
- await createExtractorFromData({ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer });
+ await createExtractorFromData({
+ data: new Uint8Array([0x52, 0x61, 0x72, 0x21]).buffer,
+ wasmBinary: await Bun.file(unrarWasmPath).arrayBuffer(),
+ });
} catch {
// 只为载入 WASM,内容无效报错是预期的
}
- seven = await SevenZip({ print: () => {}, printErr: () => {} });
+ seven = await SevenZip({
+ wasmBinary: new Uint8Array(await Bun.file(sevenWasmPath).arrayBuffer()),
+ print: () => {},
+ printErr: () => {},
+ });
}
export type AcceptEntry = (name: string, size: number) => boolean;
diff --git a/apps/server/src/sandbox/client.ts b/apps/server/src/sandbox/client.ts
index 9a06ea42..6c5f7b37 100644
--- a/apps/server/src/sandbox/client.ts
+++ b/apps/server/src/sandbox/client.ts
@@ -1,10 +1,11 @@
-// 沙箱调用(docs/adr/ADR-005-sandbox.md):每次一个子进程,权限全部撤销;超时 SIGKILL,子进程实际内存(RSS)超限 SIGKILL。
-// 起子进程方式同 fsproxy:deno CLI → `deno run worker.ts`(开发 / 测试);编译二进制 → `<自身> --role sandbox`。
-// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
+// 解析沙箱:预加载后安装 Linux seccomp(ADR-007),父进程实施超时与 RSS 强杀。
+// Bun CLI 启动 worker.ts;编译产物启动自身 --role sandbox。建立隔离失败直接拒绝。
import { basename } from "node:path";
import { fileURLToPath } from "node:url";
+import { WorkerConnection, WorkerTransportError } from "@umt/runtime/worker";
import { readProcText } from "../proc-read.ts";
-import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
+import type { SandboxInput, SandboxJobName, SandboxOutput } from "./jobs.ts";
+import { SANDBOX_MESSAGE_BYTES } from "./limits.ts";
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
@@ -27,37 +28,23 @@ export interface SandboxOptions {
}
const WORKER_PATH = fileURLToPath(new URL("./worker.ts", import.meta.url));
-let inProcessReason: string | null = null;
+let spawnFailureReason: string | null = null;
const stats = { spawned: 0, timeouts: 0, memoryKills: 0, crashes: 0 };
export function sandboxStats() {
- return { ...stats, inProcessReason };
+ return { ...stats, spawnFailureReason };
}
-function command(memoryMb: number): { cmd: string; args: string[] } {
- const exe = Deno.execPath();
- if (/^deno(\.exe)?$/.test(basename(exe)))
- return {
- cmd: exe,
- args: [
- "run",
- "-A",
- "--quiet",
- "--no-prompt", // V8 堆上限只作兜底(设为两倍):主要靠下面的 RSS 看门狗,编译产物不认 V8 参数,两种模式一致
- `--v8-flags=--max-old-space-size=${memoryMb * 2}`,
- WORKER_PATH,
- ],
- };
- return { cmd: exe, args: ["--role", "sandbox"] };
+function command(): { cmd: string; args: string[] } {
+ const exe = process.execPath;
+ return /^bun(\.exe)?$/.test(basename(exe))
+ ? { cmd: exe, args: ["--no-env-file", "--config=/dev/null", WORKER_PATH] }
+ : { cmd: exe, args: ["--role", "sandbox"] };
}
-/**
- * 子进程环境:清空,只留 PATH。编译产物启动时要按 PATH 解析 --allow-run 里的命令名(ffprobe、cat…),
- * 没有 PATH 会在进程入口报「no path to search」直接退出,所有沙箱任务都失败(2026-10 Bangumi 热门、字幕验证码即此)。
- * 子进程随后撤销全部权限,PATH 不放大能力。
- */
+/** 清空继承环境,只保留 PATH;CLI 禁用 dotenv,编译产物禁用自动配置加载。 */
export function sandboxEnv(): Record {
- return { PATH: Deno.env.get("PATH") || "/usr/local/bin:/usr/bin:/bin" };
+ return { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin" };
}
/** 子进程实际内存(KB);读不到返回 null */
@@ -75,95 +62,61 @@ export async function runSandbox(
const timeoutMs = opts.timeoutMs ?? 20_000;
const memoryMb = opts.memoryMb ?? 512;
opts.signal?.throwIfAborted();
- if (inProcessReason) return runInProcess(job, input, timeoutMs) as Promise>;
- const { cmd, args } = command(memoryMb);
- let child: Deno.ChildProcess;
+ const { cmd, args } = command();
+ let connection: WorkerConnection;
try {
- child = new Deno.Command(cmd, {
- args,
- stdin: "piped",
- stdout: "piped",
- stderr: "piped",
- env: sandboxEnv(),
- clearEnv: true,
- }).spawn();
- } catch (e) {
- inProcessReason = (e as Error).message;
- console.warn(`[sandbox] 起不来子进程,退回进程内执行:${inProcessReason}`);
- return runInProcess(job, input, timeoutMs) as Promise>;
+ connection = new WorkerConnection({ cmd, args }, { values: sandboxEnv(), clear: true });
+ } catch (error) {
+ spawnFailureReason = (error as Error).message;
+ throw new SandboxError("spawn", `沙箱子进程无法启动:${spawnFailureReason}`);
}
stats.spawned += 1;
let killedFor: SandboxErrorKind | null = null;
- const kill = (why: SandboxErrorKind) => {
+ const kill = (kind: SandboxErrorKind, message: string) => {
if (killedFor) return;
- killedFor = why;
- try {
- child.kill("SIGKILL");
- } catch {
- // 已退出
- }
+ killedFor = kind;
+ connection.terminate(new SandboxError(kind, message));
};
- const timer = setTimeout(() => kill("timeout"), timeoutMs);
+ const timer = setTimeout(() => kill("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`), timeoutMs);
const watch = setInterval(async () => {
- const kb = await rssKb(child.pid);
- if (kb !== null && kb > memoryMb * 1024) kill("memory");
+ const kb = await rssKb(connection.pid);
+ if (kb !== null && kb > memoryMb * 1024) kill("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`);
}, 100);
- const onAbort = () => kill("crash");
+ const onAbort = () => kill("crash", "沙箱任务已取消");
opts.signal?.addEventListener("abort", onAbort, { once: true });
try {
- const w = child.stdin.getWriter();
- await w.write(new TextEncoder().encode(JSON.stringify({ job, input }))).catch(() => undefined);
- await w.close().catch(() => undefined);
- const [status, stdout, stderr] = await Promise.all([
- child.status,
- new Response(child.stdout).text(),
- new Response(child.stderr).text(),
- ]);
- if (killedFor === "timeout") {
- stats.timeouts += 1;
- throw new SandboxError("timeout", `沙箱任务 ${job} 超过 ${timeoutMs} ms 未完成,已强制结束`);
+ const pending = connection.request(
+ { id: "1", task: job, input },
+ {
+ maxRequestBytes: SANDBOX_MESSAGE_BYTES,
+ maxResponseBytes: SANDBOX_MESSAGE_BYTES,
+ },
+ );
+ if (opts.signal?.aborted) onAbort();
+ const reply = await pending;
+ if (reply.type === "error") throw new SandboxError("job", reply.error.message);
+ return reply.result as SandboxOutput;
+ } catch (error) {
+ opts.signal?.throwIfAborted();
+ if (error instanceof SandboxError) {
+ if (error.kind === "timeout") stats.timeouts += 1;
+ if (error.kind === "memory") stats.memoryKills += 1;
+ throw error;
}
- if (killedFor === "memory" || /out of memory|heap limit/i.test(stderr)) {
+ if (error instanceof WorkerTransportError && error.kind === "spawn") {
+ spawnFailureReason = (error.cause as Error)?.message ?? error.message;
+ throw new SandboxError("spawn", `沙箱子进程无法启动:${spawnFailureReason}`);
+ }
+ if (error instanceof WorkerTransportError && error.kind === "size") throw new SandboxError("job", error.message);
+ if (/out of memory|heap limit/i.test((error as Error).message)) {
stats.memoryKills += 1;
throw new SandboxError("memory", `沙箱任务 ${job} 内存超过 ${memoryMb} MB,已强制结束`);
}
- opts.signal?.throwIfAborted();
- const line = stdout.trim().split("\n").pop() ?? "";
- let msg: { ok: boolean; result?: unknown; error?: string };
- try {
- msg = JSON.parse(line);
- } catch {
- stats.crashes += 1;
- throw new SandboxError("crash", `沙箱任务 ${job} 异常退出(code ${status.code}):${stderr.trim().slice(-300)}`);
- }
- if (!msg.ok) throw new SandboxError("job", msg.error ?? "沙箱任务失败");
- return msg.result as SandboxOutput;
+ stats.crashes += 1;
+ throw new SandboxError("crash", (error as Error).message);
} finally {
clearTimeout(timer);
clearInterval(watch);
opts.signal?.removeEventListener("abort", onAbort);
}
}
-
-async function runInProcess(
- job: J,
- input: SandboxInput,
- timeoutMs: number,
-): Promise {
- await prepareJobs();
- const fn = SANDBOX_JOBS[job] as (i: SandboxInput) => unknown;
- let timer: ReturnType | undefined;
- try {
- return await Promise.race([
- Promise.resolve().then(() => fn(input)),
- new Promise((_, reject) => {
- timer = setTimeout(() => reject(new SandboxError("timeout", `沙箱任务 ${job} 超时(进程内)`)), timeoutMs);
- }),
- ]);
- } catch (e) {
- if (e instanceof SandboxError) throw e;
- throw new SandboxError("job", `${(e as Error).name}: ${(e as Error).message}`);
- } finally {
- clearTimeout(timer);
- }
-}
diff --git a/apps/server/src/sandbox/jobs.ts b/apps/server/src/sandbox/jobs.ts
index 102e8c7a..6a726f1c 100644
--- a/apps/server/src/sandbox/jobs.ts
+++ b/apps/server/src/sandbox/jobs.ts
@@ -1,8 +1,12 @@
+import { execFileSync } from "node:child_process";
+import { readFile, writeFile } from "node:fs/promises";
+import { connect } from "node:net";
// 沙箱任务表(docs/adr/ADR-005-sandbox.md):只放「外部不可信字节 → 结构化数据」的纯函数。
// 子进程加载完本表后撤销全部权限(文件 / 网络 / 环境变量 / 子进程),所以任务里不能有任何 I/O;
// 输入输出必须能 JSON 化(二进制用 base64)。新增任务:在这里加一项,调用方用 runSandbox(名字, 输入)。
import { parseBangumiBrowser, parseTorrent } from "@umt/adapters";
import { decodeSubtitle, detectSubtitleLanguage, subtitleEpisode, subtitleExtOf } from "@umt/domain";
+import { runtimeReadTextFileSync } from "@umt/runtime";
import { buildPreview } from "../modules/file-manager/preview-build.ts";
import { PAGE_PARSERS, type PageKind } from "../modules/subtitles/online/parse.ts";
import { archiveKindOf, extractArchive, prepareArchives } from "./archives.ts";
@@ -96,14 +100,30 @@ function torrentParse(input: { base64: string }) {
}
/** 测试用:按指令卡死 / 吃内存 / 抛错,验证父进程的超时强杀与内存上限 */
-function selfTest(input: { mode: "ok" | "hang" | "memory" | "throw" | "io" }) {
+async function selfTest(input: {
+ mode: "ok" | "hang" | "memory" | "throw" | "io" | "async-io" | "write" | "net" | "exec" | "env";
+}) {
if (input.mode === "hang") for (;;) {}
if (input.mode === "memory") {
const keep: Uint8Array[] = [];
for (;;) keep.push(new Uint8Array(16 * 1024 * 1024).fill(1));
}
if (input.mode === "throw") throw new Error("任务自己抛的错");
- if (input.mode === "io") return { read: Deno.readTextFileSync("/etc/hostname") };
+ if (input.mode === "env") return { keys: Object.keys(process.env) };
+ if (input.mode === "async-io") return { read: await readFile("/etc/hostname", "utf8") };
+ if (input.mode === "write") return await writeFile("/tmp/umt-sandbox-denied", "denied");
+ if (input.mode === "net") {
+ return await new Promise((resolve, reject) => {
+ const socket = connect({ host: "127.0.0.1", port: 9 });
+ socket.once("error", reject);
+ socket.once("connect", () => {
+ socket.destroy();
+ resolve({ connected: true });
+ });
+ });
+ }
+ if (input.mode === "exec") return { stdout: execFileSync("/bin/echo", ["escaped"], { encoding: "utf8" }) };
+ if (input.mode === "io") return { read: runtimeReadTextFileSync("/etc/hostname") };
return { ok: true };
}
diff --git a/apps/server/src/sandbox/limits.ts b/apps/server/src/sandbox/limits.ts
new file mode 100644
index 00000000..2694fc25
--- /dev/null
+++ b/apps/server/src/sandbox/limits.ts
@@ -0,0 +1,2 @@
+/** 单条请求/响应包括 base64 的上限;任务自己的解压条目和文件大小限制另计。 */
+export const SANDBOX_MESSAGE_BYTES = 128 * 1024 * 1024;
diff --git a/apps/server/src/sandbox/native/policy.c b/apps/server/src/sandbox/native/policy.c
new file mode 100644
index 00000000..90075dee
--- /dev/null
+++ b/apps/server/src/sandbox/native/policy.c
@@ -0,0 +1,107 @@
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(__x86_64__)
+#define SANDBOX_ARCH AUDIT_ARCH_X86_64
+#elif defined(__aarch64__)
+#define SANDBOX_ARCH AUDIT_ARCH_AARCH64
+#else
+#error Unsupported sandbox architecture
+#endif
+#define ALLOW(n) BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_##n, 0, 1), BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ALLOW)
+
+/* Default-deny policy: no path-based IO, sockets, exec, ptrace, mounts,
+ * io_uring, or child processes. Apply to every existing runtime thread (TSYNC).
+ * New JS engine threads inherit the policy; only CLONE_THREAD is allowed. */
+static napi_value lockdown(napi_env env, napi_callback_info info) {
+ struct sock_filter filter[] = {
+ BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, arch)),
+ BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, SANDBOX_ARCH, 1, 0),
+ BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_KILL_PROCESS),
+ BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, nr)),
+ ALLOW(read), ALLOW(write), ALLOW(readv), ALLOW(writev), ALLOW(close),
+ ALLOW(fstat), ALLOW(lseek), ALLOW(fcntl),
+ ALLOW(mmap), ALLOW(mprotect), ALLOW(munmap), ALLOW(mremap), ALLOW(brk), ALLOW(madvise),
+ ALLOW(rt_sigaction), ALLOW(rt_sigprocmask), ALLOW(rt_sigreturn), ALLOW(sigaltstack),
+ ALLOW(futex), ALLOW(sched_yield), ALLOW(sched_getaffinity),
+ ALLOW(clock_gettime), ALLOW(clock_nanosleep), ALLOW(nanosleep), ALLOW(gettimeofday),
+ ALLOW(getpid), ALLOW(gettid), ALLOW(getuid), ALLOW(geteuid), ALLOW(getgid), ALLOW(getegid),
+ ALLOW(getrandom), ALLOW(getrusage), ALLOW(prlimit64),
+ ALLOW(epoll_create1), ALLOW(epoll_ctl), ALLOW(epoll_pwait),
+#ifdef __NR_epoll_wait
+ ALLOW(epoll_wait),
+#endif
+#ifdef __NR_epoll_pwait2
+ ALLOW(epoll_pwait2),
+#endif
+ ALLOW(eventfd2), ALLOW(pipe2), ALLOW(set_robust_list), ALLOW(rseq),
+#ifdef __NR_arch_prctl
+ ALLOW(arch_prctl),
+#endif
+ ALLOW(exit), ALLOW(exit_group),
+ /* clone3 cannot be safely inspected through its pointer argument. ENOSYS
+ * asks libc to use clone, whose thread flag we can inspect. */
+#ifdef __NR_clone3
+ BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone3, 0, 1),
+ BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|ENOSYS),
+#endif
+ BPF_JUMP(BPF_JMP|BPF_JEQ|BPF_K, __NR_clone, 0, 3),
+ BPF_STMT(BPF_LD|BPF_W|BPF_ABS, offsetof(struct seccomp_data, args[0])),
+ BPF_JUMP(BPF_JMP|BPF_JSET|BPF_K, CLONE_THREAD, 0, 1),
+ BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ALLOW),
+ BPF_STMT(BPF_RET|BPF_K, SECCOMP_RET_ERRNO|EPERM),
+ };
+ struct sock_fprog program = { .len = sizeof(filter)/sizeof(filter[0]), .filter = filter };
+ if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0 ||
+ syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, SECCOMP_FILTER_FLAG_TSYNC, &program) != 0) {
+ napi_throw_error(env, "SANDBOX_POLICY", "Cannot install sandbox seccomp policy");
+ return NULL;
+ }
+ /* Verify the installed kernel policy directly. Compiled Bun's network
+ * wrappers can normalize EPERM to ECONNREFUSED, so JS error strings alone
+ * cannot prove that the underlying syscall was blocked. */
+ errno = 0;
+ long fd = syscall(SYS_socket, AF_INET, SOCK_STREAM, 0);
+ if (fd >= 0 || errno != EPERM) {
+ if (fd >= 0) close((int)fd);
+ napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny socket creation");
+ return NULL;
+ }
+ errno = 0;
+ fd = syscall(SYS_openat, AT_FDCWD, "/__umt_sandbox_policy_probe__", O_RDONLY, 0);
+ if (fd >= 0 || errno != EPERM) {
+ if (fd >= 0) close((int)fd);
+ napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny file access");
+ return NULL;
+ }
+ errno = 0;
+ char *argv[] = { "/__umt_sandbox_policy_probe__", NULL };
+ char *envp[] = { NULL };
+ syscall(SYS_execve, argv[0], argv, envp);
+ if (errno != EPERM) {
+ napi_throw_error(env, "SANDBOX_POLICY", "Sandbox did not deny execve");
+ return NULL;
+ }
+ napi_value out;
+ napi_get_undefined(env, &out);
+ return out;
+}
+static napi_value init(napi_env env, napi_value exports) {
+ napi_value fn;
+ napi_create_function(env, "lockdown", NAPI_AUTO_LENGTH, lockdown, NULL, &fn);
+ napi_set_named_property(env, exports, "lockdown", fn);
+ return exports;
+}
+NAPI_MODULE(NODE_GYP_MODULE_NAME, init)
diff --git a/apps/server/src/sandbox/policy.ts b/apps/server/src/sandbox/policy.ts
new file mode 100644
index 00000000..6a9a6dd0
--- /dev/null
+++ b/apps/server/src/sandbox/policy.ts
@@ -0,0 +1,14 @@
+import { createRequire } from "node:module";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+/** Load before lockdown; compiled deployments install the policy next to the binary. */
+export function installSandboxPolicy(): void {
+ if (process.platform !== "linux") throw new Error("解析沙箱需要 Linux seccomp");
+ const compiled = !/^bun(?:\.exe)?$/.test(process.execPath.split("/").at(-1) ?? "");
+ const path = compiled
+ ? resolve(dirname(process.execPath), "sandbox-policy.node")
+ : fileURLToPath(new URL("../../../../dist/sandbox-policy.node", import.meta.url));
+ const policy = createRequire(import.meta.url)(path) as { lockdown(): void };
+ policy.lockdown();
+}
diff --git a/apps/server/src/sandbox/worker.ts b/apps/server/src/sandbox/worker.ts
index b9e82f78..3a67ebdb 100644
--- a/apps/server/src/sandbox/worker.ts
+++ b/apps/server/src/sandbox/worker.ts
@@ -1,33 +1,31 @@
-// 沙箱子进程入口:先加载任务表,再撤销全部权限,然后从 stdin 读一条 {job, input},把 {ok, result | error} 写到 stdout。
-// 开发 / 测试由 deno run 直接跑本文件;编译二进制以 `<自身> --role sandbox` 拉起(main.ts 在加载其余模块前分派)。
+import { decodeWorkerRequest, readWorkerRequestText, writeWorkerMessage } from "@umt/runtime/worker";
import { prepareJobs, SANDBOX_JOBS } from "./jobs.ts";
+import { SANDBOX_MESSAGE_BYTES } from "./limits.ts";
+import { installSandboxPolicy } from "./policy.ts";
-const PERMISSIONS = ["read", "write", "net", "env", "run", "ffi", "sys"] as const;
-
-async function writeAll(bytes: Uint8Array) {
- let off = 0;
- while (off < bytes.length) off += await Deno.stdout.write(bytes.subarray(off));
-}
-
+// 预加载可信任务表和 WASM,有界读取 stdin,安装策略后才 JSON.parse 与处理输入。
export async function sandboxWorkerMain(): Promise {
+ for (const key of Object.keys(process.env)) if (key !== "PATH") delete process.env[key];
await prepareJobs();
- for (const name of PERMISSIONS) {
- try {
- Deno.permissions.revokeSync({ name } as Deno.PermissionDescriptor);
- } catch {
- // 运行时不认识的权限名:跳过
- }
- }
- let out: unknown;
+ const requestText = await readWorkerRequestText(SANDBOX_MESSAGE_BYTES);
+ installSandboxPolicy();
+ const req = decodeWorkerRequest(requestText);
try {
- const req = JSON.parse(await new Response(Deno.stdin.readable).text()) as { job: string; input: unknown };
- const fn = (SANDBOX_JOBS as Record unknown>)[req.job];
- if (!fn) throw new Error(`没有这个沙箱任务:${req.job}`);
- out = { ok: true, result: await fn(req.input) };
- } catch (e) {
- out = { ok: false, error: `${(e as Error).name}: ${(e as Error).message}`.slice(0, 2000) };
+ const fn = (SANDBOX_JOBS as Record unknown>)[req.task];
+ if (!Object.hasOwn(SANDBOX_JOBS, req.task) || typeof fn !== "function")
+ throw new Error(`没有这个沙箱任务:${req.task}`);
+ const result = await fn(req.input);
+ writeWorkerMessage({ id: req.id, type: "result", result }, SANDBOX_MESSAGE_BYTES);
+ } catch (error) {
+ writeWorkerMessage(
+ {
+ id: req.id,
+ type: "error",
+ error: { code: "JOB", message: `${(error as Error).name}: ${(error as Error).message}`.slice(0, 2000) },
+ },
+ SANDBOX_MESSAGE_BYTES,
+ );
}
- await writeAll(new TextEncoder().encode(`${JSON.stringify(out)}\n`));
}
if (import.meta.main) await sandboxWorkerMain();
diff --git a/apps/server/src/storage/local-fs.ts b/apps/server/src/storage/local-fs.ts
index 8e897fa8..d299b4d5 100644
--- a/apps/server/src/storage/local-fs.ts
+++ b/apps/server/src/storage/local-fs.ts
@@ -5,7 +5,8 @@ import { assertLocalPaths } from "@umt/adapters";
// 参数是 Alist 虚拟路径(/@alist/…)就抛 FsError("REMOTE_PATH_ON_LOCAL_FS"),不在本机建出 /@alist 目录或写进文件。
// 读类函数(stat / readdir / readFile …)原样转出:对虚拟路径只会 ENOENT。
-export * from "node:fs/promises";
+// 显式导出避免 Bun 对内置模块 export * 的编译问题。
+export const { stat, lstat, readdir, readFile, realpath } = fsp;
type P = Parameters[0];
const s = (p: unknown) => (typeof p === "string" ? p : p instanceof URL ? p.pathname : undefined);
diff --git a/apps/server/src/storage/vfs.ts b/apps/server/src/storage/vfs.ts
index 3df8ab45..79ce1ead 100644
--- a/apps/server/src/storage/vfs.ts
+++ b/apps/server/src/storage/vfs.ts
@@ -250,7 +250,7 @@ export async function vreadFile(p: string, maxBytes = 4 * 1024 * 1024): Promise<
/** 本机临时目录(远端写入的本地中转) */
export function relayDir(): string {
- return join(storageDataDir() ?? Deno.env.get("DATA_DIR") ?? tmpdir(), "cache", "storage-relay");
+ return join(storageDataDir() ?? process.env.DATA_DIR ?? tmpdir(), "cache", "storage-relay");
}
/**
diff --git a/apps/server/src/worker/lanes.ts b/apps/server/src/worker/lanes.ts
index 5d47c90b..47203fd6 100644
--- a/apps/server/src/worker/lanes.ts
+++ b/apps/server/src/worker/lanes.ts
@@ -1,4 +1,5 @@
import { DEFAULT_WORKER_LANES, WORKER_LANE_MAX, type WorkerLaneSettings } from "@umt/contracts";
+import { runtimeUnrefTimer } from "@umt/runtime";
import { laneOf, type QueueCounts, type TaskDefinition, type TaskLane, type WorkerHandle } from "@umt/tasks";
import type { Logger } from "pino";
import type { Config } from "../config.ts";
@@ -167,7 +168,7 @@ export class LaneController {
if (!this.timer) {
this.timer = setInterval(() => void this.refresh(), this.o.refreshMs ?? 5000);
try {
- Deno.unrefTimer(this.timer);
+ runtimeUnrefTimer(this.timer);
} catch {
// 非 Deno 运行时
}
diff --git a/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts b/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts
new file mode 100644
index 00000000..87009f37
--- /dev/null
+++ b/apps/server/test/acquisition-organize/automatic-upgrade-replacement.test.ts
@@ -0,0 +1,258 @@
+import { existsSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { createMockQb, createTempTree, type MockQb, makeTorrent, type TempTree } from "@umt/testkit";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
+import { submitDownload, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
+import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
+import { applyEpisodeTotal } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
+import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
+
+const hasPg = await pgAvailable();
+const SIZE = 300_000;
+
+describe.skipIf(!hasPg)("自动洗版物理替换(真实 PG、qB协议与临时文件)", () => {
+ let h: Harness;
+ let qb: MockQb;
+ let lib: TempTree;
+ let cache: TempTree;
+ let base: Awaited>;
+ let groupId: string;
+ const disk = fakeDisk(100_000_000_000);
+ const torrents = new Map();
+ let pieceSeed = 0;
+
+ beforeAll(async () => {
+ qb = await createMockQb({ addVisibleAfterPolls: 0 });
+ lib = await createTempTree({}, { sentinel: true, prefix: "umt-auto-up-lib-" });
+ cache = await createTempTree({}, { prefix: "umt-auto-up-cache-" });
+ disk.state.watch = [cache.root];
+ disk.setPct(10);
+ h = await createHarness("auto_upgrade_replace", {
+ diskUsage: disk.fn,
+ torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
+ });
+ base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
+ await h.setSettings("naming", {
+ movie: "{{title}} ({{year}})/{{title}} ({{year}}) - {{videoFormat}}{{fileExt}}",
+ tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
+ });
+ const g = await h.api("POST", "/rules", { name: "自动洗版优先REMUX", ruleString: "REMUX > WEBDL" });
+ expect(g.status, JSON.stringify(g.body)).toBe(201);
+ groupId = g.body.data.id;
+ }, 60_000);
+
+ afterAll(async () => {
+ await h?.close();
+ await qb?.close();
+ await lib?.cleanup();
+ await cache?.cleanup();
+ });
+
+ async function tracked(kind: "movie" | "series", title: string, versions: { name: string; resolutions: string[] }[]) {
+ const entityId = await seedEntity(h.db, { kind, title, year: 2024 });
+ const sub = await h.db
+ .insertInto("subscription")
+ .values({
+ media_entity_id: entityId,
+ media_kind: kind,
+ mode: "once",
+ season_scope: "current_season",
+ params: JSON.stringify({ autoFill: "on" }),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ const slotIds: string[] = [];
+ for (const [position, v] of versions.entries()) {
+ const slot = await h.db
+ .insertInto("acquisition_slot")
+ .values({
+ media_entity_id: entityId,
+ subscription_id: sub.id,
+ name: v.name,
+ position,
+ hard_conditions: JSON.stringify({ resolutions: v.resolutions }),
+ soft_preferences: JSON.stringify({ groups: ["GOOD", "OTHER", "LOW"] }),
+ upgrade_threshold: JSON.stringify({
+ enabled: true,
+ basis: "priority",
+ mode: kind === "movie" ? "movie" : "per_unit",
+ ...(kind === "series" ? { consistency: "season_release" } : {}),
+ }),
+ rule_group_ids: JSON.stringify([groupId]),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ slotIds.push(slot.id);
+ }
+ if (kind === "series")
+ await h.db
+ .transaction()
+ .execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 2, now: new Date() }));
+ return { entityId, slotIds };
+ }
+
+ async function release(entityId: string, title: string) {
+ const t = makeTorrent({ name: `${title}.mkv`, length: SIZE, pieceSeed: ++pieceSeed });
+ const id = await seedRelease(h.db, { siteId: base.siteId, entityId, title, size: SIZE, hash: t.hash });
+ torrents.set(id, t.bytes);
+ return { id, hash: t.hash };
+ }
+
+ async function acquire(entityId: string, title: string, expectedSlot: string, purpose: "fill" | "upgrade") {
+ const rel = await release(entityId, title);
+ const ev = await evaluateAcquisition(h.d, { sourceReleaseId: rel.id });
+ expect(ev.plans, JSON.stringify(ev)).toHaveLength(1);
+ const planId = ev.plans[0]!;
+ const plan = await h.db
+ .selectFrom("acquisition_plan")
+ .selectAll()
+ .where("id", "=", planId)
+ .executeTakeFirstOrThrow();
+ expect(plan).toMatchObject({ slot_id: expectedSlot, purpose });
+ if (purpose === "upgrade") expect(plan.evaluation).toMatchObject({ automaticReplacement: true });
+ const submitted = await submitDownload(h.d, planId);
+ expect(submitted.status, JSON.stringify(submitted)).toBe("submitted");
+ await qb.complete(rel.hash, { writeFiles: true });
+ expect((await syncDownloads(h.d)).completed).toContain(submitted.downloadTaskId);
+ const imported = await planImport(h.d, {
+ downloadTaskId: submitted.downloadTaskId!,
+ trigger: "auto",
+ actorType: "system",
+ });
+ expect(imported.status, JSON.stringify(imported)).toBe("running");
+ expect((await executeRecord(h.d, imported.recordId!)).status).toBe("succeeded");
+ const result = await h.db
+ .selectFrom("organization_file_result")
+ .select("media_asset_id")
+ .where("organization_record_id", "=", imported.recordId!)
+ .where("role", "=", "main")
+ .where("status", "=", "succeeded")
+ .executeTakeFirstOrThrow();
+ const location = await h.db
+ .selectFrom("asset_location")
+ .select("relative_path")
+ .where("media_asset_id", "=", result.media_asset_id!)
+ .where("location_kind", "=", "library")
+ .where("status", "=", "available")
+ .executeTakeFirstOrThrow();
+ const bytes = await readFile(lib.path(location.relative_path));
+ expect(bytes).toHaveLength(SIZE);
+ return { assetId: result.media_asset_id!, path: location.relative_path, bytes, recordId: imported.recordId! };
+ }
+
+ async function expectKept(file: Awaited>) {
+ const a = await h.db
+ .selectFrom("media_asset")
+ .select("status")
+ .where("id", "=", file.assetId)
+ .executeTakeFirstOrThrow();
+ expect(a.status).toBe("active");
+ expect((await readFile(lib.path(file.path))).equals(file.bytes)).toBe(true);
+ expect(
+ await h.db.selectFrom("trash_item").select("id").where("original_path", "=", file.path).execute(),
+ ).toHaveLength(0);
+ }
+
+ async function expectTrashed(file: Awaited>, samePath = false) {
+ const a = await h.db
+ .selectFrom("media_asset")
+ .select("status")
+ .where("id", "=", file.assetId)
+ .executeTakeFirstOrThrow();
+ expect(a.status).toBe("retired");
+ const oldLocation = await h.db
+ .selectFrom("asset_location")
+ .select("status")
+ .where("media_asset_id", "=", file.assetId)
+ .where("location_kind", "=", "library")
+ .executeTakeFirstOrThrow();
+ expect(oldLocation.status).toBe("removed");
+ if (!samePath) expect(existsSync(lib.path(file.path))).toBe(false);
+ const items = await h.db
+ .selectFrom("trash_item")
+ .select("trash_path")
+ .where("original_path", "=", file.path)
+ .execute();
+ expect(items).toHaveLength(1);
+ expect((await readFile(items[0]!.trash_path)).equals(file.bytes)).toBe(true);
+ }
+
+ it("电影低档自动入库后升级替换同路径旧文件并回收,另一保留分辨率保持原字节", async () => {
+ const f = await tracked("movie", "Auto Movie", [
+ { name: "HD", resolutions: ["1080p"] },
+ { name: "UHD", resolutions: ["2160p"] },
+ ]);
+ const uhd = await acquire(f.entityId, "Auto.Movie.2024.2160p.BluRay.REMUX.HEVC-GOOD", f.slotIds[1]!, "fill");
+ const lower = await acquire(f.entityId, "Auto.Movie.2024.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
+ const higher = await acquire(f.entityId, "Auto.Movie.2024.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
+ expect(higher.path).toBe(lower.path);
+ expect(higher.assetId).not.toBe(lower.assetId);
+ expect(higher.bytes.equals(lower.bytes)).toBe(false);
+ await expectTrashed(lower, true);
+ await expectKept(uhd);
+ expect((await readFile(lib.path(higher.path))).equals(higher.bytes)).toBe(true);
+ await executeRecord(h.d, higher.recordId);
+ await expectTrashed(lower, true);
+ await expectKept(uhd);
+ }, 60_000);
+
+ it("旧单文件E1-E2在同套新E1入库时保留,新E2补齐后自动回收旧合集", async () => {
+ const f = await tracked("series", "Auto Merged", [{ name: "单保留", resolutions: ["720p", "1080p"] }]);
+ const old = await acquire(f.entityId, "Auto.Merged.S01E01-E02.720p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
+ const first = await acquire(f.entityId, "Auto.Merged.S01E01.1080p.BluRay.REMUX.AVC-GOOD", f.slotIds[0]!, "upgrade");
+ await expectKept(old);
+ const second = await acquire(
+ f.entityId,
+ "Auto.Merged.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
+ f.slotIds[0]!,
+ "upgrade",
+ );
+ await expectTrashed(old);
+ await expectKept(first);
+ await expectKept(second);
+ await executeRecord(h.d, second.recordId);
+ await expectTrashed(old);
+ }, 60_000);
+
+ it("不同组或另一保留分辨率的已入库E2不能凑齐新E1的合集覆盖", async () => {
+ const f = await tracked("series", "Auto Isolated", [
+ { name: "HD", resolutions: ["1080p"] },
+ { name: "UHD", resolutions: ["2160p"] },
+ ]);
+ const old = await acquire(f.entityId, "Auto.Isolated.S01E01-E02.1080p.WEB-DL.x264-LOW", f.slotIds[0]!, "fill");
+ const otherResolution = await acquire(
+ f.entityId,
+ "Auto.Isolated.S01E02.2160p.BluRay.REMUX.AVC-GOOD",
+ f.slotIds[1]!,
+ "fill",
+ );
+ const otherGroup = await acquire(
+ f.entityId,
+ "Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-OTHER",
+ f.slotIds[0]!,
+ "upgrade",
+ );
+ await expectKept(old);
+ const first = await acquire(
+ f.entityId,
+ "Auto.Isolated.S01E01.1080p.BluRay.REMUX.AVC-GOOD",
+ f.slotIds[0]!,
+ "upgrade",
+ );
+ await expectKept(old);
+ await expectKept(otherGroup);
+ await expectKept(otherResolution);
+ const second = await acquire(
+ f.entityId,
+ "Auto.Isolated.S01E02.1080p.BluRay.REMUX.AVC-GOOD",
+ f.slotIds[0]!,
+ "upgrade",
+ );
+ await expectTrashed(old);
+ await expectTrashed(otherGroup, true);
+ await expectKept(first);
+ await expectKept(otherResolution);
+ expect((await readFile(lib.path(second.path))).equals(second.bytes)).toBe(true);
+ }, 60_000);
+});
diff --git a/apps/server/test/acquisition-organize/collection-strategy.test.ts b/apps/server/test/acquisition-organize/collection-strategy.test.ts
new file mode 100644
index 00000000..fddec9c1
--- /dev/null
+++ b/apps/server/test/acquisition-organize/collection-strategy.test.ts
@@ -0,0 +1,334 @@
+import { randomUUID } from "node:crypto";
+import type { AcquisitionPolicyScope, SlotTemplate, SubscriptionParams } from "@umt/contracts";
+import type { AcquisitionSlotRow } from "@umt/db";
+import { type QualityFacts, type SlotSoftPreferences, type SlotSpec, seasonReleaseKey } from "@umt/domain";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { ensureSubscriptionSlot } from "../../src/modules/acquisition-organize/services/collection-strategy.ts";
+import { seasonReleaseTarget } from "../../src/modules/acquisition-organize/services/season-release.ts";
+import { slotComplete } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
+import { applySlotPreset } from "../../src/modules/acquisition-organize/services/subscriptions-params.ts";
+import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease } from "./harness.ts";
+
+const hasPg = await pgAvailable();
+
+describe.skipIf(!hasPg)("收录策略与成套洗版边界(真实 PG)", () => {
+ let h: Harness;
+ let siteId: string;
+
+ beforeAll(async () => {
+ h = await createHarness("collection_strategy");
+ siteId = (
+ await h.db
+ .insertInto("site")
+ .values({
+ key: "collection-test",
+ name: "收录策略测试站",
+ adapter: "nexusphp",
+ base_url: "https://pt.example.test",
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow()
+ ).id;
+ });
+
+ afterAll(async () => {
+ await h?.close();
+ });
+
+ async function policy(scope: AcquisitionPolicyScope, slots: SlotTemplate[], publishedAt = "2026-01-01T00:00:00Z") {
+ return (
+ await h.db
+ .insertInto("policy_revision")
+ .values({
+ policy_key: randomUUID(),
+ revision: 1,
+ kind: "acquisition",
+ status: "published",
+ title: "收录策略",
+ scope: JSON.stringify(scope),
+ rules: JSON.stringify({ defaultSlots: slots }),
+ published_at: new Date(publishedAt),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow()
+ ).id;
+ }
+
+ async function subscription(
+ kind: "movie" | "series",
+ policyId: string | null = null,
+ params: SubscriptionParams = {},
+ ) {
+ const entityId = await seedEntity(h.db, { kind, title: `Collection ${randomUUID()}`, year: 2026 });
+ const sub = await h.db
+ .insertInto("subscription")
+ .values({
+ media_entity_id: entityId,
+ media_kind: kind,
+ mode: "once",
+ season_scope: "current_season",
+ policy_revision_id: policyId,
+ params: JSON.stringify(params),
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ return { entityId, sub };
+ }
+
+ const slotsOf = (subId: string) =>
+ h.db.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", subId).orderBy("position").execute();
+
+ async function series(opts: { resolutions?: string[]; wash?: boolean; soft?: SlotSoftPreferences } = {}) {
+ const { entityId, sub } = await subscription("series");
+ await h.db
+ .insertInto("content_unit")
+ .values({ media_entity_id: entityId, kind: "season", season_number: 1, status: "confirmed", episode_total: 2 })
+ .execute();
+ const eps = new Map();
+ for (const ep of [1, 2]) {
+ const unit = await h.db
+ .insertInto("content_unit")
+ .values({
+ media_entity_id: entityId,
+ kind: "episode",
+ season_number: 1,
+ episode_number: ep,
+ status: "confirmed",
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ eps.set(ep, unit.id);
+ }
+ const slot = await h.db
+ .insertInto("acquisition_slot")
+ .values({
+ media_entity_id: entityId,
+ subscription_id: sub.id,
+ name: "保留版本",
+ hard_conditions: JSON.stringify({ resolutions: opts.resolutions ?? ["1080p"] }),
+ soft_preferences: JSON.stringify(opts.soft ?? {}),
+ upgrade_threshold: JSON.stringify(
+ opts.wash === false
+ ? {}
+ : { enabled: true, basis: "priority", consistency: "season_release", targetPri: 100 },
+ ),
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ return { entityId, sub, slot, eps };
+ }
+
+ const quality = (group: string, resolution: QualityFacts["resolution"] = "1080p"): QualityFacts => ({
+ resolution,
+ source: "WEB-DL",
+ videoCodec: "H.264",
+ releaseGroup: { name: group, source: "suffix" },
+ hdr: [],
+ pri: 100,
+ });
+
+ async function asset(f: Awaited>, episodes: number[], q: QualityFacts, ledger = true) {
+ const a = await h.db
+ .insertInto("media_asset")
+ .values({
+ media_entity_id: f.entityId,
+ kind: "video",
+ status: "active",
+ media_info: JSON.stringify({ _umt: { quality: q } }),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ for (const ep of episodes) {
+ await h.db
+ .insertInto("asset_unit_coverage")
+ .values({ media_asset_id: a.id, content_unit_id: f.eps.get(ep)!, coverage: "full" })
+ .execute();
+ if (ledger)
+ await h.db
+ .insertInto("slot_unit")
+ .values({
+ slot_id: f.slot.id,
+ content_unit_id: f.eps.get(ep)!,
+ media_asset_id: a.id,
+ quality: JSON.stringify(q),
+ pri: q.pri,
+ })
+ .onConflict((oc) =>
+ oc
+ .columns(["slot_id", "content_unit_id"])
+ .doUpdateSet({ media_asset_id: a.id, quality: JSON.stringify(q), pri: q.pri }),
+ )
+ .execute();
+ }
+ return a.id;
+ }
+
+ async function plan(
+ slot: AcquisitionSlotRow,
+ q: QualityFacts,
+ units: string[],
+ status: "submitted" | "reserved" = "submitted",
+ ) {
+ const relId = await seedRelease(h.db, {
+ siteId,
+ entityId: slot.media_entity_id,
+ title: `Collection.S01.${q.resolution}.WEB-DL.x264-${q.releaseGroup!.name}`,
+ size: 1_000_000,
+ hash: randomUUID().replaceAll("-", "").padEnd(40, "0"),
+ });
+ const p = await h.db
+ .insertInto("acquisition_plan")
+ .values({
+ slot_id: slot.id,
+ source_release_id: relId,
+ status,
+ status_reason: status === "submitted" ? "download_accepted" : null,
+ purpose: "upgrade",
+ unit_keys: units,
+ payload_hash: randomUUID(),
+ idempotency_key: randomUUID(),
+ evaluation: JSON.stringify({ pri: q.pri, quality: q, season: 1, seasonRelease: seasonReleaseKey(q) }),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ return { planId: p.id, relId };
+ }
+
+ it("显式选择优先;分类按 movie/tv 匹配,未命中分类时使用媒体类型默认模板", async () => {
+ const allId = await policy({}, [{ name: "通用" }], "2026-06-01T00:00:00Z");
+ const movieId = await policy({ mediaKinds: ["movie"] }, [{ name: "电影默认" }]);
+ const tvId = await policy({ mediaKinds: ["series"] }, [{ name: "剧集默认" }]);
+ const movieCategoryId = await policy({ categories: { movie: ["精选"] } }, [{ name: "电影精选" }]);
+ const tvCategoryId = await policy({ categories: { tv: ["精选"] } }, [{ name: "剧集精选" }]);
+ for (const [kind, category, explicitId, expectedId, name] of [
+ ["series", "精选", allId, allId, "通用"],
+ ["movie", "精选", null, movieCategoryId, "电影精选"],
+ ["series", "精选", null, tvCategoryId, "剧集精选"],
+ ["movie", "其他", null, movieId, "电影默认"],
+ ["series", "其他", null, tvId, "剧集默认"],
+ ] as const) {
+ const { sub } = await subscription(kind, explicitId);
+ expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(1);
+ expect(await slotsOf(sub.id)).toMatchObject([{ name, policy_revision_id: expectedId, position: 0 }]);
+ expect(await ensureSubscriptionSlot(h.db, sub, { mediaKind: kind, category })).toBe(0);
+ expect(await slotsOf(sub.id)).toHaveLength(1);
+ }
+ });
+
+ it("显式选择没有模板的策略时报告错误,不静默回退到分类或通用策略", async () => {
+ const policyId = await policy({}, []);
+ const { sub } = await subscription("series", policyId);
+ await expect(ensureSubscriptionSlot(h.db, sub, { mediaKind: "series", category: "精选" })).rejects.toThrow(
+ "所选收录策略",
+ );
+ expect(await slotsOf(sub.id)).toHaveLength(0);
+ });
+
+ it("双分辨率模板保留各自范围,统一预设即使与某模板相同也不能覆盖它", async () => {
+ const policyId = await policy({}, [
+ { name: "HD", hardConditions: { resolutions: ["1080p"] } },
+ { name: "UHD", hardConditions: { resolutions: ["2160p"] } },
+ ]);
+ const { sub } = await subscription("series", policyId, { resolution: "1080p" });
+ expect(await ensureSubscriptionSlot(h.db, sub)).toBe(2);
+ const expected = [
+ { name: "HD", hard_conditions: { resolutions: ["1080p"] } },
+ { name: "UHD", hard_conditions: { resolutions: ["2160p"] } },
+ ];
+ expect(await slotsOf(sub.id)).toMatchObject(expected);
+ await applySlotPreset(h.db, {
+ subscriptionId: sub.id,
+ prev: { resolution: "1080p" },
+ next: { resolution: "720p" },
+ });
+ expect(await slotsOf(sub.id)).toMatchObject(expected);
+ });
+
+ it("单保留4K版本时,作品已有完整720p不能填补4K覆盖", async () => {
+ const f = await series({ resolutions: ["2160p"], wash: false });
+ await asset(f, [1, 2], quality("LOW", "720p"), false);
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: false, atTarget: false });
+ await asset(f, [1, 2], quality("UHD", "2160p"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
+ });
+
+ it("已接收的旧720p计划同样不能填补改为4K后的范围", async () => {
+ const f = await series({ resolutions: ["2160p"], wash: false });
+ await plan(f.slot, quality("LOW", "720p"), ["s1e1", "s1e2"]);
+ expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
+ complete: false,
+ atTarget: false,
+ });
+ await plan(f.slot, quality("UHD", "2160p"), ["s1e1", "s1e2"]);
+ expect(await slotComplete(h.db, f.slot, "tv", [], { includeSubmitted: true })).toMatchObject({
+ complete: true,
+ atTarget: true,
+ });
+ });
+
+ it("HD/UHD分别完成,另一个保留分辨率的资产不能借来填缺", async () => {
+ const f = await series({ resolutions: ["1080p"], wash: false });
+ const uhd = await h.db
+ .insertInto("acquisition_slot")
+ .values({
+ media_entity_id: f.entityId,
+ subscription_id: f.sub.id,
+ name: "UHD",
+ hard_conditions: JSON.stringify({ resolutions: ["2160p"] }),
+ })
+ .returningAll()
+ .executeTakeFirstOrThrow();
+ await asset(f, [1, 2], quality("HD"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
+ expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: false });
+ await asset({ ...f, slot: uhd }, [1, 2], quality("UHD", "2160p"));
+ expect(await slotComplete(h.db, uhd, "tv", [])).toMatchObject({ complete: true });
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true });
+ });
+
+ it("各集pri100但组不同仍未到成套目标;同组的分集资源合齐后到目标", async () => {
+ const f = await series({ soft: { groups: ["B", "A"] } });
+ await asset(f, [1], quality("B"));
+ await asset(f, [2], quality("A"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
+ await asset(f, [2], quality("B"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
+ });
+
+ it("完整A不能盖过更优但只有部分集的B,成套洗版继续以B为目标", async () => {
+ const f = await series({ soft: { groups: ["B", "A"] } });
+ await asset(f, [1, 2], quality("A"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
+ await asset(f, [1], quality("B"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: false });
+ await asset(f, [2], quality("B"));
+ expect(await slotComplete(h.db, f.slot, "tv", [])).toMatchObject({ complete: true, atTarget: true });
+ });
+
+ it("在途季包冻结版本事实,发布属性修改不漂移锚点,失败后才释放", async () => {
+ const f = await series({ soft: { groups: ["C", "A", "B"] } });
+ const frozen = quality("A");
+ const p = await plan(f.slot, frozen, ["s1*"], "reserved");
+ const spec: SlotSpec = {
+ name: "成套目标",
+ hard: { resolutions: ["1080p"] },
+ soft: { groups: ["C", "A", "B"] },
+ upgrade: { enabled: true, basis: "priority", consistency: "season_release" },
+ };
+ expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
+ seasonReleaseKey(frozen),
+ );
+ await h.db
+ .updateTable("source_release")
+ .set({ title: "Collection.S01.1080p.WEB-DL.x264-C", parsed: JSON.stringify({}), parser_version: null })
+ .where("id", "=", p.relId)
+ .execute();
+ expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
+ seasonReleaseKey(frozen),
+ );
+ await h.db.updateTable("acquisition_plan").set({ status: "failed" }).where("id", "=", p.planId).execute();
+ expect(seasonReleaseKey((await seasonReleaseTarget(h.db, f.slot, 1, spec, quality("B")))!)).toBe(
+ seasonReleaseKey(quality("B")),
+ );
+ });
+});
diff --git a/apps/server/test/acquisition-organize/download-observation-order.test.ts b/apps/server/test/acquisition-organize/download-observation-order.test.ts
new file mode 100644
index 00000000..71184aa7
--- /dev/null
+++ b/apps/server/test/acquisition-organize/download-observation-order.test.ts
@@ -0,0 +1,186 @@
+import { createQbClient } from "@umt/adapters";
+import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { MISSING_CONFIRM_ROUNDS, syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
+import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase } from "./harness.ts";
+
+const hasPg = await pgAvailable();
+
+function deferred() {
+ let resolve!: () => void;
+ const promise = new Promise((done) => {
+ resolve = done;
+ });
+ return { promise, resolve };
+}
+
+describe.skipIf(!hasPg)("下载观测顺序(真实 PG + 受控 qB 响应)", () => {
+ let h: Harness;
+ let qb: MockQb;
+ let lib: TempTree;
+ let cache: TempTree;
+ let base: Awaited>;
+ let tick = 0;
+ let hashIndex = 0;
+ const epoch = Date.parse("2026-10-13T00:00:00Z");
+ let pending: {
+ method: "list" | "findByHash";
+ hash?: string;
+ captured: ReturnType;
+ resume: ReturnType;
+ } | null = null;
+
+ beforeAll(async () => {
+ qb = await createMockQb();
+ lib = await createTempTree({}, { sentinel: true });
+ cache = await createTempTree({});
+ h = await createHarness("download_order", { diskUsage: fakeDisk(10_000_000_000).fn });
+ base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
+ h.d.ports.now = () => new Date(epoch + tick);
+ const original = h.d.ports.downloader;
+ h.d.ports.downloader = async (inst) => {
+ const client = await original(inst);
+ const hold = async (method: "list" | "findByHash", read: () => Promise, hash?: string): Promise => {
+ const gate = pending?.method === method && (!pending.hash || pending.hash === hash) ? pending : null;
+ if (gate) pending = null;
+ const snapshot = await read();
+ if (gate) {
+ gate.captured.resolve();
+ await gate.resume.promise;
+ }
+ return snapshot;
+ };
+ return {
+ ...client,
+ list: (input) => hold("list", () => client.list(input)),
+ findByHash: (hash) => hold("findByHash", () => client.findByHash(hash), hash),
+ };
+ };
+ });
+
+ afterAll(async () => {
+ pending?.resume.resolve();
+ await h?.close();
+ await qb?.close();
+ await lib?.cleanup();
+ await cache?.cleanup();
+ });
+
+ const nextTime = () => new Date(epoch + ++tick);
+ const row = (id: string) =>
+ h.db.selectFrom("download_task").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
+ const pauseNext = (method: "list" | "findByHash", hash?: string) => {
+ const gate = { method, hash, captured: deferred(), resume: deferred() };
+ pending = gate;
+ return gate;
+ };
+ const seedTorrent = (hash: string) => {
+ qb.seed({ hash, name: `Observation.${hash}.mkv`, size: 300_000, tags: ["umt"], save_path: cache.root });
+ qb.setProgress(hash, 0.25);
+ };
+ async function task() {
+ const hash = (++hashIndex).toString(16).padStart(40, "0");
+ seedTorrent(hash);
+ nextTime();
+ await syncDownloads(h.d);
+ const r = await h.db
+ .selectFrom("download_task")
+ .selectAll()
+ .where("torrent_hash", "=", hash)
+ .executeTakeFirstOrThrow();
+ expect(r).toMatchObject({ managed: true, status: "downloading", missing_count: 0 });
+ return { hash, id: r.id };
+ }
+
+ it("移除标签的新观测先提交时,旧有标签列表晚到不能复活managed", async () => {
+ const t = await task();
+ const gate = pauseNext("list");
+ nextTime();
+ const older = syncDownloads(h.d);
+ try {
+ await gate.captured.promise;
+ qb.get(t.hash)!.tags = [];
+ const newerAt = nextTime();
+ await syncDownloads(h.d);
+ expect(await row(t.id)).toMatchObject({ managed: false, tags: [], last_synced_at: newerAt });
+ gate.resume.resolve();
+ await older;
+ expect(await row(t.id)).toMatchObject({ managed: false, tags: [], removed_at: null, last_synced_at: newerAt });
+ } finally {
+ gate.resume.resolve();
+ await older;
+ }
+ });
+
+ it("旧缺失响应晚到不能把已经重新观测到的任务判移除或恢复旧missing_count", async () => {
+ const t = await task();
+ await h.db
+ .updateTable("download_task")
+ .set({ missing_count: MISSING_CONFIRM_ROUNDS - 1, missing_since: new Date(epoch) })
+ .where("id", "=", t.id)
+ .execute();
+ const external = createQbClient({
+ instanceId: base.instanceId,
+ baseUrl: qb.url,
+ username: "admin",
+ password: "adminadmin",
+ });
+ await external.delete([t.hash], { deleteFiles: false });
+ const gate = pauseNext("findByHash", t.hash);
+ nextTime();
+ const older = syncDownloads(h.d);
+ try {
+ await gate.captured.promise;
+ seedTorrent(t.hash);
+ const newerAt = nextTime();
+ await syncDownloads(h.d);
+ expect(await row(t.id)).toMatchObject({
+ managed: true,
+ status: "downloading",
+ missing_count: 0,
+ last_synced_at: newerAt,
+ });
+ gate.resume.resolve();
+ await older;
+ expect(await row(t.id)).toMatchObject({
+ managed: true,
+ status: "downloading",
+ removed_at: null,
+ missing_count: 0,
+ missing_since: null,
+ last_synced_at: newerAt,
+ });
+ } finally {
+ gate.resume.resolve();
+ await older;
+ }
+ });
+
+ it("重新贴标签的新观测可恢复管理,旧无标签响应晚到不能撤销恢复", async () => {
+ const t = await task();
+ qb.get(t.hash)!.tags = [];
+ const gate = pauseNext("findByHash", t.hash);
+ nextTime();
+ const older = syncDownloads(h.d);
+ try {
+ await gate.captured.promise;
+ qb.get(t.hash)!.tags = ["umt"];
+ const newerAt = nextTime();
+ await syncDownloads(h.d);
+ gate.resume.resolve();
+ await older;
+ expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: newerAt });
+ qb.get(t.hash)!.tags = [];
+ nextTime();
+ await syncDownloads(h.d);
+ expect(await row(t.id)).toMatchObject({ managed: false });
+ qb.get(t.hash)!.tags = ["umt"];
+ const restoredAt = nextTime();
+ await syncDownloads(h.d);
+ expect(await row(t.id)).toMatchObject({ managed: true, tags: ["umt"], last_synced_at: restoredAt });
+ } finally {
+ gate.resume.resolve();
+ await older;
+ }
+ });
+});
diff --git a/apps/server/test/acquisition-organize/organize-recovery.test.ts b/apps/server/test/acquisition-organize/organize-recovery.test.ts
index 606810ef..f27864b4 100644
--- a/apps/server/test/acquisition-organize/organize-recovery.test.ts
+++ b/apps/server/test/acquisition-organize/organize-recovery.test.ts
@@ -262,6 +262,23 @@ describe.skipIf(!hasPg)("整理卡死回收(真实 PG + mock qB + 临时目录)"
const dec = await decision(`organize_excluded:${plan.recordId}`);
expect(dec).toMatchObject({ status: "open", subject_id: plan.recordId });
expect(JSON.stringify(dec!.evidence)).toContain("excluded:episode_unresolved");
+ // 终态重放也不能把部分成功当成整包成功,且刮削记录不重复。
+ const scrapes = () =>
+ h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", plan.recordId!).execute();
+ const beforeReplay = await scrapes();
+ expect(beforeReplay).toHaveLength(1);
+ expect((await executeRecord(h.d, plan.recordId!)).status).toBe("partial");
+ expect(existsSync(join(cache.root, name, ani(40)))).toBe(true);
+ expect(qb.get(hash)).toBeDefined();
+ expect(qb.callsTo("torrents/delete")).toHaveLength(0);
+ expect(
+ await h.db
+ .selectFrom("download_task")
+ .select(["managed", "removed_at"])
+ .where("id", "=", dtId)
+ .executeTakeFirst(),
+ ).toEqual({ managed: true, removed_at: null });
+ expect(await scrapes()).toEqual(beforeReplay);
// partial 不在补移除范围内
await h.db
.updateTable("organization_record")
diff --git a/apps/server/test/acquisition-organize/policy-combinations.test.ts b/apps/server/test/acquisition-organize/policy-combinations.test.ts
new file mode 100644
index 00000000..c677789c
--- /dev/null
+++ b/apps/server/test/acquisition-organize/policy-combinations.test.ts
@@ -0,0 +1,318 @@
+import { randomUUID } from "node:crypto";
+import type { AcquisitionPolicyRules, PolicyRevision, RuleGroup } from "@umt/contracts";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
+import { loadRuleCatalog } from "../../src/modules/acquisition-organize/services/rules.ts";
+import { slotSpecWithRules } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
+import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity, seedRelease } from "./harness.ts";
+
+// 实际 PG + 路由/评估入口:模板引用、补齐与洗版组合,以及发布/删除时的引用保护。
+const pg = await pgAvailable();
+
+describe.skipIf(!pg)("收录策略与规则组合", () => {
+ let h: Harness;
+ let base: Awaited>;
+ let n = 0;
+ const disk = fakeDisk(100_000_000_000);
+
+ beforeAll(async () => {
+ disk.setPct(10);
+ h = await createHarness("policy_combinations", { diskUsage: disk.fn });
+ base = await seedBase(h.db, {
+ libraryPath: "/tmp/umt-policy-combinations-library",
+ cachePath: "/tmp/umt-policy-combinations-cache",
+ qbUrl: "http://127.0.0.1:9",
+ });
+ });
+
+ beforeEach(async () => {
+ const defaults = await h.api("PUT", "/rules/defaults", { acquisition: [], upgrade: [], search: [] });
+ expect(defaults.status).toBe(200);
+ await h.db
+ .updateTable("policy_revision")
+ .set({ status: "archived" })
+ .where("status", "in", ["draft", "published"])
+ .execute();
+ await h.db.updateTable("acquisition_plan").set({ status: "cancelled" }).where("status", "=", "reserved").execute();
+ });
+
+ afterAll(async () => {
+ await h?.close();
+ });
+
+ async function group(ruleString = "H265", kind: "acquisition" | "upgrade" = "acquisition") {
+ const response = await h.api("POST", "/rules", { name: `组合规则-${++n}`, kind, ruleString });
+ expect(response.status, JSON.stringify(response.body)).toBe(201);
+ return response.body.data as RuleGroup;
+ }
+
+ async function draft(rules: AcquisitionPolicyRules, policyKey = `combinations-${++n}`) {
+ const response = await h.api("POST", "/policy-revisions", {
+ policyKey,
+ kind: "acquisition",
+ title: "组合策略",
+ rules,
+ });
+ expect(response.status, JSON.stringify(response.body)).toBe(201);
+ return response.body.data as PolicyRevision;
+ }
+
+ async function publish(policy: PolicyRevision) {
+ const response = await h.api("POST", `/policy-revisions/${policy.id}/publish`, {
+ expectedRevision: policy.revision,
+ });
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ return response.body.data as PolicyRevision;
+ }
+
+ const release = (entityId: string, title: string) =>
+ seedRelease(h.db, {
+ siteId: base.siteId,
+ entityId,
+ title,
+ size: 300_000_000,
+ hash: (++n).toString(16).padStart(40, "0"),
+ });
+
+ it("策略预览执行模板的 H265 规则组,硬条件相同的 H264 资源被排除", async () => {
+ const filter = await group();
+ const entityId = await seedEntity(h.db, { kind: "movie", title: "Preview Film", year: 2024 });
+ const h265 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H265-GRP");
+ const h264 = await release(entityId, "Preview.Film.2024.1080p.WEB-DL.H264-GRP");
+ const policy = await draft({
+ defaultSlots: [{ name: "只要 H265", hardConditions: { resolutions: ["1080p"] }, ruleGroupIds: [filter.id] }],
+ });
+ const response = await h.api("POST", `/policy-revisions/${policy.id}/preview`);
+ expect(response.status, JSON.stringify(response.body)).toBe(200);
+ const preview = response.body.data.preview as {
+ matched: number;
+ excluded: number;
+ samples: { sourceReleaseId: string; decision: string; reasons: string[] }[];
+ };
+ expect(preview).toMatchObject({ matched: 1, excluded: 1 });
+ expect(preview.samples.find((sample) => sample.sourceReleaseId === h265)).toMatchObject({ decision: "accept" });
+ expect(preview.samples.find((sample) => sample.sourceReleaseId === h264)).toMatchObject({ decision: "reject" });
+ expect(await h.db.selectFrom("acquisition_plan").select("id").execute()).toHaveLength(0);
+ });
+
+ it("新订阅允许先补 WEB:即使模板开启升级,也不把 REMUX 洗版默认链叠到补齐规则", async () => {
+ const fill = await group("H265");
+ const wash = await group("REMUX", "upgrade");
+ expect(
+ (await h.api("PUT", "/rules/defaults", { acquisition: [fill.id], upgrade: [wash.id], search: [] })).status,
+ ).toBe(200);
+ await publish(
+ await draft({
+ defaultSlots: [
+ {
+ name: "先补齐再洗版",
+ hardConditions: { resolutions: ["1080p"] },
+ upgradeThreshold: { enabled: true, mode: "movie", basis: "priority" },
+ },
+ ],
+ }),
+ );
+ const entityId = await seedEntity(h.db, { kind: "movie", title: "Fill Film", year: 2024 });
+ const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
+ expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
+ const slot = await h.db
+ .selectFrom("acquisition_slot")
+ .selectAll()
+ .where("subscription_id", "=", subscription.body.data.id)
+ .executeTakeFirstOrThrow();
+ expect(slot.upgrade_threshold).toMatchObject({ enabled: true });
+ const resource = await release(entityId, "Fill.Film.2024.1080p.WEB-DL.H265-GRP");
+ const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
+ expect(result.plans, JSON.stringify(result)).toHaveLength(1);
+ const plan = await h.db
+ .selectFrom("acquisition_plan")
+ .selectAll()
+ .where("id", "=", result.plans[0]!)
+ .executeTakeFirstOrThrow();
+ expect(plan).toMatchObject({ slot_id: slot.id, purpose: "fill", status: "reserved" });
+ const spec = slotSpecWithRules(
+ await loadRuleCatalog(h.db),
+ slot,
+ null,
+ { id: entityId, kind: "movie" },
+ base.siteId,
+ );
+ expect(spec.rules?.groups.map((g) => g.id)).toEqual([fill.id]);
+ expect(spec.upgradeRules?.groups.map((g) => g.id)).toEqual([fill.id, wash.id]);
+ });
+
+ it("规则组被收录版本的洗版引用时不能删;通过版本更新移除引用后可删", async () => {
+ const wash = await group("REMUX", "upgrade");
+ const entityId = await seedEntity(h.db, { kind: "movie", title: "Slot Reference", year: 2024 });
+ const created = await h.api("POST", "/acquisition-slots", {
+ mediaEntityId: entityId,
+ name: "洗版引用",
+ upgradeThreshold: { enabled: true, ruleGroupIds: [wash.id] },
+ });
+ expect(created.status, JSON.stringify(created.body)).toBe(201);
+ expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(409);
+ expect(
+ (await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
+ .deleted_at,
+ ).toBeNull();
+ const updated = await h.api("PATCH", `/acquisition-slots/${created.body.data.id}`, {
+ expectedRevision: created.body.data.revision,
+ upgradeThreshold: { enabled: true, ruleGroupIds: [] },
+ });
+ expect(updated.status, JSON.stringify(updated.body)).toBe(200);
+ expect((await h.api("DELETE", `/rules/${wash.id}`)).status).toBe(200);
+ expect(
+ (await h.db.selectFrom("rule_group").select("deleted_at").where("id", "=", wash.id).executeTakeFirstOrThrow())
+ .deleted_at,
+ ).not.toBeNull();
+ });
+
+ it.each([
+ { label: "草案 defaultSlots 补齐", published: false, newTitle: false, wash: false },
+ { label: "已发布 defaultSlots 洗版", published: true, newTitle: false, wash: true },
+ { label: "草案 newTitle 洗版", published: false, newTitle: true, wash: true },
+ { label: "已发布 newTitle 补齐", published: true, newTitle: true, wash: false },
+ ])("$label 引用阻止删除,解除有效策略引用后可删", async ({ published, newTitle, wash }) => {
+ const referenced = await group(wash ? "REMUX" : "H265", wash ? "upgrade" : "acquisition");
+ const template = {
+ name: "引用模板",
+ ...(wash
+ ? { upgradeThreshold: { enabled: true, ruleGroupIds: [referenced.id] } }
+ : { ruleGroupIds: [referenced.id] }),
+ };
+ const rules: AcquisitionPolicyRules = newTitle
+ ? { newTitle: { enabled: true, slots: [template] } }
+ : { defaultSlots: [template] };
+ const policy = await draft(rules);
+ if (published) await publish(policy);
+ expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(409);
+ if (published) {
+ await publish(await draft({}, policy.policyKey));
+ expect(
+ (
+ await h.db
+ .selectFrom("policy_revision")
+ .select("status")
+ .where("id", "=", policy.id)
+ .executeTakeFirstOrThrow()
+ ).status,
+ ).toBe("superseded");
+ } else {
+ const updated = await h.api("PATCH", `/policy-revisions/${policy.id}`, { rules: {} });
+ expect(updated.status, JSON.stringify(updated.body)).toBe(200);
+ }
+ expect((await h.api("DELETE", `/rules/${referenced.id}`)).status).toBe(200);
+ });
+
+ it.each(["fill", "wash"] as const)("发布拒绝不存在的模板 %s 规则组,旧发布和授权保留", async (phase) => {
+ const existing = await publish(await draft({}));
+ const missing = randomUUID();
+ const rules: AcquisitionPolicyRules =
+ phase === "fill"
+ ? { defaultSlots: [{ name: "坏补齐引用", ruleGroupIds: [missing] }] }
+ : {
+ newTitle: {
+ enabled: true,
+ slots: [{ name: "坏洗版引用", upgradeThreshold: { enabled: true, ruleGroupIds: [missing] } }],
+ },
+ };
+ const invalid = await draft(rules, existing.policyKey);
+ const response = await h.api("POST", `/policy-revisions/${invalid.id}/publish`, {
+ expectedRevision: invalid.revision,
+ });
+ expect(response.status, JSON.stringify(response.body)).toBe(400);
+ expect(response.body.error.message).toContain("规则组不存在");
+ expect(response.body.error.message).toContain(missing);
+ const policies = await h.db
+ .selectFrom("policy_revision")
+ .select(["id", "status"])
+ .where("id", "in", [existing.id, invalid.id])
+ .execute();
+ expect(policies).toEqual(
+ expect.arrayContaining([
+ { id: existing.id, status: "published" },
+ { id: invalid.id, status: "draft" },
+ ]),
+ );
+ expect(
+ await h.db
+ .selectFrom("automation_grant")
+ .select(["status"])
+ .where("policy_revision_id", "=", existing.id)
+ .execute(),
+ ).toEqual([{ status: "active" }]);
+ expect(
+ await h.db.selectFrom("automation_grant").select("id").where("policy_revision_id", "=", invalid.id).execute(),
+ ).toHaveLength(0);
+ });
+
+ it("电影忽略季级一致性,仍能从已有 WEB 资产生成 REMUX 升级计划", async () => {
+ const entityId = await seedEntity(h.db, { kind: "movie", title: "Movie Upgrade", year: 2024 });
+ const subscription = await h.api("POST", "/subscriptions", { mediaEntityId: entityId, mediaKind: "movie" });
+ expect(subscription.status, JSON.stringify(subscription.body)).toBe(201);
+ const slot = await h.db
+ .selectFrom("acquisition_slot")
+ .selectAll()
+ .where("subscription_id", "=", subscription.body.data.id)
+ .executeTakeFirstOrThrow();
+ const update = await h.api("PATCH", `/acquisition-slots/${slot.id}`, {
+ expectedRevision: slot.revision,
+ hardConditions: { resolutions: ["1080p"] },
+ upgradeThreshold: { enabled: true, mode: "movie", basis: "tier", consistency: "season_release" },
+ });
+ expect(update.status, JSON.stringify(update.body)).toBe(200);
+ const asset = await h.db
+ .insertInto("media_asset")
+ .values({
+ media_entity_id: entityId,
+ kind: "video",
+ size_bytes: 300_000_000,
+ media_info: JSON.stringify({
+ _umt: {
+ quality: {
+ resolution: "1080p",
+ source: "WEB-DL",
+ videoCodec: "H.264",
+ releaseGroup: { name: "OLD", source: "suffix" },
+ hdr: [],
+ },
+ },
+ }),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ await h.db
+ .updateTable("acquisition_slot")
+ .set({ status: "satisfied", current_asset_id: asset.id })
+ .where("id", "=", slot.id)
+ .execute();
+ const persisted = await h.db
+ .selectFrom("acquisition_slot")
+ .selectAll()
+ .where("id", "=", slot.id)
+ .executeTakeFirstOrThrow();
+ expect(persisted.upgrade_threshold).toMatchObject({ enabled: true, consistency: "season_release" });
+ const spec = slotSpecWithRules(
+ await loadRuleCatalog(h.db),
+ persisted,
+ null,
+ { id: entityId, kind: "movie" },
+ base.siteId,
+ );
+ expect(spec.upgrade).toMatchObject({ enabled: true, mode: "movie" });
+ expect(spec.upgrade?.consistency).toBeUndefined();
+ const resource = await release(entityId, "Movie.Upgrade.2024.1080p.BluRay.REMUX.H264-NEW");
+ const result = await evaluateAcquisition(h.d, { sourceReleaseId: resource });
+ expect(result.plans, JSON.stringify(result)).toHaveLength(1);
+ const plan = await h.db
+ .selectFrom("acquisition_plan")
+ .selectAll()
+ .where("id", "=", result.plans[0]!)
+ .executeTakeFirstOrThrow();
+ expect(plan).toMatchObject({
+ slot_id: slot.id,
+ purpose: "upgrade",
+ evaluation: { automaticReplacement: true, replacesAssetId: asset.id },
+ });
+ });
+});
diff --git a/apps/server/test/auth-settings/rotate-master-key.test.ts b/apps/server/test/auth-settings/rotate-master-key.test.ts
index 38f5227c..42503ec9 100644
--- a/apps/server/test/auth-settings/rotate-master-key.test.ts
+++ b/apps/server/test/auth-settings/rotate-master-key.test.ts
@@ -79,7 +79,7 @@ describe.skipIf(!hasPg)("主密钥轮换(临时库)", () => {
const main = fileURLToPath(new URL("../../src/main.ts", import.meta.url));
const url = `${PG_URL}${PG_URL.includes("?") ? "&" : "?"}options=${encodeURIComponent(`-c search_path=${h.schema},public`)}`;
const run = (extra: Record) =>
- spawnSync("deno", ["run", "-A", main, "--role", "rotate-master-key"], {
+ spawnSync(process.execPath, [main, "--role", "rotate-master-key"], {
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? "",
diff --git a/apps/server/test/auth-settings/sse-pg.test.ts b/apps/server/test/auth-settings/sse-pg.test.ts
index 6f7615a1..ee188e40 100644
--- a/apps/server/test/auth-settings/sse-pg.test.ts
+++ b/apps/server/test/auth-settings/sse-pg.test.ts
@@ -13,7 +13,7 @@ const hasPg = await pgAvailable();
const SERVER_DIR = fileURLToPath(new URL("../..", import.meta.url));
function proc(args: string[]) {
- const child = spawn(process.execPath, ["run", "-A", "test/fixtures/sse-bus-proc.ts", ...args], {
+ const child = spawn(process.execPath, ["test/fixtures/sse-bus-proc.ts", ...args], {
cwd: SERVER_DIR,
env: { ...process.env, DATABASE_URL: PG_URL },
stdio: ["ignore", "pipe", "pipe"],
diff --git a/apps/server/test/authorization/grants.test.ts b/apps/server/test/authorization/grants.test.ts
new file mode 100644
index 00000000..60d94940
--- /dev/null
+++ b/apps/server/test/authorization/grants.test.ts
@@ -0,0 +1,130 @@
+import { randomUUID } from "node:crypto";
+import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
+import { coveringGrant, type PlanBinding, planBinding } from "../../src/authorization/grants.ts";
+import { createTestSchema, pgAvailable, type TestSchema, uniqueSchema } from "../support/pg.ts";
+
+const hasPg = await pgAvailable();
+const now = new Date("2026-01-15T12:00:00Z");
+const target: PlanBinding = {
+ payloadHash: "plan-hash",
+ revision: 1,
+ mediaEntityId: randomUUID(),
+ mediaKind: "tv",
+ bytes: 100,
+};
+
+describe.skipIf(!hasPg)("共用计划绑定与持续授权(真实 PG)", () => {
+ let schema: TestSchema;
+ beforeAll(async () => {
+ schema = await createTestSchema(uniqueSchema("server_test_grants"));
+ });
+ afterAll(async () => {
+ await schema?.drop();
+ });
+ beforeEach(async () => {
+ await schema.db.deleteFrom("capability_invocation").execute();
+ await schema.db.deleteFrom("automation_grant").execute();
+ await schema.db.deleteFrom("policy_revision").execute();
+ await schema.db.deleteFrom("artifact").execute();
+ });
+
+ const grant = (over: Record = {}) =>
+ schema.db
+ .insertInto("automation_grant")
+ .values({ action: "download.submit", granted_by: "user:test", ...over })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ const check = (plan = target) => coveringGrant(schema.db, "download.submit", plan, now);
+
+ it("整理计划绑定读取当前内容与 hash,计划不存在时返回 null", async () => {
+ const p = await schema.db
+ .insertInto("artifact")
+ .values({
+ kind: "plan",
+ title: "整理计划",
+ content_hash: target.payloadHash,
+ content: JSON.stringify({ mediaEntityId: target.mediaEntityId, mediaKind: target.mediaKind }),
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ expect(await planBinding(schema.db, "organize_plan", p.id)).toEqual({ ...target, bytes: null });
+ await schema.db.updateTable("artifact").set({ content_hash: "new-hash" }).where("id", "=", p.id).execute();
+ expect((await planBinding(schema.db, "organize_plan", p.id))?.payloadHash).toBe("new-hash");
+ expect(await planBinding(schema.db, "organize_plan", randomUUID())).toBeNull();
+ });
+
+ it("授权范围同时检查能力、作品和媒体类型,series 范围覆盖 tv", async () => {
+ const g = await grant({
+ scope: JSON.stringify({
+ capabilities: ["download.submit"],
+ mediaEntityIds: [target.mediaEntityId],
+ mediaKinds: ["series"],
+ }),
+ });
+ expect(await check()).toEqual({ grantId: g.id });
+ expect(await check({ ...target, mediaEntityId: randomUUID() })).toMatchObject({ reasons: [expect.any(String)] });
+ expect(await check({ ...target, mediaKind: "movie" })).toMatchObject({ reasons: [expect.any(String)] });
+ await schema.db
+ .updateTable("automation_grant")
+ .set({ scope: JSON.stringify({ capabilities: ["organize.execute"] }) })
+ .where("id", "=", g.id)
+ .execute();
+ expect(await check()).toMatchObject({ reasons: [expect.any(String)] });
+ });
+
+ it.each(["expired", "revoked"] as const)("%s 授权不放行", async (state) => {
+ await grant(
+ state === "expired" ? { expires_at: new Date(now.getTime() - 1) } : { status: "revoked", revoked_at: now },
+ );
+ expect(await check()).toEqual({ reasons: [] });
+ });
+
+ it("策略被替代后,绑定该策略的持续授权不再放行", async () => {
+ const p = await schema.db
+ .insertInto("policy_revision")
+ .values({
+ policy_key: "test-acquisition",
+ revision: 1,
+ kind: "acquisition",
+ title: "测试策略",
+ status: "published",
+ })
+ .returning("id")
+ .executeTakeFirstOrThrow();
+ const g = await grant({ action: "policy.acquisition", policy_revision_id: p.id });
+ expect(await check()).toEqual({ grantId: g.id });
+ await schema.db.updateTable("policy_revision").set({ status: "superseded" }).where("id", "=", p.id).execute();
+ expect(await check()).toMatchObject({ reasons: [expect.stringContaining("已不是发布状态")] });
+ });
+
+ it("每日次数只统计成功调用,达到预算后拒绝", async () => {
+ const g = await grant({ budget: JSON.stringify({ maxInvocationsPerDay: 2 }) });
+ const invoke = (status: "failed" | "succeeded") =>
+ schema.db
+ .insertInto("capability_invocation")
+ .values({
+ idempotency_key: randomUUID(),
+ capability_id: "download.submit",
+ capability_version: 1,
+ actor_id: "user:test",
+ source: "web",
+ status,
+ grant_id: g.id,
+ created_at: now,
+ })
+ .execute();
+ await invoke("succeeded");
+ await invoke("failed");
+ expect(await check()).toEqual({ grantId: g.id });
+ await invoke("succeeded");
+ expect(await check()).toMatchObject({ reasons: [expect.stringContaining("次数预算已用尽")] });
+ });
+
+ it("提交下载时把本次预留字节计入预算", async () => {
+ const g = await grant({ budget: JSON.stringify({ maxBytesPerDay: 100 }) });
+ expect(await check()).toEqual({ grantId: g.id });
+ expect(await check({ ...target, bytes: 101 })).toMatchObject({
+ reasons: [expect.stringContaining("字节预算不足")],
+ });
+ });
+});
diff --git a/apps/server/test/desktop/desktop.test.ts b/apps/server/test/desktop/desktop.test.ts
index b25649b7..90d9db85 100644
--- a/apps/server/test/desktop/desktop.test.ts
+++ b/apps/server/test/desktop/desktop.test.ts
@@ -52,7 +52,7 @@ function harness(
status: 200,
headers: { "content-type": "text/html", "www-authenticate": "Basic", "x-frame-options": "DENY" },
});
- }) as typeof fetch,
+ }) as (input: RequestInfo | URL, init?: RequestInit) => Promise,
});
const root = new OpenAPIHono();
root.onError((err, c) => {
diff --git a/apps/server/test/e2e/agent-prompt-eval.real.test.ts b/apps/server/test/e2e/agent-prompt-eval.real.test.ts
index ab1289f3..25371044 100644
--- a/apps/server/test/e2e/agent-prompt-eval.real.test.ts
+++ b/apps/server/test/e2e/agent-prompt-eval.real.test.ts
@@ -14,7 +14,7 @@ import { createE2E, type E2E, pgAvailable, runEvents, runRow, waitFor } from "./
// 运行(凭证只经环境变量传入,不写进代码与报告):
// UMT_EVAL_REAL=1 UMT_EVAL_API_KEY=… UMT_EVAL_PROVIDER=opencode-go UMT_EVAL_MODEL=deepseek-v4.1-flash \
// UMT_EVAL_LEGACY_PROMPT=/tmp/legacy-system-prompt.ts UMT_EVAL_CASES=health,acquire,why UMT_EVAL_OUT=/tmp/eval.json \
-// DATABASE_URL=… deno run -A npm:vitest run test/e2e/agent-prompt-eval.real.test.ts
+// DATABASE_URL=… bun --bun x vitest run test/e2e/agent-prompt-eval.real.test.ts
// UMT_EVAL_LEGACY_PROMPT 用 `git show main:packages/agent/src/system-prompt.ts > /tmp/legacy-system-prompt.ts` 生成;
// UMT_EVAL_PROMPTS=legacy,layered 控制跑哪几组(缺省两组都跑)。
diff --git a/apps/server/test/e2e/crash-recovery.test.ts b/apps/server/test/e2e/crash-recovery.test.ts
index 0a55a8a7..64cddeb1 100644
--- a/apps/server/test/e2e/crash-recovery.test.ts
+++ b/apps/server/test/e2e/crash-recovery.test.ts
@@ -29,7 +29,7 @@ describe.skipIf(!hasPg)("E4 崩溃恢复(worker 子进程 kill -9)", () => {
const children: ChildProcess[] = [];
const spawnWorker = (): Promise => {
- const child = spawn(process.execPath, ["run", "-A", WORKER], {
+ const child = spawn(process.execPath, [WORKER], {
cwd: SERVER_DIR,
env: {
...process.env,
diff --git a/apps/server/test/e2e/fixtures/worker.ts b/apps/server/test/e2e/fixtures/worker.ts
index ad3131db..9fbce67c 100644
--- a/apps/server/test/e2e/fixtures/worker.ts
+++ b/apps/server/test/e2e/fixtures/worker.ts
@@ -11,17 +11,17 @@ import { startWorker } from "../../../src/worker/index.ts";
import { buildModules } from "./modules.ts";
import { crashScript } from "./scripts.ts";
-const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
-const biz = Deno.env.get("BIZ_SCHEMA")!;
-const ow = Deno.env.get("OW_SCHEMA")!;
-const sessionDir = Deno.env.get("SESSION_DIR")!;
+const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
+const biz = process.env.BIZ_SCHEMA!;
+const ow = process.env.OW_SCHEMA!;
+const sessionDir = process.env.SESSION_DIR!;
await import("@umt/db"); // 注册 int8 解析器
const db = new Kysely({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 8, options: `-c search_path=${biz},public` }),
}),
});
-const logger = createLogger({ level: Deno.env.get("E2E_LOG_LEVEL") === "debug" ? "debug" : "warn" });
+const logger = createLogger({ level: process.env.E2E_LOG_LEVEL === "debug" ? "debug" : "warn" });
const config = ConfigSchema.parse({
DATABASE_URL: url,
SECRET_MASTER_KEY: "e2e-master-key-0123456789",
@@ -43,10 +43,10 @@ const worker = await startWorker({
agentConcurrency: 2,
},
});
-console.log(`worker ready pid=${Deno.pid}`);
-Deno.addSignalListener("SIGTERM", async () => {
+console.log(`worker ready pid=${process.pid}`);
+process.on("SIGTERM", async () => {
await worker.stop().catch(() => {});
await booted.engine.stop().catch(() => {});
await db.destroy().catch(() => {});
- Deno.exit(0);
+ process.exit(0);
});
diff --git a/apps/server/test/e2e/websearch.test.ts b/apps/server/test/e2e/websearch.test.ts
index fb72a98a..da8f97f7 100644
--- a/apps/server/test/e2e/websearch.test.ts
+++ b/apps/server/test/e2e/websearch.test.ts
@@ -52,7 +52,7 @@ const mockFetch = (async (input: string | URL | Request, init?: RequestInit) =>
if (url.href === NOTICE_URL)
return new Response(NOTICE_HTML, { headers: { "content-type": "text/html; charset=utf-8" } });
return new Response("not found", { status: 404 });
-}) as typeof fetch;
+}) as (input: RequestInfo | URL, init?: RequestInit) => Promise;
const resolveHost = async (host: string) => (host.endsWith("example.org") ? ["93.184.216.34"] : ["203.0.114.10"]);
diff --git a/apps/server/test/fixtures/sse-bus-proc.ts b/apps/server/test/fixtures/sse-bus-proc.ts
index 5a7665fb..5749251e 100644
--- a/apps/server/test/fixtures/sse-bus-proc.ts
+++ b/apps/server/test/fixtures/sse-bus-proc.ts
@@ -1,14 +1,14 @@
// SSE 多进程测试用子进程:sub 模式订阅并逐行输出收到的事件 JSON,收满 n 条退出;pub 模式发布 n 条后退出。
-// 用法:deno run -A test/fixtures/sse-bus-proc.ts
+// 用法:bun test/fixtures/sse-bus-proc.ts
import type { DB } from "@umt/db";
import { Kysely, PostgresDialect } from "kysely";
import pg from "pg";
import { PgEventBus } from "../../src/http/sse-pg.ts";
import { createLogger } from "../../src/logger.ts";
-const [mode, schema, channel, nRaw] = Deno.args;
+const [mode, schema, channel, nRaw] = process.argv.slice(2);
const n = Number(nRaw);
-const url = Deno.env.get("DATABASE_URL") ?? "postgres://umt:umt@localhost:55433/umt";
+const url = process.env.DATABASE_URL ?? "postgres://umt:umt@localhost:55433/umt";
const db = new Kysely({
dialect: new PostgresDialect({
pool: new pg.Pool({ connectionString: url, max: 2, options: `-c search_path=${schema},public` }),
@@ -32,12 +32,12 @@ if (mode === "sub") {
console.log("READY");
const timeout = setTimeout(() => {
console.error("timeout");
- Deno.exit(3);
+ process.exit(3);
}, 20_000);
await done;
clearTimeout(timeout);
} else {
- for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: Deno.pid });
+ for (let i = 0; i < n; i++) await bus.publish("test.ping", { i, pid: process.pid });
}
await bus.stop();
await db.destroy();
diff --git a/apps/server/test/hooks/hook-registry.test.ts b/apps/server/test/hooks/hook-registry.test.ts
deleted file mode 100644
index 3d6d84af..00000000
--- a/apps/server/test/hooks/hook-registry.test.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import type { ParsedRelease } from "@umt/domain";
-import { describe, expect, it, vi } from "vitest";
-import { HookRegistry, hasHook, interceptHook, rewriteHook } from "../../src/hooks/index.ts";
-
-// 链式钩子(ADR-006):注册顺序、改写、拦截、抛错隔离、超时、没有注册时零开销。
-
-const warnings = () => {
- const warn = vi.fn();
- return { logger: { warn } as never, warn };
-};
-const parsed = (title: string) => ({ title, raw: title, explicitIds: [] }) as unknown as ParsedRelease;
-const recognizeCtx = { title: "x", subtitle: null, sourceReleaseId: null, trigger: "observed" };
-const completionCtx = {
- subscriptionId: "s",
- mediaEntityId: null,
- mediaKind: "movie",
- reason: "slot_satisfied",
- progress: {},
-};
-
-describe("链式钩子注册表", () => {
- it("按注册顺序执行(bootstrap 按模块顺序注册),每个钩子拿到上一个的结果;缺省名 <模块>#<序号>", async () => {
- const reg = new HookRegistry();
- const seen: string[] = [];
- const a = reg.forModule("a");
- const b = reg.forModule("b");
- a.rewrite("organize.rename", (name) => {
- seen.push(`a1:${name}`);
- return `${name}-a1`;
- });
- b.rewrite("organize.rename", (name) => {
- seen.push(`b:${name}`);
- return `${name}-b`;
- });
- a.rewrite("organize.rename", (name) => {
- seen.push(`a2:${name}`);
- return undefined; // 不改
- });
- const ctx = { hooks: reg };
- const out = await rewriteHook(ctx, "organize.rename", "X.mkv", {
- targetPath: "/lib/X.mkv",
- sourcePath: "/src/X.mkv",
- mediaEntityId: "e",
- mediaType: "movie",
- season: null,
- episodes: null,
- });
- expect(out).toBe("X.mkv-a1-b");
- expect(seen).toEqual(["a1:X.mkv", "b:X.mkv-a1", "a2:X.mkv-a1-b"]);
- expect(reg.list()).toEqual({ "organize.rename": ["a#1", "b#1", "a#2"] });
- });
-
- it("改写生效:异步钩子改写解析结果", async () => {
- const reg = new HookRegistry();
- reg.forModule("m").rewrite("name.recognize", async (p) => ({ ...p, title: "改写后" }));
- const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("原名"), recognizeCtx);
- expect(out.title).toBe("改写后");
- });
-
- it("拦截生效:第一个返回原因的钩子拒绝,后面的不再执行;都放行返回 null", async () => {
- const reg = new HookRegistry();
- const later = vi.fn(() => "不该执行");
- const m = reg.forModule("m");
- m.intercept("subscription.completion.check", () => null);
- m.intercept("subscription.completion.check", () => " 还缺特别篇 ", { name: "缺特别篇" });
- m.intercept("subscription.completion.check", later);
- expect(await interceptHook({ hooks: reg }, "subscription.completion.check", completionCtx)).toEqual({
- reason: "还缺特别篇",
- by: "缺特别篇",
- });
- expect(later).not.toHaveBeenCalled();
-
- const pass = new HookRegistry();
- pass.forModule("m").intercept("subscription.completion.check", () => undefined);
- expect(await interceptHook({ hooks: pass }, "subscription.completion.check", completionCtx)).toBeNull();
- });
-
- it("抛错隔离:出错的钩子跳过并记日志,前后的钩子照常生效", async () => {
- const { logger, warn } = warnings();
- const reg = new HookRegistry(logger);
- const m = reg.forModule("m");
- m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+1` }));
- m.rewrite("name.recognize", () => {
- throw new Error("插件坏了");
- });
- m.rewrite("name.recognize", async () => {
- throw new Error("异步也坏了");
- });
- m.rewrite("name.recognize", (p) => ({ ...p, title: `${p.title}+2` }));
- const out = await rewriteHook({ hooks: reg }, "name.recognize", parsed("t"), recognizeCtx);
- expect(out.title).toBe("t+1+2");
- expect(warn).toHaveBeenCalledTimes(2);
- expect(warn.mock.calls[0]![0]).toMatchObject({
- point: "name.recognize",
- hook: "m#2",
- err: { message: "插件坏了" },
- });
-
- // 拦截类抛错按放行处理
- const r2 = new HookRegistry(logger);
- r2.forModule("m").intercept("organize.intercept", () => {
- throw new Error("x");
- });
- expect(
- await interceptHook({ hooks: r2 }, "organize.intercept", {
- mediaEntityId: "e",
- mediaType: "movie",
- action: "copy",
- trigger: "auto",
- downloadTaskId: null,
- files: [],
- }),
- ).toBeNull();
- });
-
- it("超时:超过上限的钩子跳过(沿用上一个值),不等它跑完", async () => {
- const { logger, warn } = warnings();
- const reg = new HookRegistry(logger);
- const m = reg.forModule("slow");
- m.rewrite("organize.rename", () => new Promise((r) => setTimeout(() => r("迟到.mkv"), 5_000)), {
- timeoutMs: 30,
- });
- m.rewrite("organize.rename", (n) => `${n}.ok`);
- const t0 = Date.now();
- const out = await reg.runRewrite("organize.rename", "A.mkv", {
- targetPath: "/l/A.mkv",
- sourcePath: "/s/A.mkv",
- mediaEntityId: "e",
- mediaType: "movie",
- season: null,
- episodes: null,
- });
- expect(out).toBe("A.mkv.ok");
- expect(Date.now() - t0).toBeLessThan(2_000);
- expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 });
- });
-
- it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => {
- const ctxBuilder = vi.fn(() => recognizeCtx);
- const p = parsed("t");
- const empty = new HookRegistry();
- // 别的挂点有钩子不影响这里
- empty.forModule("m").intercept("organize.intercept", () => "x");
- expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false);
- expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p);
- expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p);
- expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull();
- expect(ctxBuilder).not.toHaveBeenCalled();
- });
-
- it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => {
- const { logger, warn } = warnings();
- const reg = new HookRegistry(logger);
- const m = reg.forModule("m");
- m.rewrite("organize.rename", (n) => `../${n}`);
- m.rewrite("organize.rename", (n) => `${n}.ok`);
- const c = {
- targetPath: "/a",
- sourcePath: "/b",
- mediaEntityId: "e",
- mediaType: "movie" as const,
- season: null,
- episodes: null,
- };
- expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok");
- expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" });
- });
-
- it("用错挂点类型注册直接报错", () => {
- const reg = new HookRegistry();
- // biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查
- const m = reg.forModule("m") as any;
- expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/);
- });
-});
diff --git a/apps/server/test/hooks/resource-selection.test.ts b/apps/server/test/hooks/resource-selection.test.ts
deleted file mode 100644
index f1429e52..00000000
--- a/apps/server/test/hooks/resource-selection.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import type { SourceReleaseRow } from "@umt/db";
-import { describe, expect, it } from "vitest";
-import { HookRegistry } from "../../src/hooks/index.ts";
-import { selectResources } from "../../src/modules/acquisition-organize/services/acquisition.ts";
-
-// 链式钩子 resource.selection(ADR-006,对照 MP ChainEventType.ResourceSelection):订阅重评时调整候选顺序 / 过滤,
-// 只能重排与删减,不能加入新候选。
-
-const rel = (id: string, title: string) => ({ id, title, parsed: null }) as unknown as SourceReleaseRow;
-const rels = [
- rel("r1", "Show.S01E01.1080p.WEB-DL"),
- rel("r2", "Show.S01E01.2160p.WEB-DL"),
- rel("r3", "Show.S01E01.720p"),
-];
-const context = { mediaEntityId: "e", subscriptionId: "s", slotId: null };
-const ctxOf = (hooks?: HookRegistry) => ({ d: { ctx: { hooks } } }) as never;
-
-describe("resource.selection 钩子", () => {
- it("没有钩子时原样返回同一个数组", async () => {
- expect(await selectResources(ctxOf(), rels, context)).toBe(rels);
- expect(await selectResources(ctxOf(new HookRegistry()), rels, context)).toBe(rels);
- });
-
- it("重排与过滤生效;不认识的 ID 忽略、重复的只取一次", async () => {
- const hooks = new HookRegistry();
- const seen: unknown[] = [];
- hooks.forModule("test").rewrite("resource.selection", (cands, c) => {
- seen.push({ ids: cands.map((x) => x.sourceReleaseId), c });
- const by = new Map(cands.map((x) => [x.sourceReleaseId, x]));
- // 2160p 优先、去掉 720p,夹带一个不存在的候选和一个重复
- return [by.get("r2")!, { ...by.get("r1")!, sourceReleaseId: "intruder" }, by.get("r1")!, by.get("r2")!];
- });
- const out = await selectResources(ctxOf(hooks), rels, context);
- expect(out.map((r) => r.id)).toEqual(["r2", "r1"]);
- expect(seen).toEqual([{ ids: ["r1", "r2", "r3"], c: context }]);
- });
-
- it("钩子出错时沿用原顺序", async () => {
- const hooks = new HookRegistry();
- hooks.forModule("test").rewrite("resource.selection", () => {
- throw new Error("坏了");
- });
- expect((await selectResources(ctxOf(hooks), rels, context)).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
- });
-});
diff --git a/apps/server/test/lifecycle/lifecycle-events.test.ts b/apps/server/test/lifecycle/lifecycle-events.test.ts
index 4d81ca09..b568d4e2 100644
--- a/apps/server/test/lifecycle/lifecycle-events.test.ts
+++ b/apps/server/test/lifecycle/lifecycle-events.test.ts
@@ -2,7 +2,6 @@ import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from
import { OutboxRelay } from "@umt/tasks";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectOutboxRoutes } from "../../src/context.ts";
-import { HookRegistry } from "../../src/hooks/index.ts";
import {
emitDownloadLifecycle,
emitScrapeFinished,
@@ -78,7 +77,7 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
]);
});
- it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
+ it("订阅完成:发已完成(按历史行去重),重复完成不重复发事件", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
const sub = await h.db
.insertInto("subscription")
@@ -86,26 +85,6 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
.returningAll()
.executeTakeFirstOrThrow();
- const hooks = new HookRegistry();
- const seen: unknown[] = [];
- hooks.forModule("test").intercept("subscription.completion.check", (c) => {
- seen.push(c);
- return "还要等导演剪辑版";
- });
- h.ctx.hooks = hooks;
- try {
- const done = await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"));
- expect(done).toBe(false);
- } finally {
- h.ctx.hooks = undefined;
- }
- expect(seen).toEqual([
- { subscriptionId: sub.id, mediaEntityId: e, mediaKind: "movie", reason: "slot_satisfied", progress: {} },
- ]);
- const still = await h.db.selectFrom("subscription").select("status").where("id", "=", sub.id).executeTakeFirst();
- expect(still?.status).toBe("active");
- expect((await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id)).toEqual([]);
-
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(true);
// 已完成的再判一次不会重复发
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(
diff --git a/apps/server/test/lifecycle/name-recognize-hook.test.ts b/apps/server/test/lifecycle/name-recognize-hook.test.ts
deleted file mode 100644
index a85c8f16..00000000
--- a/apps/server/test/lifecycle/name-recognize-hook.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import { parseRelease } from "@umt/domain";
-import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
-import { HookRegistry } from "../../src/hooks/index.ts";
-import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
-import { createHarness, type Harness, pgAvailable, seedSite } from "../sources-identity/harness.ts";
-
-// 链式钩子 name.recognize(ADR-006,对照 MP ChainEventType.NameRecognize):资源标题解析之后、召回之前改写解析结果。
-
-const up = await pgAvailable();
-const GARBLED = "Zhuo.Wu.Ming.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT";
-
-describe.skipIf(!up)("name.recognize 钩子改写解析结果", () => {
- let h: Harness;
- let tmdb: MockTmdb;
- let siteId: string;
- let n = 0;
- const release = async (title: string) =>
- h.db
- .insertInto("source_release")
- .values({ site_id: siteId, site_release_id: String(910000 + ++n), title })
- .returningAll()
- .executeTakeFirstOrThrow();
-
- beforeAll(async () => {
- tmdb = await createMockTmdb({ fixtures: TMDB_FIXTURES });
- h = await createHarness("name_hook", { tmdbUrl: tmdb.url });
- siteId = (await seedSite(h, { baseUrl: "https://hook.example/" })).site.id;
- await h.db
- .insertInto("settings")
- .values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
- .execute();
- });
- afterEach(() => {
- h.ctx.hooks = undefined;
- });
- afterAll(async () => {
- await h?.close();
- await tmdb?.close();
- });
-
- it("没有钩子:认不出的标题照常不自动识别", async () => {
- const r = await release(GARBLED);
- const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
- expect(res.outcome).not.toBe("auto");
- });
-
- it("钩子按站点别名改写解析结果后自动识别;上下文带原标题与来源资源", async () => {
- const seen: unknown[] = [];
- const hooks = new HookRegistry();
- hooks.forModule("test").rewrite("name.recognize", (p, c) => {
- seen.push({ title: c.title, sourceReleaseId: c.sourceReleaseId, trigger: c.trigger });
- return c.title.startsWith("Zhuo.Wu.Ming") ? parseRelease(c.title.replace("Zhuo.Wu.Ming", "Oppenheimer")) : p;
- });
- h.ctx.hooks = hooks;
- const r = await release(GARBLED);
- const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
- expect(res.outcome).toBe("auto");
- const e = await h.db
- .selectFrom("external_identity")
- .select("external_id")
- .where("media_entity_id", "=", res.mediaEntityId!)
- .where("provider", "=", "tmdb")
- .executeTakeFirstOrThrow();
- expect(e.external_id).toBe("872585");
- expect(seen).toEqual([{ title: GARBLED, sourceReleaseId: r.id, trigger: "observed" }]);
- });
-});
diff --git a/apps/server/test/lifecycle/organize-hooks.test.ts b/apps/server/test/lifecycle/organize-hooks.test.ts
deleted file mode 100644
index b2008dac..00000000
--- a/apps/server/test/lifecycle/organize-hooks.test.ts
+++ /dev/null
@@ -1,112 +0,0 @@
-import { existsSync } from "node:fs";
-import { createTempTree, type TempTree } from "@umt/testkit";
-import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
-import { HookRegistry } from "../../src/hooks/index.ts";
-import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
-import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
-
-// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
-// 钩子出错不影响整理。整理结束发 organization.finished。
-
-const hasPg = await pgAvailable();
-const SIZE = 200_000;
-const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
-const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
-const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
-
-describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
- let h: Harness;
- let lib: TempTree;
- let src: TempTree;
- let entity = "";
- const req = () => ({
- sourcePaths: [src.path(DIR)],
- mediaEntityId: entity,
- action: "copy" as const,
- trigger: "manual" as const,
- actorType: "user" as const,
- });
-
- beforeAll(async () => {
- lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
- src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
- h = await createHarness("organize_hooks");
- await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
- await h.setSettings("organize", { scrapeAfterImport: false });
- entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
- await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
- }, 60_000);
- afterEach(() => {
- h.ctx.hooks = undefined;
- });
- afterAll(async () => {
- await h?.close();
- for (const t of [lib, src]) await t?.cleanup();
- });
-
- it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
- const hooks = new HookRegistry();
- const seen: string[][] = [];
- hooks.forModule("test").intercept("organize.intercept", (c) => {
- seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
- return c.action === "copy" ? "演示:不许复制" : null;
- });
- h.ctx.hooks = hooks;
- const plan = await planImport(h.d, req());
- expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
- expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
- const rec = await h.db
- .selectFrom("organization_record")
- .select(["status", "error"])
- .where("id", "=", plan.recordId!)
- .executeTakeFirstOrThrow();
- expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
- const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
- expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
- });
-
- it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
- const hooks = new HookRegistry();
- const ctxs: unknown[] = [];
- hooks.forModule("test").rewrite("organize.rename", (name, c) => {
- ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
- return name.replace(/\.mkv$/, " [自定义].mkv");
- });
- // 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
- hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
- h.ctx.hooks = hooks;
- const plan = await planImport(h.d, req());
- expect(plan.status).toBe("running");
- const ex = await executeRecord(h.d, plan.recordId!);
- expect(ex.status).toBe("succeeded");
- expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
- const files = await h.db
- .selectFrom("organization_file_result")
- .select(["target_path", "status"])
- .where("organization_record_id", "=", plan.recordId!)
- .execute();
- const targets = files.map((f) => f.target_path ?? "").sort();
- expect(targets).toHaveLength(2);
- const video = targets.find((t) => t.endsWith(".mkv"))!;
- const sub = targets.find((t) => t.endsWith(".srt"))!;
- expect(video).toMatch(/ \[自定义\]\.mkv$/);
- const stem = video.slice(0, -".mkv".length);
- expect(sub.startsWith(stem)).toBe(true);
- expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
- const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
- expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
- });
-
- it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
- const hooks = new HookRegistry();
- hooks.forModule("broken").rewrite("organize.rename", () => {
- throw new Error("坏了");
- });
- hooks.forModule("broken").intercept("organize.intercept", () => new Promise(() => {}), { timeoutMs: 20 });
- h.ctx.hooks = hooks;
- const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
- expect(plan.status).toBe("queued");
- const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
- expect(main.dst).not.toContain("自定义");
- });
-});
diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts
index 66e52e0c..14a22b26 100644
--- a/apps/server/test/modules.test.ts
+++ b/apps/server/test/modules.test.ts
@@ -1,8 +1,8 @@
import { createRoute, z } from "@hono/zod-openapi";
-import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
-import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
+import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS, TaskKind } from "@umt/contracts";
+import { defineTask, TaskRegistry, UnknownTaskError } from "@umt/tasks";
import { describe, expect, it } from "vitest";
-import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
+import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
import { createApp } from "../src/http/app.ts";
import { requireScope } from "../src/http/auth.ts";
import { EventBus } from "../src/http/sse.ts";
@@ -12,6 +12,8 @@ import { DEV_KEY, json, memoryLogger } from "./helpers.ts";
// register* 阶段模块只保存 ctx 引用,这里不需要真实依赖
const fakeCtx = { role: "all" } as unknown as ServerContext;
+// agentRun 是业务记录类型,对应的执行任务叫 runAgent。
+const taskNames = TaskKind.options.map((kind) => (kind === "agentRun" ? "runAgent" : kind));
const pingRoute = createRoute({
method: "get",
@@ -85,22 +87,25 @@ describe("ServerModule 骨架", () => {
expect((await app.request("/api/v1/nope", { headers: { authorization: `Bearer ${DEV_KEY}` } })).status).toBe(404);
});
- it("任务注册表:模块任务优先,其余 §3.1-8 任务由空壳兜底;重复注册报错", async () => {
+ it("任务注册表只收录模块实现;未注册任务和重复注册明确报错", async () => {
const reg = buildTaskRegistry([...SERVER_MODULES, pingModule], fakeCtx);
- expect(reg.names()).toEqual(expect.arrayContaining([...TASK_NAMES, "testPing"]));
+ expect(reg.names()).toEqual(expect.arrayContaining([...taskNames, "testPing"]));
const ingest = reg.latest("testPing");
expect(await ingest.handler({ input: {} } as never)).toBe("real");
+ const partial = buildTaskRegistry([pingModule], fakeCtx);
+ expect(partial.names()).toEqual(["testPing"]);
+ expect(() => partial.latest("submitDownload")).toThrow(UnknownTaskError);
expect(() => buildTaskRegistry([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复注册/);
});
- it("生产装配:每个核心任务都由业务模块正式注册,不落到空壳兜底", () => {
+ it("生产装配:契约中的每个业务任务都有正式处理器", () => {
const owned = new Set();
for (const m of SERVER_MODULES) {
const own = new TaskRegistry();
m.registerTasks(own, fakeCtx);
for (const name of own.names()) owned.add(name);
}
- expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
+ expect(taskNames.filter((n) => !owned.has(n))).toEqual([]);
});
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
@@ -171,34 +176,6 @@ describe("ServerModule 骨架", () => {
expect(Object.keys(subtitles.outboxRoutes?.(fakeCtx) ?? {})).toEqual(subtitles.manifest?.subscribes);
});
- it("链式钩子按模块顺序注册;生产装配目前没有模块注册钩子(无注册零开销)", async () => {
- const order: string[] = [];
- const mod = (name: string): ServerModule => ({
- name,
- registerRoutes() {},
- registerTasks() {},
- registerHooks(hooks) {
- hooks.rewrite("organize.rename", (n) => {
- order.push(name);
- return `${n}.${name}`;
- });
- },
- });
- const hooks = buildHookRegistry([mod("first"), mod("second")], fakeCtx);
- expect(
- await hooks.runRewrite("organize.rename", "a", {
- targetPath: "/a",
- sourcePath: "/b",
- mediaEntityId: "e",
- mediaType: "movie",
- season: null,
- episodes: null,
- }),
- ).toBe("a.first.second");
- expect(order).toEqual(["first", "second"]);
- expect(buildHookRegistry(SERVER_MODULES, fakeCtx).list()).toEqual({});
- });
-
it("引擎就绪前调用给出明确错误", async () => {
const e = new DeferredTaskEngine();
expect(e.ready).toBe(false);
diff --git a/apps/server/test/organize-in-place-verify.test.ts b/apps/server/test/organize-in-place-verify.test.ts
index acd64836..a4b716a3 100644
--- a/apps/server/test/organize-in-place-verify.test.ts
+++ b/apps/server/test/organize-in-place-verify.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
// 不挂载 HTTP 路由;隔离 Deno 静态文件适配器,以便本单元测试在 Node 下执行。
-vi.mock("hono/deno", () => ({ serveStatic: vi.fn() }));
+vi.mock("hono/bun", () => ({ serveStatic: vi.fn() }));
import { verifyInPlace } from "../src/modules/acquisition-organize/services/organize.ts";
diff --git a/apps/server/test/preflight.test.ts b/apps/server/test/preflight.test.ts
index 13e3cfd5..6ae94475 100644
--- a/apps/server/test/preflight.test.ts
+++ b/apps/server/test/preflight.test.ts
@@ -62,7 +62,7 @@ describe("存储自检", () => {
mkdirSync(ok);
mkdirSync(ro);
chmodSync(ro, 0o555);
- const isRoot = Deno.uid() === 0;
+ const isRoot = process.getuid?.() === 0;
const { problems, warnings } = await checkStorage({
dataDir,
sessionDir: join(dataDir, "agent-sessions"),
@@ -82,7 +82,7 @@ describe("存储自检", () => {
});
it("数据目录不可写 → problem", async () => {
- if (Deno.uid() === 0) return; // root 无视权限位
+ if (process.getuid?.() === 0) return; // root 无视权限位
const locked = join(dir, "locked");
mkdirSync(locked);
chmodSync(locked, 0o555);
diff --git a/apps/server/test/sandbox/sandbox.test.ts b/apps/server/test/sandbox/sandbox.test.ts
index 370845d5..e65c3548 100644
--- a/apps/server/test/sandbox/sandbox.test.ts
+++ b/apps/server/test/sandbox/sandbox.test.ts
@@ -16,13 +16,34 @@ describe("沙箱子进程", () => {
it("正常返回;子进程里读文件被拒绝", async () => {
expect(await runSandbox("self.test", { mode: "ok" })).toEqual({ ok: true });
- await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({ kind: "job", message: /NotCapable/ });
+ await expect(runSandbox("self.test", { mode: "io" })).rejects.toMatchObject({
+ kind: "job",
+ message: /EPERM|operation not permitted/i,
+ });
+ });
+
+ it("不继承父进程密钥,也不自动读取 dotenv", async () => {
+ process.env.UMT_SANDBOX_TEST_SECRET = "private";
+ try {
+ const result = await runSandbox("self.test", { mode: "env" });
+ expect(result).toEqual({ keys: ["PATH"] });
+ } finally {
+ delete process.env.UMT_SANDBOX_TEST_SECRET;
+ }
+ });
+
+ it.each(["async-io", "write", "net", "exec"] as const)("内核拒绝 %s,包括运行时工作线程", async (mode) => {
+ await expect(runSandbox("self.test", { mode })).rejects.toMatchObject({
+ kind: "job",
+ message: /EPERM|operation not permitted/i,
+ });
});
it("卡死:超时后强杀,不拖住调用方", async () => {
const t0 = Date.now();
const e = await runSandbox("self.test", { mode: "hang" }, { timeoutMs: 800 }).catch((x) => x);
expect(e).toBeInstanceOf(SandboxError);
+ if (!(e instanceof SandboxError)) throw e;
expect(e.kind).toBe("timeout");
expect(Date.now() - t0).toBeLessThan(5000);
});
@@ -30,6 +51,7 @@ describe("沙箱子进程", () => {
it("吃内存:超过上限强杀", async () => {
const e = await runSandbox("self.test", { mode: "memory" }, { memoryMb: 128, timeoutMs: 20_000 }).catch((x) => x);
expect(e).toBeInstanceOf(SandboxError);
+ if (!(e instanceof SandboxError)) throw e;
expect(e.kind).toBe("memory");
});
diff --git a/apps/server/test/scrape/gen-scrape-layout-mp.py b/apps/server/test/scrape/gen-scrape-layout-mp.py
index 39b8bc6c..d99a1101 100644
--- a/apps/server/test/scrape/gen-scrape-layout-mp.py
+++ b/apps/server/test/scrape/gen-scrape-layout-mp.py
@@ -141,4 +141,4 @@ for name, (target, kind) in SCENARIOS.items():
(HERE / "scrape-layout-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps(out, ensure_ascii=False, indent=2))
-# 生成后在本仓库跑一次格式化(lint 要求):deno run -A npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
+# 生成后在本仓库跑一次格式化(lint 要求):bun npm:@biomejs/biome check --write apps/server/test/scrape/scrape-layout-mp.json
diff --git a/apps/server/test/search/discover.test.ts b/apps/server/test/search/discover.test.ts
index cdbaad60..68fe5754 100644
--- a/apps/server/test/search/discover.test.ts
+++ b/apps/server/test/search/discover.test.ts
@@ -78,7 +78,7 @@ describe.skipIf(!up)("发现页接口", () => {
settings: plainSettings(() => db),
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
- const metaFetch: typeof fetch = async (input, init) => {
+ const metaFetch: (input: RequestInfo | URL, init?: RequestInit) => Promise = async (input, init) => {
const u = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
meta.push(`${init?.method ?? "GET"} ${u.host}${u.pathname}${u.search}`);
if (u.pathname.startsWith("/rexxar/api/v2/subject_collection/")) return doubanReply();
diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts
index d8371e38..4c28bf4e 100644
--- a/apps/server/test/search/search.test.ts
+++ b/apps/server/test/search/search.test.ts
@@ -153,7 +153,7 @@ describe.skipIf(!up)("全局搜索", () => {
capabilities: createCapabilityRegistry({ logger }),
} as ServerContext;
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
- const fetchImpl: typeof fetch = (input, init) => {
+ const fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise = (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input));
if (url.hostname !== SLOW_HOST) return fetch(input, init);
slowCalls++;
diff --git a/apps/server/test/sources-identity/backfill-languages.test.ts b/apps/server/test/sources-identity/backfill-languages.test.ts
index 764190dd..0d3e893a 100644
--- a/apps/server/test/sources-identity/backfill-languages.test.ts
+++ b/apps/server/test/sources-identity/backfill-languages.test.ts
@@ -99,8 +99,8 @@ describe.skipIf(!up)("元数据语言回刷", () => {
.values({ key: "metadata", value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) } as never)
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ tmdbBaseUrl: `${tmdb.url}/3` }) }))
.execute();
- const prev = Deno.env.get("TMDB_API_KEY");
- Deno.env.set("TMDB_API_KEY", "test-tmdb-key");
+ const prev = process.env.TMDB_API_KEY;
+ process.env.TMDB_API_KEY = "test-tmdb-key";
try {
const rt = new SourcesRuntime(h.ctx, {});
const t0 = performance.now();
@@ -112,8 +112,8 @@ describe.skipIf(!up)("元数据语言回刷", () => {
const second = await backfillLanguages(rt, { minIntervalMs: 100 });
expect(second).toMatchObject({ refreshed: 2, requests: 0 });
} finally {
- if (prev === undefined) Deno.env.delete("TMDB_API_KEY");
- else Deno.env.set("TMDB_API_KEY", prev);
+ if (prev === undefined) delete process.env.TMDB_API_KEY;
+ else process.env.TMDB_API_KEY = prev;
}
});
diff --git a/apps/server/test/sources-identity/cloudflare.test.ts b/apps/server/test/sources-identity/cloudflare.test.ts
index a2efc7a8..d205b17f 100644
--- a/apps/server/test/sources-identity/cloudflare.test.ts
+++ b/apps/server/test/sources-identity/cloudflare.test.ts
@@ -118,7 +118,7 @@ describe.skipIf(!up)("CF-2 Cloudflare 级联", () => {
}
cf.passed++;
return globalThis.fetch(input, init);
- }) as typeof fetch;
+ }) as (input: RequestInfo | URL, init?: RequestInit) => Promise;
const site = () => h.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirstOrThrow();
const account = () =>
diff --git a/apps/server/test/sources-identity/data-hygiene.test.ts b/apps/server/test/sources-identity/data-hygiene.test.ts
index c607067d..037f7ea2 100644
--- a/apps/server/test/sources-identity/data-hygiene.test.ts
+++ b/apps/server/test/sources-identity/data-hygiene.test.ts
@@ -285,7 +285,7 @@ describe.skipIf(!up)("DH-1 内容单元数据治理", () => {
const u = new URL(PG_URL);
u.searchParams.set("options", `-c search_path=${schema},public`);
const run = (...args: string[]) =>
- execFileSync(Deno.execPath(), ["run", "-A", SCRIPT, "--merge-duplicates", ...args], {
+ execFileSync(process.execPath, [SCRIPT, "--merge-duplicates", ...args], {
cwd: ROOT,
env: { ...process.env, DATABASE_URL: u.toString(), NO_COLOR: "1" },
encoding: "utf8",
diff --git a/apps/server/test/sources-identity/egress.test.ts b/apps/server/test/sources-identity/egress.test.ts
index 411623a7..cf15a053 100644
--- a/apps/server/test/sources-identity/egress.test.ts
+++ b/apps/server/test/sources-identity/egress.test.ts
@@ -1,4 +1,5 @@
import { DEFAULT_SETTINGS } from "@umt/contracts";
+import { type HttpServer, serve } from "@umt/runtime";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
directHttpClient,
@@ -27,66 +28,58 @@ const PROXY_KEYS = [
];
describe("出口选择(直连 / 代理)", () => {
- let proxy: Deno.HttpServer;
- let target: Deno.HttpServer