merge: feat/z-lifecycle
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
commit
86d3254db1
55 files changed
+2720
-79
No files matched your search
@@ -10,6 +10,7 @@ src/
|
|||||||
context.ts bootstrap():ServerContext、任务注册表(模块 + 空壳兜底)、outbox 路由合并、连接引擎
|
context.ts bootstrap():ServerContext、任务注册表(模块 + 空壳兜底)、outbox 路由合并、连接引擎
|
||||||
http/app.ts createApp():系统路由 + 逐个挂 SERVER_MODULES 的 registerRoutes
|
http/app.ts createApp():系统路由 + 逐个挂 SERVER_MODULES 的 registerRoutes
|
||||||
worker/index.ts startWorker():引擎 worker + OutboxRelay + 各模块 start()
|
worker/index.ts startWorker():引擎 worker + OutboxRelay + 各模块 start()
|
||||||
|
hooks/ 链式钩子注册表与调用入口(ADR-006)
|
||||||
modules/
|
modules/
|
||||||
types.ts ServerModule / ServerContext / OutboxWriter / SecretStore / SettingsReader(共享,不随子任务改)
|
types.ts ServerModule / ServerContext / OutboxWriter / SecretStore / SettingsReader(共享,不随子任务改)
|
||||||
index.ts SERVER_MODULES 清单
|
index.ts SERVER_MODULES 清单
|
||||||
@@ -64,3 +65,31 @@ export const sourcesIdentityModule: ServerModule = {
|
|||||||
`ServerContext` 字段:`db`(Kysely<DB>)、`config`、`logger`(pino,自动脱敏)、`events`(SSE 总线 `EventBusPort`)、`engine`(TaskEngine:enqueue/signal/cancel/get/list;register* 阶段不要调用)、`outbox`(`emit(trx, type, payload, {idempotencyKey})` + worker 的 `relay`)、`secrets`(SecretStore,W2-A 实现前调用会抛错)、`settings`(读 settings 表并合并缺省)、`capabilities`(CapabilityRegistry)。domain ↔ contracts 的取值转换一律用 `@umt/mapping`。
|
`ServerContext` 字段:`db`(Kysely<DB>)、`config`、`logger`(pino,自动脱敏)、`events`(SSE 总线 `EventBusPort`)、`engine`(TaskEngine:enqueue/signal/cancel/get/list;register* 阶段不要调用)、`outbox`(`emit(trx, type, payload, {idempotencyKey})` + worker 的 `relay`)、`secrets`(SecretStore,W2-A 实现前调用会抛错)、`settings`(读 settings 表并合并缺省)、`capabilities`(CapabilityRegistry)。domain ↔ contracts 的取值转换一律用 `@umt/mapping`。
|
||||||
|
|
||||||
角色:`all` 单进程时连接引擎会顺带跑引擎迁移;`api` / `worker` 分进程部署时由 `--role migrate` 预先迁移(db + openworkflow),两个进程都以 `runMigrations:false` 连接。
|
角色:`all` 单进程时连接引擎会顺带跑引擎迁移;`api` / `worker` 分进程部署时由 `--role migrate` 预先迁移(db + openworkflow),两个进程都以 `runMigrations:false` 连接。
|
||||||
|
|
||||||
|
## 如何写一个功能模块(ADR-006)
|
||||||
|
|
||||||
|
外围功能(补字幕这类)按下面的约定接进来,不做插件系统、不做开关。样板是 `modules/subtitles/`。
|
||||||
|
|
||||||
|
1. **登记 manifest**:在 `packages/contracts/src/features.ts` 的 `FEATURES` 里加一份(名称、说明、怎么用、订阅的事件、能力、页面入口、插槽、钩子)。它会进智能体系统提示「能力地图」末尾的功能清单;`test/modules.test.ts` 核对模块与清单一一对应。
|
||||||
|
2. **建模块**:`modules/<id>/index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("<id>")`,加进 `modules/index.ts`。
|
||||||
|
3. **订阅广播事件**(事后通知,异步、至少一次):`outboxRoutes` 里写 `"<事件>": (ev) => 任务 | 任务数组 | null`。生命周期事件(`LIFECYCLE_EVENTS`)可以多个模块同时订阅;payload 用 `parseEventPayload` 取,按 ID 回查业务表;订阅任务要幂等(同一事件可能投递多次)。
|
||||||
|
```ts
|
||||||
|
outboxRoutes: (ctx) => ({
|
||||||
|
"organization.finished": (ev) => {
|
||||||
|
const p = parseEventPayload("organization.finished", ev.payload);
|
||||||
|
return p?.outcome === "succeeded" ? { task: "myFollowUp", input: { recordId: p.recordId } } : null;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
```
|
||||||
|
4. **注册链式钩子**(同步改写 / 拦截,挂点见 `packages/contracts/src/hooks.ts`):
|
||||||
|
```ts
|
||||||
|
registerHooks(hooks) {
|
||||||
|
hooks.rewrite("organize.rename", (name, c) => (c.mediaType === "movie" ? name.replace(/ - 1080p/, "") : undefined));
|
||||||
|
hooks.intercept("organize.intercept", (c) => (c.files.some((f) => f.dst.includes("/Sample/")) ? "目标在 Sample 目录" : null));
|
||||||
|
},
|
||||||
|
```
|
||||||
|
钩子可能在业务事务里执行:只做内存里的判断,不发外部请求、不写库;缺省 2 秒超时,抛错 / 超时只跳过自己。
|
||||||
|
5. **自己发事件**:业务节点在事务里 `ctx.outbox.emit(trx, "<事件>", payload, { idempotencyKey: lifecycleKey("<事件>", id) })`;新事件先在 `contracts/events.ts` 加类型、payload schema,并登记进 `LIFECYCLE_EVENTS`。
|
||||||
|
6. **前端入口**:在 `apps/web/app/features/<id>.tsx` 用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在 `features/index.ts` 调一次;默认直接显示。
|
||||||
|
7. **智能体能力**(可选):照常在 contracts 定义能力、在 `registerCapabilities` 挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。
|
||||||
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { type EventType, parseEventPayload } from "@umt/contracts";
|
import { type EventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
|
||||||
import { type DB, type Kysely, sql } from "@umt/db";
|
import { type DB, type Kysely, sql } from "@umt/db";
|
||||||
import {
|
import {
|
||||||
createDefaultRegistry,
|
createDefaultRegistry,
|
||||||
type LogSink,
|
type LogSink,
|
||||||
OpenWorkflowEngine,
|
OpenWorkflowEngine,
|
||||||
type OutboxRelay,
|
type OutboxRelay,
|
||||||
|
type OutboxRoute,
|
||||||
type OutboxRouteFn,
|
type OutboxRouteFn,
|
||||||
type TaskEngine,
|
type TaskEngine,
|
||||||
TaskRegistry,
|
TaskRegistry,
|
||||||
} from "@umt/tasks";
|
} from "@umt/tasks";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { Config } from "./config.ts";
|
import type { Config } from "./config.ts";
|
||||||
|
import { HookRegistry } from "./hooks/registry.ts";
|
||||||
import type { EventBusPort } from "./http/sse.ts";
|
import type { EventBusPort } from "./http/sse.ts";
|
||||||
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
|
import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts";
|
||||||
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
|
import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts";
|
||||||
@@ -97,24 +99,66 @@ export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerC
|
|||||||
return registry;
|
return registry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 合并各模块的 outbox 路由;同一事件 type 只能由一个模块认领 */
|
const BROADCAST_TYPES = new Set<string>(LIFECYCLE_EVENTS.map((e) => e.type));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并各模块的 outbox 路由。生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)可以有多个订阅方,按模块顺序合成一个路由:
|
||||||
|
* 各订阅方各自返回任务(或 null),合起来交给中继逐个入队;某个订阅方的路由函数抛错只记日志,不连累别人。
|
||||||
|
* 其它事件(内部流程,如 release.observed → resolveObservation)仍只能由一个模块认领。
|
||||||
|
*/
|
||||||
export function collectOutboxRoutes(
|
export function collectOutboxRoutes(
|
||||||
modules: readonly ServerModule[],
|
modules: readonly ServerModule[],
|
||||||
ctx: ServerContext,
|
ctx: ServerContext,
|
||||||
): Record<string, OutboxRouteFn> {
|
): Record<string, OutboxRouteFn> {
|
||||||
const routes: Record<string, OutboxRouteFn> = {};
|
const subscribers = new Map<string, { module: string; fn: OutboxRouteFn }[]>();
|
||||||
const owner = new Map<string, string>();
|
|
||||||
for (const m of modules) {
|
for (const m of modules) {
|
||||||
for (const [type, fn] of Object.entries(m.outboxRoutes?.(ctx) ?? {})) {
|
for (const [type, fn] of Object.entries(m.outboxRoutes?.(ctx) ?? {})) {
|
||||||
const prev = owner.get(type);
|
const prev = subscribers.get(type) ?? [];
|
||||||
if (prev) throw new Error(`outbox 事件 ${type} 被模块 ${prev} 与 ${m.name} 重复认领`);
|
if (prev.length && !BROADCAST_TYPES.has(type))
|
||||||
owner.set(type, m.name);
|
throw new Error(`outbox 事件 ${type} 被模块 ${prev[0]!.module} 与 ${m.name} 重复认领`);
|
||||||
routes[type] = fn;
|
subscribers.set(type, [...prev, { module: m.name, fn }]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const routes: Record<string, OutboxRouteFn> = {};
|
||||||
|
for (const [type, subs] of subscribers) {
|
||||||
|
if (subs.length === 1) {
|
||||||
|
routes[type] = subs[0]!.fn;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
routes[type] = (ev) => {
|
||||||
|
const out: OutboxRoute[] = [];
|
||||||
|
for (const s of subs) {
|
||||||
|
let r: ReturnType<OutboxRouteFn>;
|
||||||
|
try {
|
||||||
|
r = s.fn(ev);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logger.warn(
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
module: s.module,
|
||||||
|
eventId: ev.id,
|
||||||
|
err: { message: err instanceof Error ? err.message : String(err) },
|
||||||
|
},
|
||||||
|
"outbox 订阅方路由出错,已跳过",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (r === null) continue;
|
||||||
|
out.push(...((Array.isArray(r) ? r : [r]) as OutboxRoute[]));
|
||||||
|
}
|
||||||
|
return out.length ? out : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 链式钩子注册表:按模块顺序调 registerHooks,执行顺序即注册顺序(ADR-006) */
|
||||||
|
export function buildHookRegistry(modules: readonly ServerModule[], ctx: ServerContext): HookRegistry {
|
||||||
|
const hooks = new HookRegistry(ctx.logger?.child({ component: "hooks" }));
|
||||||
|
for (const m of modules) m.registerHooks?.(hooks.forModule(m.name), ctx);
|
||||||
|
return hooks;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BootstrapOptions {
|
export interface BootstrapOptions {
|
||||||
role: ServerRole;
|
role: ServerRole;
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -158,6 +202,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise<Bootstrapped> {
|
|||||||
};
|
};
|
||||||
const tasks = buildTaskRegistry(opts.modules, ctx);
|
const tasks = buildTaskRegistry(opts.modules, ctx);
|
||||||
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
|
for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx);
|
||||||
|
ctx.hooks = buildHookRegistry(opts.modules, ctx);
|
||||||
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
|
// 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器
|
||||||
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
|
if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities);
|
||||||
engine.attach(
|
engine.attach(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { HookPoint } from "@umt/contracts";
|
||||||
|
import type { ServerContext } from "../modules/types.ts";
|
||||||
|
import type { HookContext, HookValue, InterceptPoint, RewritePoint } from "./points.ts";
|
||||||
|
import type { HookRejection } from "./registry.ts";
|
||||||
|
|
||||||
|
export type * from "./points.ts";
|
||||||
|
export {
|
||||||
|
DEFAULT_HOOK_TIMEOUT_MS,
|
||||||
|
type HookOptions,
|
||||||
|
type HookRegistrar,
|
||||||
|
HookRegistry,
|
||||||
|
type HookRejection,
|
||||||
|
} from "./registry.ts";
|
||||||
|
|
||||||
|
// 业务节点调用钩子的入口。ctx.hooks 由 bootstrap 装配;测试里手搭的上下文没有它,等同于没有注册。
|
||||||
|
|
||||||
|
/** 这个挂点有没有钩子(上下文构造代价大时先问一下) */
|
||||||
|
export function hasHook(ctx: Pick<ServerContext, "hooks">, point: HookPoint): boolean {
|
||||||
|
return ctx.hooks?.has(point) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */
|
||||||
|
export async function rewriteHook<P extends RewritePoint>(
|
||||||
|
ctx: Pick<ServerContext, "hooks">,
|
||||||
|
point: P,
|
||||||
|
value: HookValue<P>,
|
||||||
|
context: HookContext<P> | (() => HookContext<P>),
|
||||||
|
accept?: (v: HookValue<P>) => boolean,
|
||||||
|
): Promise<HookValue<P>> {
|
||||||
|
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<P extends InterceptPoint>(
|
||||||
|
ctx: Pick<ServerContext, "hooks">,
|
||||||
|
point: P,
|
||||||
|
context: HookContext<P> | (() => HookContext<P>),
|
||||||
|
): Promise<HookRejection | null> {
|
||||||
|
const h = ctx.hooks;
|
||||||
|
if (!h?.has(point)) return null;
|
||||||
|
return h.runIntercept(point, typeof context === "function" ? context() : context);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
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<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindOf<P extends HookPoint> = (typeof HOOK_POINTS)[P]["kind"];
|
||||||
|
export type RewritePoint = { [P in HookPoint]: KindOf<P> extends "rewrite" ? P : never }[HookPoint];
|
||||||
|
export type InterceptPoint = { [P in HookPoint]: KindOf<P> extends "intercept" ? P : never }[HookPoint];
|
||||||
|
|
||||||
|
export type HookValue<P extends HookPoint> = HookPointTypes[P]["value"];
|
||||||
|
export type HookContext<P extends HookPoint> = Readonly<HookPointTypes[P]["context"]>;
|
||||||
|
|
||||||
|
type MaybePromise<T> = T | Promise<T>;
|
||||||
|
/** 返回新值;返回 undefined 表示不改 */
|
||||||
|
export type RewriteHook<P extends RewritePoint> = (
|
||||||
|
value: HookValue<P>,
|
||||||
|
context: HookContext<P>,
|
||||||
|
) => MaybePromise<HookValue<P> | undefined>;
|
||||||
|
/** 返回拒绝原因;返回 null / undefined 表示放行 */
|
||||||
|
export type InterceptHook<P extends InterceptPoint> = (
|
||||||
|
context: HookContext<P>,
|
||||||
|
) => MaybePromise<string | null | undefined>;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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<P extends RewritePoint>(point: P, fn: RewriteHook<P>, opts?: HookOptions): void;
|
||||||
|
intercept<P extends InterceptPoint>(point: P, fn: InterceptHook<P>, 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<HookPoint, Entry[]>();
|
||||||
|
|
||||||
|
constructor(private readonly logger?: Pick<Logger, "warn">) {}
|
||||||
|
|
||||||
|
/** 给某个模块的注册入口(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<string, string[]> {
|
||||||
|
return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */
|
||||||
|
async runRewrite<P extends RewritePoint>(
|
||||||
|
point: P,
|
||||||
|
value: HookValue<P>,
|
||||||
|
context: HookContext<P>,
|
||||||
|
accept?: (v: HookValue<P>) => boolean,
|
||||||
|
): Promise<HookValue<P>> {
|
||||||
|
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<P>)) {
|
||||||
|
this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cur = r as HookValue<P>;
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */
|
||||||
|
async runIntercept<P extends InterceptPoint>(point: P, context: HookContext<P>): Promise<HookRejection | null> {
|
||||||
|
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<unknown> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const r = await Promise.race([
|
||||||
|
Promise.resolve().then(() => e.fn(...args)),
|
||||||
|
new Promise<typeof TIMEOUT>((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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db";
|
|||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import type { AuthEnv } from "../../../http/auth.ts";
|
import type { AuthEnv } from "../../../http/auth.ts";
|
||||||
import { ApiException } from "../../../http/errors.ts";
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
|
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
|
||||||
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
||||||
import type { ModuleApp } from "../../types.ts";
|
import type { ModuleApp } from "../../types.ts";
|
||||||
import { ensureSubscriptionSlot } from "../services/acquisition.ts";
|
import { ensureSubscriptionSlot } from "../services/acquisition.ts";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
slotsOfSub,
|
slotsOfSub,
|
||||||
subscriptionCoverage,
|
subscriptionCoverage,
|
||||||
} from "../services/coverage.ts";
|
} from "../services/coverage.ts";
|
||||||
|
import { emitSubscriptionEvent } from "../services/lifecycle-events.ts";
|
||||||
import { loadRuleCatalog } from "../services/rules.ts";
|
import { loadRuleCatalog } from "../services/rules.ts";
|
||||||
import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts";
|
import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts";
|
||||||
import { resetSubscription } from "../services/subscription-reset.ts";
|
import { resetSubscription } from "../services/subscription-reset.ts";
|
||||||
@@ -376,6 +378,9 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
|
// 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入)
|
||||||
await ensureSubscriptionSlot(trx, r);
|
await ensureSubscriptionSlot(trx, r);
|
||||||
await applyDerived(trx, r, norm, now);
|
await applyDerived(trx, r, norm, now);
|
||||||
|
// 生命周期广播(ADR-006):订阅已建;关联作品 = 作品进入关注
|
||||||
|
await emitSubscriptionEvent(d, trx, "subscription.created", r);
|
||||||
|
if (r.media_entity_id) await emitMediaWatched(d.ctx, trx, r.media_entity_id, "subscription", r.id);
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
if (row.media_entity_id) {
|
if (row.media_entity_id) {
|
||||||
@@ -436,6 +441,7 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
|
if (r.status !== "cancelled") await ensureSubscriptionSlot(trx, r);
|
||||||
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
|
if (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur));
|
||||||
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
|
else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now());
|
||||||
|
await emitSubscriptionChange(trx, cur, r, Object.keys(body));
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
invalidateCoverage(r.id);
|
invalidateCoverage(r.id);
|
||||||
@@ -446,6 +452,21 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
{ tag: TAG },
|
{ tag: TAG },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 生命周期广播(ADR-006):改成 cancelled 发「已取消」,其余改动发「已改」 */
|
||||||
|
const emitSubscriptionChange = async (
|
||||||
|
trx: Exec,
|
||||||
|
before: SubscriptionRow,
|
||||||
|
after: SubscriptionRow,
|
||||||
|
changes: string[],
|
||||||
|
reason?: string,
|
||||||
|
) => {
|
||||||
|
if (after.status === "cancelled" && before.status !== "cancelled")
|
||||||
|
await emitSubscriptionEvent(d, trx, "subscription.cancelled", after, {
|
||||||
|
reason: reason ?? after.status_reason ?? "",
|
||||||
|
});
|
||||||
|
else if (changes.length) await emitSubscriptionEvent(d, trx, "subscription.updated", after, { changes });
|
||||||
|
};
|
||||||
|
|
||||||
// ── 状态动作 ──
|
// ── 状态动作 ──
|
||||||
const setStatus = async (
|
const setStatus = async (
|
||||||
sub: SubscriptionRow,
|
sub: SubscriptionRow,
|
||||||
@@ -456,16 +477,22 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
const now = d.ports.now();
|
const now = d.ports.now();
|
||||||
const searchOn = (subParams(sub).searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0;
|
const searchOn = (subParams(sub).searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0;
|
||||||
const r = await db()
|
const r = await db()
|
||||||
.updateTable("subscription")
|
.transaction()
|
||||||
.set({
|
.execute(async (trx) => {
|
||||||
status: to,
|
const r = await trx
|
||||||
status_reason: reason,
|
.updateTable("subscription")
|
||||||
...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}),
|
.set({
|
||||||
...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}),
|
status: to,
|
||||||
})
|
status_reason: reason,
|
||||||
.where("id", "=", sub.id)
|
...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}),
|
||||||
.returningAll()
|
...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}),
|
||||||
.executeTakeFirstOrThrow();
|
})
|
||||||
|
.where("id", "=", sub.id)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitSubscriptionChange(trx, sub, r, ["status"], reason);
|
||||||
|
return r;
|
||||||
|
});
|
||||||
// 私有规则组随订阅删除(取消),重新订阅时按 params 重建
|
// 私有规则组随订阅删除(取消),重新订阅时按 params 重建
|
||||||
if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now);
|
if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now);
|
||||||
invalidateCoverage(sub.id);
|
invalidateCoverage(sub.id);
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { AcquisitionPolicyRules, AcquisitionPriority, CandidateEvaluation, SlotTemplate } from "@umt/contracts";
|
import {
|
||||||
|
type AcquisitionPolicyRules,
|
||||||
|
type AcquisitionPriority,
|
||||||
|
type CandidateEvaluation,
|
||||||
|
lifecycleKey,
|
||||||
|
type SlotTemplate,
|
||||||
|
} from "@umt/contracts";
|
||||||
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
import type { AcquisitionSlotRow, MediaEntityRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +29,7 @@ import {
|
|||||||
unitsOverlap,
|
unitsOverlap,
|
||||||
} from "@umt/domain";
|
} from "@umt/domain";
|
||||||
import { toContractSlotEvaluation } from "@umt/mapping";
|
import { toContractSlotEvaluation } from "@umt/mapping";
|
||||||
|
import { hasHook, rewriteHook } from "../../../hooks/index.ts";
|
||||||
import { hashJson, jsonb, startOfDay } from "../util.ts";
|
import { hashJson, jsonb, startOfDay } from "../util.ts";
|
||||||
import {
|
import {
|
||||||
type AoDeps,
|
type AoDeps,
|
||||||
@@ -38,6 +45,7 @@ import {
|
|||||||
recordCandidate,
|
recordCandidate,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
import { autoFillCheck, type ForceFill } from "./evaluate-autofill.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 { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||||
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
|
import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts";
|
||||||
import {
|
import {
|
||||||
@@ -430,7 +438,12 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise<EvaluateOutcome
|
|||||||
focus,
|
focus,
|
||||||
rels.filter((x) => !seen.has(x.id)),
|
rels.filter((x) => !seen.has(x.id)),
|
||||||
);
|
);
|
||||||
const ordered = await rankReleases(c, entity, sub, focus[0], [...deferred, ...fresh]);
|
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 } : {};
|
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 ordered) await evaluateRelease(c, r, [], scope);
|
||||||
return c.out;
|
return c.out;
|
||||||
@@ -500,6 +513,33 @@ async function rankReleases(
|
|||||||
})).map((x) => byParsed.get(x.candidate)!);
|
})).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<SourceReleaseRow[]> {
|
||||||
|
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 {
|
interface EvalScope {
|
||||||
slotId?: string | undefined;
|
slotId?: string | undefined;
|
||||||
@@ -600,8 +640,9 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
|||||||
policy_revision_id: pol.row.id,
|
policy_revision_id: pol.row.id,
|
||||||
created_by: `policy:${pol.row.policy_key}`,
|
created_by: `policy:${pol.row.policy_key}`,
|
||||||
})
|
})
|
||||||
.returning("id")
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitSubscriptionEvent(c.d, trx, "subscription.created", sub);
|
||||||
await createSlotsFromTemplates(trx, {
|
await createSlotsFromTemplates(trx, {
|
||||||
entityId: entity.id,
|
entityId: entity.id,
|
||||||
subscriptionId: sub.id,
|
subscriptionId: sub.id,
|
||||||
@@ -634,6 +675,10 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon
|
|||||||
.where("id", "=", s.id)
|
.where("id", "=", s.id)
|
||||||
.execute();
|
.execute();
|
||||||
await clearSubscriptionDeferrals(db, s.id);
|
await clearSubscriptionDeferrals(db, s.id);
|
||||||
|
await emitSubscriptionEvent(c.d, db, "subscription.completed", s, {
|
||||||
|
reason: "stop_after_reached",
|
||||||
|
key: lifecycleKey("subscription.completed", s.id, `stop_after:${stopAfter}`),
|
||||||
|
});
|
||||||
s.status = "completed";
|
s.status = "completed";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
|
import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping";
|
||||||
import { withAttemptToken } from "@umt/tasks";
|
import { withAttemptToken } from "@umt/tasks";
|
||||||
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
|
import { createEntityEpisodeMapper, publishedAtOf } from "../../sources-identity/service/episode-resolution.ts";
|
||||||
|
import { emitMediaWatched } from "../../sources-identity/service/lifecycle-events.ts";
|
||||||
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
import { enqueueWatchedRefresh } from "../../sources-identity/service/watched.ts";
|
||||||
import { errMessage, jsonb } from "../util.ts";
|
import { errMessage, jsonb } from "../util.ts";
|
||||||
import { parsedOf } from "./acquisition.ts";
|
import { parsedOf } from "./acquisition.ts";
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
timeSlot,
|
timeSlot,
|
||||||
upsertDecision,
|
upsertDecision,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
|
import { emitDownloadLifecycle } from "./lifecycle-events.ts";
|
||||||
import { matchesQbSource, qbSourceTags } from "./organize-config.ts";
|
import { matchesQbSource, qbSourceTags } from "./organize-config.ts";
|
||||||
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
|
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
|
||||||
import {
|
import {
|
||||||
@@ -212,6 +214,8 @@ async function emitStatus(
|
|||||||
? { idempotencyKey: generation > 0 ? `download.completed:${id}:g${generation}` : `download.completed:${id}` }
|
? { idempotencyKey: generation > 0 ? `download.completed:${id}:g${generation}` : `download.completed:${id}` }
|
||||||
: {},
|
: {},
|
||||||
);
|
);
|
||||||
|
// 生命周期广播(ADR-006):下载已完成 / 失败 / 已移除
|
||||||
|
await emitDownloadLifecycle(d, exec, id, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 提交 ──
|
// ── 提交 ──
|
||||||
@@ -269,7 +273,10 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
// 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts)
|
// 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts)
|
||||||
const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id;
|
const watchedEntity = ev.mediaEntityId ?? rel.media_entity_id;
|
||||||
if (watchedEntity) await enqueueWatchedRefresh(d.ctx, watchedEntity, "download");
|
if (watchedEntity) {
|
||||||
|
await enqueueWatchedRefresh(d.ctx, watchedEntity, "download");
|
||||||
|
await emitMediaWatched(d.ctx, db, watchedEntity, "download", plan.id);
|
||||||
|
}
|
||||||
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
const site = await db.selectFrom("site").selectAll().where("id", "=", rel.site_id).executeTakeFirst();
|
||||||
const client = await d.ports.downloader(topo.inst);
|
const client = await d.ports.downloader(topo.inst);
|
||||||
const tag = topo.inst.managed_tag;
|
const tag = topo.inst.managed_tag;
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { type DownloadStatus, lifecycleKey, type OrganizationStatus } from "@umt/contracts";
|
||||||
|
import type { ScrapeRecordRow, SubscriptionRow } from "@umt/db";
|
||||||
|
import type { AoDeps, Exec } from "./common.ts";
|
||||||
|
|
||||||
|
// 生命周期广播事件(ADR-006)在本模块的发出点:都在调用方的业务事务里写 outbox,订阅方按 ID 回查业务表。
|
||||||
|
|
||||||
|
type SubEvent = "subscription.created" | "subscription.updated" | "subscription.completed" | "subscription.cancelled";
|
||||||
|
|
||||||
|
type SubRef = Pick<SubscriptionRow, "id" | "media_entity_id" | "media_kind" | "status" | "origin">;
|
||||||
|
|
||||||
|
/** 订阅已建 / 已改 / 已完成 / 已取消 */
|
||||||
|
export async function emitSubscriptionEvent(
|
||||||
|
d: AoDeps,
|
||||||
|
exec: Exec,
|
||||||
|
type: SubEvent,
|
||||||
|
sub: SubRef,
|
||||||
|
extra: { reason?: string; changes?: string[]; key?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const ref = { subscriptionId: sub.id, mediaEntityId: sub.media_entity_id, mediaKind: sub.media_kind };
|
||||||
|
const opts = extra.key ? { idempotencyKey: extra.key } : {};
|
||||||
|
switch (type) {
|
||||||
|
case "subscription.created":
|
||||||
|
return d.ctx.outbox.emit(
|
||||||
|
exec,
|
||||||
|
type,
|
||||||
|
{ ...ref, origin: sub.origin, status: sub.status },
|
||||||
|
{ idempotencyKey: lifecycleKey(type, sub.id) },
|
||||||
|
);
|
||||||
|
case "subscription.updated":
|
||||||
|
return d.ctx.outbox.emit(exec, type, { ...ref, status: sub.status, changes: extra.changes ?? [] }, opts);
|
||||||
|
case "subscription.completed":
|
||||||
|
case "subscription.cancelled":
|
||||||
|
return d.ctx.outbox.emit(exec, type, { ...ref, reason: extra.reason ?? "" }, opts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOWNLOAD_END: Partial<Record<DownloadStatus, "download.completed" | "download.failed" | "download.removed">> = {
|
||||||
|
completed: "download.completed",
|
||||||
|
failed: "download.failed",
|
||||||
|
removed: "download.removed",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 下载状态进入 completed / failed / removed 时发对应的生命周期事件(每代每种只发一次) */
|
||||||
|
export async function emitDownloadLifecycle(
|
||||||
|
d: AoDeps,
|
||||||
|
exec: Exec,
|
||||||
|
id: string,
|
||||||
|
from: DownloadStatus | null,
|
||||||
|
to: DownloadStatus,
|
||||||
|
): Promise<void> {
|
||||||
|
const type = DOWNLOAD_END[to];
|
||||||
|
if (!type || from === to) return;
|
||||||
|
const row = await exec
|
||||||
|
.selectFrom("download_task as t")
|
||||||
|
.leftJoin("acquisition_plan as p", "p.id", "t.acquisition_plan_id")
|
||||||
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
|
.select([
|
||||||
|
"t.name",
|
||||||
|
"t.generation",
|
||||||
|
"t.acquisition_plan_id",
|
||||||
|
"t.media_entity_id",
|
||||||
|
"s.media_entity_id as slot_entity",
|
||||||
|
])
|
||||||
|
.where("t.id", "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row) return;
|
||||||
|
const ref = {
|
||||||
|
downloadTaskId: id,
|
||||||
|
planId: row.acquisition_plan_id,
|
||||||
|
mediaEntityId: row.media_entity_id ?? row.slot_entity ?? null,
|
||||||
|
name: row.name,
|
||||||
|
generation: row.generation,
|
||||||
|
};
|
||||||
|
const key = { idempotencyKey: lifecycleKey(type, id, `g${row.generation}`) };
|
||||||
|
if (type === "download.completed") await d.ctx.outbox.emit(exec, type, ref, key);
|
||||||
|
else await d.ctx.outbox.emit(exec, type, { ...ref, from }, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINISHED: readonly OrganizationStatus[] = ["succeeded", "partial", "failed"];
|
||||||
|
|
||||||
|
/** 整理记录进入终态时发 organization.finished(按 change_seq 去重:同一次状态变化只发一次) */
|
||||||
|
export async function emitOrganizationFinished(
|
||||||
|
d: AoDeps,
|
||||||
|
exec: Exec,
|
||||||
|
r: {
|
||||||
|
id: string;
|
||||||
|
status: OrganizationStatus;
|
||||||
|
action: string;
|
||||||
|
trigger: string;
|
||||||
|
media_entity_id: string | null;
|
||||||
|
download_task_id: string | null;
|
||||||
|
reason: string | null;
|
||||||
|
change_seq: number | string;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
if (!FINISHED.includes(r.status)) return;
|
||||||
|
await d.ctx.outbox.emit(
|
||||||
|
exec,
|
||||||
|
"organization.finished",
|
||||||
|
{
|
||||||
|
recordId: r.id,
|
||||||
|
outcome: r.status as "succeeded" | "partial" | "failed",
|
||||||
|
action: r.action as never,
|
||||||
|
trigger: r.trigger as never,
|
||||||
|
mediaEntityId: r.media_entity_id,
|
||||||
|
downloadTaskId: r.download_task_id,
|
||||||
|
reason: r.reason,
|
||||||
|
changeSeq: String(r.change_seq),
|
||||||
|
},
|
||||||
|
{ idempotencyKey: lifecycleKey("organization.finished", r.id, String(r.change_seq)) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刮削记录结束:failed → scrape.failed;succeeded / partial / skipped → scrape.completed */
|
||||||
|
export async function emitScrapeFinished(d: AoDeps, row: ScrapeRecordRow): Promise<void> {
|
||||||
|
if (row.status === "queued" || row.status === "running") return;
|
||||||
|
const ref = {
|
||||||
|
scrapeRecordId: row.id,
|
||||||
|
mediaEntityId: row.media_entity_id,
|
||||||
|
organizationRecordId: row.organization_record_id,
|
||||||
|
trigger: row.trigger,
|
||||||
|
status: row.status,
|
||||||
|
written: row.written,
|
||||||
|
failed: row.failed,
|
||||||
|
};
|
||||||
|
const at = row.finished_at?.getTime() ?? 0;
|
||||||
|
if (row.status === "failed")
|
||||||
|
await d.ctx.outbox.emit(
|
||||||
|
d.ctx.db,
|
||||||
|
"scrape.failed",
|
||||||
|
{ ...ref, reason: row.reason },
|
||||||
|
{
|
||||||
|
idempotencyKey: lifecycleKey("scrape.failed", row.id, at),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
else
|
||||||
|
await d.ctx.outbox.emit(d.ctx.db, "scrape.completed", ref, {
|
||||||
|
idempotencyKey: lifecycleKey("scrape.completed", row.id, at),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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<ServerContext, "hooks">,
|
||||||
|
plan: OrganizePlan,
|
||||||
|
base: { mediaEntityId: string; mediaType: "movie" | "tv" },
|
||||||
|
): Promise<number> {
|
||||||
|
if (!hasHook(ctx, "organize.rename")) return 0;
|
||||||
|
const renamed = new Map<string, string>();
|
||||||
|
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<string, OrganizePlan["files"]>();
|
||||||
|
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<ServerContext, "hooks">,
|
||||||
|
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 })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -42,10 +42,12 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts";
|
|||||||
import { classifyEntity } from "./classification.ts";
|
import { classifyEntity } from "./classification.ts";
|
||||||
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts";
|
||||||
import { applySlotEvent } from "./downloads.ts";
|
import { applySlotEvent } from "./downloads.ts";
|
||||||
|
import { emitOrganizationFinished } from "./lifecycle-events.ts";
|
||||||
import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts";
|
import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts";
|
||||||
import { resolveOrganizeSource } from "./organize-config.ts";
|
import { resolveOrganizeSource } from "./organize-config.ts";
|
||||||
import { PathGuardError } from "./organize-guard.ts";
|
import { PathGuardError } from "./organize-guard.ts";
|
||||||
import { successfulOrganizeCounts } from "./organize-history.ts";
|
import { successfulOrganizeCounts } from "./organize-history.ts";
|
||||||
|
import { applyRenameHook, organizeIntercept } from "./organize-hooks.ts";
|
||||||
import { planTransfer } from "./organize-transfer.ts";
|
import { planTransfer } from "./organize-transfer.ts";
|
||||||
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
// 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→
|
||||||
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
// 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。
|
||||||
@@ -925,8 +927,19 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise<PlanRes
|
|||||||
plan.requiresApproval = true;
|
plan.requiresApproval = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 链式钩子 organize.rename(ADR-006):模板命名时可改写主视频目标文件名
|
||||||
|
if (!inPlace && !keepNames) await applyRenameHook(d.ctx, plan, { mediaEntityId: entity.id, mediaType });
|
||||||
if (keepNames && !inPlace) keepOriginalNames(plan);
|
if (keepNames && !inPlace) keepOriginalNames(plan);
|
||||||
if (req.keepBoth && !inPlace) await keepBothTargets(plan);
|
if (req.keepBoth && !inPlace) await keepBothTargets(plan);
|
||||||
|
// 链式钩子 organize.intercept:返回原因即拒绝本次整理(记为失败)
|
||||||
|
const veto = await organizeIntercept(d.ctx, plan, {
|
||||||
|
mediaEntityId: entity.id,
|
||||||
|
mediaType,
|
||||||
|
action,
|
||||||
|
trigger: req.trigger,
|
||||||
|
downloadTaskId: dt?.id ?? null,
|
||||||
|
});
|
||||||
|
if (veto) return fail(`intercepted:${veto.reason}`, { hook: veto.by, reason: veto.reason }, entity.id);
|
||||||
const frozen: FrozenPlan = {
|
const frozen: FrozenPlan = {
|
||||||
version: 1,
|
version: 1,
|
||||||
...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}),
|
...(req.scrapeAfterImport !== undefined ? { scrapeAfterImport: req.scrapeAfterImport } : {}),
|
||||||
@@ -1288,7 +1301,7 @@ export async function writeFailedRecord(
|
|||||||
export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promise<void> {
|
export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promise<void> {
|
||||||
const r = await exec
|
const r = await exec
|
||||||
.selectFrom("organization_record")
|
.selectFrom("organization_record")
|
||||||
.select(["status", "change_seq", "media_entity_id"])
|
.select(["id", "status", "change_seq", "media_entity_id", "action", "trigger", "download_task_id", "reason"])
|
||||||
.where("id", "=", recordId)
|
.where("id", "=", recordId)
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
await d.ctx.outbox.emit(exec, "organization.changed", {
|
await d.ctx.outbox.emit(exec, "organization.changed", {
|
||||||
@@ -1297,6 +1310,8 @@ export async function emitRecord(d: AoDeps, exec: Exec, recordId: string): Promi
|
|||||||
changeSeq: String(r.change_seq),
|
changeSeq: String(r.change_seq),
|
||||||
mediaEntityId: r.media_entity_id,
|
mediaEntityId: r.media_entity_id,
|
||||||
});
|
});
|
||||||
|
// 终态另发生命周期广播(ADR-006):整理成功 / 部分成功 / 失败
|
||||||
|
await emitOrganizationFinished(d, exec, r);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from "../../sources-identity/service/episode-resolution.ts";
|
} from "../../sources-identity/service/episode-resolution.ts";
|
||||||
import { errMessage, jsonb } from "../util.ts";
|
import { errMessage, jsonb } from "../util.ts";
|
||||||
import type { AoDeps } from "./common.ts";
|
import type { AoDeps } from "./common.ts";
|
||||||
|
import { emitScrapeFinished } from "./lifecycle-events.ts";
|
||||||
import {
|
import {
|
||||||
type EntityForNfo,
|
type EntityForNfo,
|
||||||
episodeMetadataMissing,
|
episodeMetadataMissing,
|
||||||
@@ -930,6 +931,7 @@ export async function runScrapeRecord(
|
|||||||
.execute()
|
.execute()
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
if (dirs.size) void d.ctx.events.publish("files.changed", { rootId: rec.root_id, dirs: [...dirs] });
|
if (dirs.size) void d.ctx.events.publish("files.changed", { rootId: rec.root_id, dirs: [...dirs] });
|
||||||
|
await emitScrapeFinished(d, row);
|
||||||
return scrapeRecordView(row);
|
return scrapeRecordView(row);
|
||||||
};
|
};
|
||||||
const fail = (reason: string) => finish({ status: "failed", reason, files: [] });
|
const fail = (reason: string) => finish({ status: "failed", reason, files: [] });
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { AcquisitionSlot, SubscriptionParams, SubscriptionProgress, UnitProgressState } from "@umt/contracts";
|
import {
|
||||||
|
type AcquisitionSlot,
|
||||||
|
lifecycleKey,
|
||||||
|
type SubscriptionParams,
|
||||||
|
type SubscriptionProgress,
|
||||||
|
type UnitProgressState,
|
||||||
|
} from "@umt/contracts";
|
||||||
import type { AcquisitionSlotRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
import type { AcquisitionSlotRow, SourceReleaseRow, SubscriptionRow } from "@umt/db";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import {
|
import {
|
||||||
@@ -20,8 +26,10 @@ import {
|
|||||||
type UnitRange,
|
type UnitRange,
|
||||||
} from "@umt/domain";
|
} from "@umt/domain";
|
||||||
import { slotSpecFromRow } from "@umt/mapping";
|
import { slotSpecFromRow } from "@umt/mapping";
|
||||||
|
import { interceptHook } from "../../../hooks/index.ts";
|
||||||
import { errMessage, jsonb, toApi } from "../util.ts";
|
import { errMessage, jsonb, toApi } from "../util.ts";
|
||||||
import { type AoDeps, type Exec, upsertDecision } from "./common.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 { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts";
|
||||||
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts";
|
||||||
|
|
||||||
@@ -868,6 +876,18 @@ export async function completeSubscription(
|
|||||||
reason: string,
|
reason: string,
|
||||||
progress: Record<string, unknown> = {},
|
progress: Record<string, unknown> = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
// 链式钩子 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
|
const done = await trx
|
||||||
.updateTable("subscription")
|
.updateTable("subscription")
|
||||||
.set({ status: "completed", status_reason: reason, next_search_at: null })
|
.set({ status: "completed", status_reason: reason, next_search_at: null })
|
||||||
@@ -877,7 +897,7 @@ export async function completeSubscription(
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!done) return false;
|
if (!done) return false;
|
||||||
await clearSubscriptionDeferrals(trx, done.id);
|
await clearSubscriptionDeferrals(trx, done.id);
|
||||||
await trx
|
const history = await trx
|
||||||
.insertInto("subscription_history")
|
.insertInto("subscription_history")
|
||||||
.values({
|
.values({
|
||||||
subscription_id: done.id,
|
subscription_id: done.id,
|
||||||
@@ -890,7 +910,12 @@ export async function completeSubscription(
|
|||||||
progress: jsonb(progress),
|
progress: jsonb(progress),
|
||||||
completed_at: d.ports.now(),
|
completed_at: d.ports.now(),
|
||||||
})
|
})
|
||||||
.execute();
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitSubscriptionEvent(d, trx, "subscription.completed", done, {
|
||||||
|
reason,
|
||||||
|
key: lifecycleKey("subscription.completed", done.id, history.id),
|
||||||
|
});
|
||||||
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
|
d.ctx.logger.info({ subscriptionId: sub.id, reason }, "订阅完成,已写历史");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { featureManifest } from "@umt/contracts";
|
||||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { registerBrowserCapabilities } from "./capabilities.ts";
|
import { registerBrowserCapabilities } from "./capabilities.ts";
|
||||||
@@ -36,6 +37,7 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
name: "browser",
|
name: "browser",
|
||||||
|
manifest: featureManifest("browser"),
|
||||||
service,
|
service,
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
registerBrowserRoutes(app, () => service(ctx));
|
registerBrowserRoutes(app, () => service(ctx));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { featureManifest } from "@umt/contracts";
|
||||||
import { browserServiceFor } from "../browser/index.ts";
|
import { browserServiceFor } from "../browser/index.ts";
|
||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
|
import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts";
|
||||||
@@ -11,6 +12,7 @@ export function createDesktopModule(): ServerModule {
|
|||||||
let closeAll: (() => void) | null = null;
|
let closeAll: (() => void) | null = null;
|
||||||
return {
|
return {
|
||||||
name: "desktop",
|
name: "desktop",
|
||||||
|
manifest: featureManifest("desktop"),
|
||||||
registerRoutes(app, ctx: ServerContext) {
|
registerRoutes(app, ctx: ServerContext) {
|
||||||
const r = registerDesktopRoutes(app, {
|
const r = registerDesktopRoutes(app, {
|
||||||
upstream: desktopUpstream({
|
upstream: desktopUpstream({
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { featureManifest } from "@umt/contracts";
|
||||||
import denoJson from "../../../deno.json" with { type: "json" };
|
import denoJson from "../../../deno.json" with { type: "json" };
|
||||||
import type { ServerModule } from "../types.ts";
|
import type { ServerModule } from "../types.ts";
|
||||||
import { createMcpConfigStore } from "./config-store.ts";
|
import { createMcpConfigStore } from "./config-store.ts";
|
||||||
@@ -13,6 +14,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule &
|
|||||||
const table = new McpSessionTable();
|
const table = new McpSessionTable();
|
||||||
return {
|
return {
|
||||||
name: "mcp",
|
name: "mcp",
|
||||||
|
manifest: featureManifest("mcp"),
|
||||||
sessions: table,
|
sessions: table,
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
registerMcpRoutes(app, ctx, {
|
registerMcpRoutes(app, ctx, {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { featureManifest } from "@umt/contracts";
|
||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { builtinAgentCheckDefs } from "./agent-checks.ts";
|
import { builtinAgentCheckDefs } from "./agent-checks.ts";
|
||||||
import { registerScheduleCapabilities } from "./capabilities.ts";
|
import { registerScheduleCapabilities } from "./capabilities.ts";
|
||||||
@@ -26,6 +27,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
|
|||||||
let scheduler: Scheduler | null = null;
|
let scheduler: Scheduler | null = null;
|
||||||
return {
|
return {
|
||||||
name: "schedules",
|
name: "schedules",
|
||||||
|
manifest: featureManifest("schedules"),
|
||||||
scheduler: () => scheduler,
|
scheduler: () => scheduler,
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
|
registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx));
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import type { SourcesRuntime } from "../runtime.ts";
|
|||||||
import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts";
|
import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts";
|
||||||
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts";
|
||||||
import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts";
|
import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts";
|
||||||
|
import { emitSiteReachability } from "./lifecycle-events.ts";
|
||||||
import { browserOnlyCookiesOf, resolveUserAgent, type UserAgentDefaults } from "./user-agent.ts";
|
import { browserOnlyCookiesOf, resolveUserAgent, type UserAgentDefaults } from "./user-agent.ts";
|
||||||
|
|
||||||
// W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、
|
// W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、
|
||||||
@@ -865,12 +866,20 @@ export async function checkSiteHealth(
|
|||||||
? (HEALTH_OF[status] ?? "degraded")
|
? (HEALTH_OF[status] ?? "degraded")
|
||||||
: site.health_status;
|
: site.health_status;
|
||||||
const last = { ok, status, latencyMs, message, detail };
|
const last = { ok, status, latencyMs, message, detail };
|
||||||
|
// 生命周期广播(ADR-006):登录失效 / 访问不了 ⇄ 正常,只在跨越时发
|
||||||
|
const bad = (h: string, st: unknown) => h === "failing" || st === "unreachable";
|
||||||
|
const wasBad = bad(site.health_status, (site.last_health as { status?: unknown } | null)?.status);
|
||||||
|
const isBad = site.enabled && bad(healthStatus, status);
|
||||||
await rt.ctx.db.transaction().execute(async (trx) => {
|
await rt.ctx.db.transaction().execute(async (trx) => {
|
||||||
await trx
|
await trx
|
||||||
.updateTable("site")
|
.updateTable("site")
|
||||||
.set({ last_health: jsonb(last), last_health_at: now, health_status: healthStatus })
|
.set({ last_health: jsonb(last), last_health_at: now, health_status: healthStatus })
|
||||||
.where("id", "=", site.id)
|
.where("id", "=", site.id)
|
||||||
.execute();
|
.execute();
|
||||||
|
if (isBad && !wasBad)
|
||||||
|
await emitSiteReachability(rt.ctx, trx, site.id, { reachable: false, reason: status, message });
|
||||||
|
else if (wasBad && healthStatus === "healthy")
|
||||||
|
await emitSiteReachability(rt.ctx, trx, site.id, { reachable: true, reason: "health_check" });
|
||||||
// 首页显示已登录:顺手记上次登录
|
// 首页显示已登录:顺手记上次登录
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await trx
|
await trx
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
TmdbError,
|
TmdbError,
|
||||||
type TmdbRichDetail,
|
type TmdbRichDetail,
|
||||||
} from "@umt/adapters";
|
} from "@umt/adapters";
|
||||||
import type {
|
import {
|
||||||
EpisodeGroupList,
|
type EpisodeGroupList,
|
||||||
EpisodeGroupSetResult,
|
type EpisodeGroupSetResult,
|
||||||
EpisodeStructureCurrent,
|
type EpisodeStructureCurrent,
|
||||||
EpisodeStructureSeason,
|
type EpisodeStructureSeason,
|
||||||
EpisodeStructureSeasons,
|
type EpisodeStructureSeasons,
|
||||||
|
lifecycleKey,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { sql } from "@umt/db";
|
import { sql } from "@umt/db";
|
||||||
import { ApiException } from "../../../http/errors.ts";
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
@@ -357,6 +358,13 @@ export async function setEpisodeGroup(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const reresolve = await reresolveEntityEpisodes(rt.ctx, trx, tv.id);
|
const reresolve = await reresolveEntityEpisodes(rt.ctx, trx, tv.id);
|
||||||
|
// 生命周期广播(ADR-006):季集结构已切换
|
||||||
|
await rt.ctx.outbox.emit(
|
||||||
|
trx,
|
||||||
|
"media.episode_structure_changed",
|
||||||
|
{ mediaEntityId: tv.id, fromGroupId: fromId, toGroupId: groupId },
|
||||||
|
{ idempotencyKey: lifecycleKey("media.episode_structure_changed", tv.id, now.toISOString()) },
|
||||||
|
);
|
||||||
return { ...r, reresolve };
|
return { ...r, reresolve };
|
||||||
});
|
});
|
||||||
let refresh = "refreshed";
|
let refresh = "refreshed";
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { lifecycleKey } from "@umt/contracts";
|
||||||
|
import type { DB, Kysely, Transaction } from "@umt/db";
|
||||||
|
import type { ServerContext } from "../../types.ts";
|
||||||
|
|
||||||
|
// 生命周期广播事件(ADR-006)在来源与识别侧的发出点。元数据刷新、进入关注的判定本身在 metadata.ts / watched.ts,
|
||||||
|
// 这里只在调用方发事件,不改那两处。
|
||||||
|
|
||||||
|
type Exec = Kysely<DB> | Transaction<DB>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 作品进入关注:建订阅、开始下载、用户搜索 / 手动认定时由调用方发。
|
||||||
|
* 同一作品同一触发对象(订阅、下载计划、资源)只发一次;订阅方自己判断作品是否已经处理过。
|
||||||
|
*/
|
||||||
|
export async function emitMediaWatched(
|
||||||
|
ctx: Pick<ServerContext, "outbox">,
|
||||||
|
exec: Exec,
|
||||||
|
mediaEntityId: string,
|
||||||
|
reason: string,
|
||||||
|
subjectId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await ctx.outbox.emit(
|
||||||
|
exec,
|
||||||
|
"media.watched",
|
||||||
|
{ mediaEntityId, reason },
|
||||||
|
{ idempotencyKey: lifecycleKey("media.watched", mediaEntityId, reason, subjectId) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 作品元数据刷新完成(刷新任务的调用方发;按任务 run 去重) */
|
||||||
|
export async function emitMetadataRefreshed(
|
||||||
|
ctx: Pick<ServerContext, "outbox">,
|
||||||
|
exec: Exec,
|
||||||
|
input: { mediaEntityId: string; full: boolean; changedFields: string[]; runId: string },
|
||||||
|
): Promise<void> {
|
||||||
|
await ctx.outbox.emit(
|
||||||
|
exec,
|
||||||
|
"media.metadata_refreshed",
|
||||||
|
{ mediaEntityId: input.mediaEntityId, full: input.full, changedFields: input.changedFields },
|
||||||
|
{ idempotencyKey: lifecycleKey("media.metadata_refreshed", input.mediaEntityId, input.runId) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点不可达 / 恢复。只在状态跨越时由调用方发(连续失败到阈值、登录失效、检测结果变化) */
|
||||||
|
export async function emitSiteReachability(
|
||||||
|
ctx: Pick<ServerContext, "outbox">,
|
||||||
|
exec: Exec,
|
||||||
|
siteId: string,
|
||||||
|
change: { reachable: false; reason: string; message: string | null } | { reachable: true; reason: string },
|
||||||
|
key?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const opts = key ? { idempotencyKey: key } : {};
|
||||||
|
if (change.reachable) await ctx.outbox.emit(exec, "site.recovered", { siteId, reason: change.reason }, opts);
|
||||||
|
else
|
||||||
|
await ctx.outbox.emit(exec, "site.unreachable", { siteId, reason: change.reason, message: change.message }, opts);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
toParsedColumns,
|
toParsedColumns,
|
||||||
toReleaseResolutionStatus,
|
toReleaseResolutionStatus,
|
||||||
} from "@umt/mapping";
|
} from "@umt/mapping";
|
||||||
|
import { rewriteHook } from "../../../hooks/index.ts";
|
||||||
import { jsonb } from "../rows.ts";
|
import { jsonb } from "../rows.ts";
|
||||||
import type { SourcesRuntime } from "../runtime.ts";
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
|
import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts";
|
||||||
@@ -52,6 +53,7 @@ import {
|
|||||||
openGroupDecision,
|
openGroupDecision,
|
||||||
refreshGroupDecision,
|
refreshGroupDecision,
|
||||||
} from "./identity-decisions.ts";
|
} from "./identity-decisions.ts";
|
||||||
|
import { emitMediaWatched } from "./lifecycle-events.ts";
|
||||||
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
import { loadSubject, searchBangumi } from "./meta-subjects.ts";
|
||||||
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
|
||||||
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
|
||||||
@@ -480,7 +482,18 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
|||||||
mediaEntityId: release.media_entity_id,
|
mediaEntityId: release.media_entity_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
|
// 链式钩子 name.recognize(ADR-006):可改写解析结果;没有钩子时原样
|
||||||
|
const parsed = await rewriteHook(
|
||||||
|
ctx,
|
||||||
|
"name.recognize",
|
||||||
|
parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {}),
|
||||||
|
() => ({
|
||||||
|
title: release.title,
|
||||||
|
subtitle: release.subtitle ?? null,
|
||||||
|
sourceReleaseId: release.id,
|
||||||
|
trigger: input.trigger ?? (input.force ? "review" : "observed"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const cols = toParsedColumns(parsed);
|
const cols = toParsedColumns(parsed);
|
||||||
// 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分
|
// 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分
|
||||||
const siteIds = siteExplicitIds(release.raw);
|
const siteIds = siteExplicitIds(release.raw);
|
||||||
@@ -909,6 +922,9 @@ async function bindAuto(
|
|||||||
} else if (userAsked && result.mediaEntityId) {
|
} else if (userAsked && result.mediaEntityId) {
|
||||||
await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`);
|
await enqueueWatchedRefresh(ctx, result.mediaEntityId, `lookup:${lookup}`);
|
||||||
}
|
}
|
||||||
|
// 用户搜索 / 手动认定 = 作品进入关注(ADR-006 生命周期广播)
|
||||||
|
if (userAsked && result.mediaEntityId)
|
||||||
|
await emitMediaWatched(ctx, ctx.db, result.mediaEntityId, `lookup:${lookup}`, release.id);
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof IdentityConflictError && attempt < 2) continue;
|
if (e instanceof IdentityConflictError && attempt < 2) continue;
|
||||||
@@ -1160,13 +1176,20 @@ export interface NameIdentifyResult {
|
|||||||
*/
|
*/
|
||||||
export async function identifyParsed(
|
export async function identifyParsed(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
parsed: ParsedRelease,
|
input: ParsedRelease,
|
||||||
opts: { trigger?: ResolveTrigger; evidence?: Record<string, unknown> } = {},
|
opts: { trigger?: ResolveTrigger; evidence?: Record<string, unknown> } = {},
|
||||||
): Promise<NameIdentifyResult> {
|
): Promise<NameIdentifyResult> {
|
||||||
const { ctx } = rt;
|
const { ctx } = rt;
|
||||||
const none = { mediaEntityId: null, contentUnitIds: [] as string[], candidates: [], created: false };
|
const none = { mediaEntityId: null, contentUnitIds: [] as string[], candidates: [], created: false };
|
||||||
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
|
|
||||||
const trigger = opts.trigger ?? "takeover";
|
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,
|
||||||
|
}));
|
||||||
|
if (!parsed.title || parsed.auxiliaryOnly) return { outcome: "unparseable", reason: "unparseable_title", ...none };
|
||||||
const local = await localCandidates(ctx.db, parsed, null);
|
const local = await localCandidates(ctx.db, parsed, null);
|
||||||
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
|
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
|
||||||
let remote: MetadataCandidate[] = [];
|
let remote: MetadataCandidate[] = [];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
import { type SiteDetailPage, SiteError, type SitePage, type SiteRelease } from "@umt/adapters";
|
||||||
import type { SiteSnapshotKind } from "@umt/contracts";
|
import { lifecycleKey, type SiteSnapshotKind } from "@umt/contracts";
|
||||||
import { type SiteRow, sql } from "@umt/db";
|
import { type SiteRow, sql } from "@umt/db";
|
||||||
import { parseRelease } from "@umt/domain";
|
import { parseRelease } from "@umt/domain";
|
||||||
import { siteReleaseToSourceRelease } from "@umt/mapping";
|
import { siteReleaseToSourceRelease } from "@umt/mapping";
|
||||||
@@ -10,6 +10,7 @@ import { siteCan } from "../site-kinds.ts";
|
|||||||
import { enqueueRelogin } from "./account.ts";
|
import { enqueueRelogin } from "./account.ts";
|
||||||
import { closeDecisions, type Exec, openDecision } from "./common.ts";
|
import { closeDecisions, type Exec, openDecision } from "./common.ts";
|
||||||
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
|
import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
|
||||||
|
import { emitSiteReachability } from "./lifecycle-events.ts";
|
||||||
|
|
||||||
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
|
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
|
||||||
// 所有请求经站点共享限流器;结果按 site+站点资源 ID upsert 到 source_release,并记录 site_snapshot 覆盖范围。
|
// 所有请求经站点共享限流器;结果按 site+站点资源 ID upsert 到 source_release,并记录 site_snapshot 覆盖范围。
|
||||||
@@ -114,6 +115,12 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
|
|||||||
.where("status", "in", ["unknown", "active"])
|
.where("status", "in", ["unknown", "active"])
|
||||||
.execute();
|
.execute();
|
||||||
await trx.updateTable("site").set({ health_status: "failing" }).where("id", "=", site.id).execute();
|
await trx.updateTable("site").set({ health_status: "failing" }).where("id", "=", site.id).execute();
|
||||||
|
if (site.health_status !== "failing")
|
||||||
|
await emitSiteReachability(rt.ctx, trx, site.id, {
|
||||||
|
reachable: false,
|
||||||
|
reason: e.kind,
|
||||||
|
message: e.message.slice(0, 300),
|
||||||
|
});
|
||||||
await openDecision(rt.ctx, trx, {
|
await openDecision(rt.ctx, trx, {
|
||||||
kind: "source_unreachable",
|
kind: "source_unreachable",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
@@ -247,6 +254,23 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
|
|||||||
const prevStreak = Number((site.snapshot_cursor as { failStreak?: unknown } | null)?.failStreak) || 0;
|
const prevStreak = Number((site.snapshot_cursor as { failStreak?: unknown } | null)?.failStreak) || 0;
|
||||||
const failStreak = status === "failed" ? prevStreak + 1 : 0;
|
const failStreak = status === "failed" ? prevStreak + 1 : 0;
|
||||||
await syncSnapshotFailureDecision(rt, trx, site, failStreak, errJson);
|
await syncSnapshotFailureDecision(rt, trx, site, failStreak, errJson);
|
||||||
|
// 生命周期广播(ADR-006):连续失败到开待处理的阈值 = 不可达;从阈值以上恢复 = 恢复
|
||||||
|
if (failStreak === SNAPSHOT_FAIL_DECISION_AT)
|
||||||
|
await emitSiteReachability(
|
||||||
|
rt.ctx,
|
||||||
|
trx,
|
||||||
|
site.id,
|
||||||
|
{ reachable: false, reason: "snapshot_failed", message: errJson?.message.slice(0, 300) ?? null },
|
||||||
|
lifecycleKey("site.unreachable", site.id, snap.id),
|
||||||
|
);
|
||||||
|
else if (failStreak === 0 && prevStreak >= SNAPSHOT_FAIL_DECISION_AT)
|
||||||
|
await emitSiteReachability(
|
||||||
|
rt.ctx,
|
||||||
|
trx,
|
||||||
|
site.id,
|
||||||
|
{ reachable: true, reason: "snapshot_ok" },
|
||||||
|
lifecycleKey("site.recovered", site.id, snap.id),
|
||||||
|
);
|
||||||
await trx
|
await trx
|
||||||
.updateTable("site")
|
.updateTable("site")
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { SourcesRuntime } from "./runtime.ts";
|
|||||||
import { refreshSiteAccount } from "./service/account.ts";
|
import { refreshSiteAccount } from "./service/account.ts";
|
||||||
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
|
import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts";
|
||||||
import { ingestFeed } from "./service/feeds.ts";
|
import { ingestFeed } from "./service/feeds.ts";
|
||||||
|
import { emitMetadataRefreshed } from "./service/lifecycle-events.ts";
|
||||||
import { refreshMetadata } from "./service/metadata.ts";
|
import { refreshMetadata } from "./service/metadata.ts";
|
||||||
import { resolveRelease } from "./service/resolve.ts";
|
import { resolveRelease } from "./service/resolve.ts";
|
||||||
import { snapshotSite } from "./service/snapshot.ts";
|
import { snapshotSite } from "./service/snapshot.ts";
|
||||||
@@ -75,7 +76,18 @@ export function registerSourcesTasks(registry: TaskRegistry, rt: () => SourcesRu
|
|||||||
inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }),
|
inputSchema: z.object({ mediaEntityId: id, full: z.boolean().optional() }),
|
||||||
retryPolicy: NETWORK_RETRY,
|
retryPolicy: NETWORK_RETRY,
|
||||||
handler: (t) =>
|
handler: (t) =>
|
||||||
t.step.run("refresh", () => refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full })),
|
t.step.run("refresh", async () => {
|
||||||
|
const r = await refreshMetadata(rt(), t.input.mediaEntityId, { full: !!t.input.full });
|
||||||
|
// 生命周期广播(ADR-006):元数据已刷新(在调用方发,刷新本身在 metadata.ts)
|
||||||
|
if (r.status === "refreshed")
|
||||||
|
await emitMetadataRefreshed(rt().ctx, rt().ctx.db, {
|
||||||
|
mediaEntityId: t.input.mediaEntityId,
|
||||||
|
full: !!t.input.full,
|
||||||
|
changedFields: r.changedFields,
|
||||||
|
runId: t.runId,
|
||||||
|
});
|
||||||
|
return r;
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
defineTask({
|
defineTask({
|
||||||
name: "searchSubscription",
|
name: "searchSubscription",
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type EventPayload, parseEventPayload } from "@umt/contracts";
|
||||||
|
import type { OutboxRouteFn } from "@umt/tasks";
|
||||||
|
import type { ServerContext } from "../types.ts";
|
||||||
|
|
||||||
|
// 补字幕订阅的生命周期事件(ADR-006 功能模块样板)。
|
||||||
|
// 「入库后自动补字幕」还没做(docs/plan/subtitles.md):这里只认出值得补字幕的整理,记一条调试日志,不建任务。
|
||||||
|
// 以后做自动补字幕时把 null 换成 { task: "...", input } 即可,订阅关系不用再改。
|
||||||
|
|
||||||
|
const IMPORT_ACTIONS = new Set(["copy", "move", "in_place"]);
|
||||||
|
|
||||||
|
/** 整理结束事件里值得补字幕的:成功或部分成功、入库类动作(复制 / 移动 / 原地入库)、认到了作品 */
|
||||||
|
export function subtitleCandidate(
|
||||||
|
p: EventPayload<"organization.finished">,
|
||||||
|
): { recordId: string; mediaEntityId: string } | null {
|
||||||
|
if (p.outcome === "failed" || !IMPORT_ACTIONS.has(p.action) || !p.mediaEntityId) return null;
|
||||||
|
return { recordId: p.recordId, mediaEntityId: p.mediaEntityId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subtitlesOutboxRoutes(ctx: Pick<ServerContext, "logger">): Record<string, OutboxRouteFn> {
|
||||||
|
return {
|
||||||
|
"organization.finished": (ev) => {
|
||||||
|
const p = parseEventPayload("organization.finished", ev.payload);
|
||||||
|
const c = p ? subtitleCandidate(p) : null;
|
||||||
|
if (c) ctx.logger.debug(c, "补字幕:整理已完成,自动补字幕还没做,跳过");
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { Api, type SubtitleApplyRequest, type SubtitleFetchRequest, type SubtitleSource } from "@umt/contracts";
|
import {
|
||||||
|
Api,
|
||||||
|
featureManifest,
|
||||||
|
type SubtitleApplyRequest,
|
||||||
|
type SubtitleFetchRequest,
|
||||||
|
type SubtitleSource,
|
||||||
|
} from "@umt/contracts";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
import { route } from "../agent/http.ts";
|
import { route } from "../agent/http.ts";
|
||||||
import { human } from "../browser/routes.ts";
|
import { human } from "../browser/routes.ts";
|
||||||
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
import { sourcesIdentityModule } from "../sources-identity/index.ts";
|
||||||
import type { ServerModule } from "../types.ts";
|
import type { ServerModule } from "../types.ts";
|
||||||
|
import { subtitlesOutboxRoutes } from "./events.ts";
|
||||||
import {
|
import {
|
||||||
downloadFromSession,
|
downloadFromSession,
|
||||||
downloadOnline,
|
downloadOnline,
|
||||||
@@ -13,10 +20,15 @@ import {
|
|||||||
} from "./online/index.ts";
|
} from "./online/index.ts";
|
||||||
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
import { applySubtitles, parseSubtitles, subtitleTargets, subtitleWrites, undoSubtitleWrite } from "./service.ts";
|
||||||
|
|
||||||
/** 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销 */
|
/**
|
||||||
|
* 补字幕(docs/plan/subtitles.md):上传 / 在线下载的字幕解析、配对、写入、历史与撤销。
|
||||||
|
* 功能模块样板(ADR-006):manifest 进功能清单(给智能体),订阅 organization.finished(events.ts),
|
||||||
|
* 写入后发 subtitle.written;前端经插槽挂在作品页操作区与整理记录行菜单(apps/web/app/features/subtitles.tsx)。
|
||||||
|
*/
|
||||||
export function createSubtitlesModule(): ServerModule {
|
export function createSubtitlesModule(): ServerModule {
|
||||||
return {
|
return {
|
||||||
name: "subtitles",
|
name: "subtitles",
|
||||||
|
manifest: featureManifest("subtitles"),
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
const tag = { tag: "subtitles" };
|
const tag = { tag: "subtitles" };
|
||||||
const rt = () => sourcesIdentityModule.runtime(ctx);
|
const rt = () => sourcesIdentityModule.runtime(ctx);
|
||||||
@@ -69,6 +81,7 @@ export function createSubtitlesModule(): ServerModule {
|
|||||||
route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag);
|
route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag);
|
||||||
},
|
},
|
||||||
registerTasks() {},
|
registerTasks() {},
|
||||||
|
outboxRoutes: (ctx) => subtitlesOutboxRoutes(ctx),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import type {
|
import {
|
||||||
SubtitleApplyRequest,
|
lifecycleKey,
|
||||||
SubtitleApplyResult,
|
type SubtitleApplyRequest,
|
||||||
SubtitleLanguage,
|
type SubtitleApplyResult,
|
||||||
SubtitleParsedItem,
|
type SubtitleLanguage,
|
||||||
SubtitleParseResponse,
|
type SubtitleParsedItem,
|
||||||
SubtitleSource,
|
type SubtitleParseResponse,
|
||||||
SubtitleTarget,
|
type SubtitleSource,
|
||||||
SubtitleTargetsResponse,
|
type SubtitleTarget,
|
||||||
SubtitleWriteView,
|
type SubtitleTargetsResponse,
|
||||||
|
type SubtitleWriteView,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import type { SubtitleWriteTable } from "@umt/db";
|
import type { SubtitleWriteTable } from "@umt/db";
|
||||||
import {
|
import {
|
||||||
@@ -259,7 +260,7 @@ export async function applySubtitles(
|
|||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
return await trx
|
const w = await trx
|
||||||
.insertInto("subtitle_write")
|
.insertInto("subtitle_write")
|
||||||
.values({
|
.values({
|
||||||
id,
|
id,
|
||||||
@@ -277,6 +278,20 @@ export async function applySubtitles(
|
|||||||
})
|
})
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
// 生命周期广播(ADR-006):字幕已写入
|
||||||
|
await ctx.outbox.emit(
|
||||||
|
trx,
|
||||||
|
"subtitle.written",
|
||||||
|
{
|
||||||
|
writeId: w.id,
|
||||||
|
mediaEntityId: w.media_entity_id,
|
||||||
|
videoLocationId: t.locationId,
|
||||||
|
language: w.language as SubtitleLanguage,
|
||||||
|
source: w.source as SubtitleSource,
|
||||||
|
},
|
||||||
|
{ idempotencyKey: lifecycleKey("subtitle.written", w.id) },
|
||||||
|
);
|
||||||
|
return w;
|
||||||
});
|
});
|
||||||
written.push(writeView(row));
|
written.push(writeView(row));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
import type { CapabilityRegistry } from "@umt/agent";
|
import type { CapabilityRegistry } from "@umt/agent";
|
||||||
import type { EventPayload, EventType, GlobalSettings, SecretPurpose, SettingsKey } from "@umt/contracts";
|
import type {
|
||||||
|
EventPayload,
|
||||||
|
EventType,
|
||||||
|
FeatureManifest,
|
||||||
|
GlobalSettings,
|
||||||
|
SecretPurpose,
|
||||||
|
SettingsKey,
|
||||||
|
} from "@umt/contracts";
|
||||||
import type { DB, Kysely, Transaction } from "@umt/db";
|
import type { DB, Kysely, Transaction } from "@umt/db";
|
||||||
import type { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks";
|
import type { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { Config } from "../config.ts";
|
import type { Config } from "../config.ts";
|
||||||
|
import type { HookRegistrar, HookRegistry } from "../hooks/registry.ts";
|
||||||
import type { AuthEnv } from "../http/auth.ts";
|
import type { AuthEnv } from "../http/auth.ts";
|
||||||
import type { EventBusPort } from "../http/sse.ts";
|
import type { EventBusPort } from "../http/sse.ts";
|
||||||
|
|
||||||
@@ -72,6 +80,11 @@ export interface ServerContext {
|
|||||||
settings: SettingsReader;
|
settings: SettingsReader;
|
||||||
/** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */
|
/** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */
|
||||||
capabilities: CapabilityRegistry;
|
capabilities: CapabilityRegistry;
|
||||||
|
/**
|
||||||
|
* 链式钩子(ADR-006):bootstrap 装配,模块在 registerHooks 里注册;业务节点经 hooks/index.ts 的
|
||||||
|
* rewriteHook / interceptHook 调用。测试里手搭的上下文可以没有(等同于没有注册)。
|
||||||
|
*/
|
||||||
|
hooks?: HookRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerModule {
|
export interface ServerModule {
|
||||||
@@ -84,8 +97,15 @@ export interface ServerModule {
|
|||||||
* 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。
|
* 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。
|
||||||
*/
|
*/
|
||||||
registerTasks(registry: TaskRegistry, ctx: ServerContext): void;
|
registerTasks(registry: TaskRegistry, ctx: ServerContext): void;
|
||||||
/** 所有角色:outbox 事件 type → 任务路由;同一 type 只能由一个模块认领 */
|
/**
|
||||||
|
* 所有角色:outbox 事件 type → 任务路由。生命周期广播事件(ADR-006)可以被多个模块订阅,
|
||||||
|
* 中继按模块顺序把每个订阅方的路由都投递一遍;返回 null 表示这条事件本模块不处理。
|
||||||
|
*/
|
||||||
outboxRoutes?(ctx: ServerContext): Record<string, OutboxRouteFn>;
|
outboxRoutes?(ctx: ServerContext): Record<string, OutboxRouteFn>;
|
||||||
|
/** 外围功能的说明(功能清单只给智能体看,不做开关);取 contracts 的 featureManifest(id) */
|
||||||
|
manifest?: FeatureManifest;
|
||||||
|
/** 所有角色:注册链式钩子(同步、按注册顺序、可改写或拦截;见 hooks/registry.ts) */
|
||||||
|
registerHooks?(hooks: HookRegistrar, ctx: ServerContext): void;
|
||||||
/** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */
|
/** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */
|
||||||
registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void;
|
registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void;
|
||||||
/** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */
|
/** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { featureManifest } from "@umt/contracts";
|
||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { registerWebSearchCapabilities } from "./capabilities.ts";
|
import { registerWebSearchCapabilities } from "./capabilities.ts";
|
||||||
import { registerWebSearchRoutes } from "./routes.ts";
|
import { registerWebSearchRoutes } from "./routes.ts";
|
||||||
@@ -24,6 +25,7 @@ export function createWebSearchModule(opts: WebSearchModuleOptions = {}): Server
|
|||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
name: "websearch",
|
name: "websearch",
|
||||||
|
manifest: featureManifest("websearch"),
|
||||||
service,
|
service,
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
registerWebSearchRoutes(app, () => service(ctx));
|
registerWebSearchRoutes(app, () => service(ctx));
|
||||||
|
|||||||
@@ -18,8 +18,20 @@ import {
|
|||||||
/**
|
/**
|
||||||
* outbox → SSE 桥:这些前缀的 outbox 事件在中继投递成功后镜像到实时总线(payload 原样,带业务 id),
|
* outbox → SSE 桥:这些前缀的 outbox 事件在中继投递成功后镜像到实时总线(payload 原样,带业务 id),
|
||||||
* 首页、收件箱、任务、来源与下载页据此失效刷新。feed.due 是调度器内部节拍,不推给浏览器。
|
* 首页、收件箱、任务、来源与下载页据此失效刷新。feed.due 是调度器内部节拍,不推给浏览器。
|
||||||
|
* 镜像列表同时决定中继认领哪些没有路由的事件:生命周期广播事件都在这里,否则没有订阅方时会一直挂在 pending。
|
||||||
*/
|
*/
|
||||||
const SSE_MIRROR_PREFIXES = ["feed.", "decision.", "download.", "organization.", "subscription."] as const;
|
const SSE_MIRROR_PREFIXES = [
|
||||||
|
"feed.",
|
||||||
|
"decision.",
|
||||||
|
"download.",
|
||||||
|
"organization.",
|
||||||
|
"subscription.",
|
||||||
|
// 生命周期广播事件(ADR-006):没有订阅方时也要被中继认领(标记已投递),顺带推给浏览器
|
||||||
|
"scrape.",
|
||||||
|
"subtitle.",
|
||||||
|
"media.",
|
||||||
|
"site.",
|
||||||
|
] as const;
|
||||||
const SSE_MIRROR_EXCLUDED = new Set<string>([EventType.feedDue]);
|
const SSE_MIRROR_EXCLUDED = new Set<string>([EventType.feedDue]);
|
||||||
export const SSE_MIRRORED_OUTBOX_TYPES: readonly string[] = Object.values(EventType).filter(
|
export const SSE_MIRRORED_OUTBOX_TYPES: readonly string[] = Object.values(EventType).filter(
|
||||||
(t) => SSE_MIRROR_PREFIXES.some((p) => t.startsWith(p)) && !SSE_MIRROR_EXCLUDED.has(t),
|
(t) => SSE_MIRROR_PREFIXES.some((p) => t.startsWith(p)) && !SSE_MIRROR_EXCLUDED.has(t),
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
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<string>((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 类/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
|
||||||
|
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,
|
||||||
|
} from "../../src/modules/acquisition-organize/services/lifecycle-events.ts";
|
||||||
|
import { emitRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
import { completeSubscription } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
|
||||||
|
import { SERVER_MODULES } from "../../src/modules/index.ts";
|
||||||
|
import {
|
||||||
|
emitMetadataRefreshed,
|
||||||
|
emitSiteReachability,
|
||||||
|
} from "../../src/modules/sources-identity/service/lifecycle-events.ts";
|
||||||
|
import type { ServerContext, ServerModule } from "../../src/modules/types.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
|
||||||
|
|
||||||
|
// 生命周期广播事件(ADR-006):各业务节点在事务内写 outbox,payload 过 contracts schema,幂等键防重复。
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||||
|
|
||||||
|
/** 某类事件的全部行;顺带确认 payload 过 schema */
|
||||||
|
const events = async (type: EventType) => {
|
||||||
|
const rows = await h.outbox(type);
|
||||||
|
for (const r of rows) expect(parseEventPayload(type, r.payload), `${type} payload`).not.toBeNull();
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness("lifecycle");
|
||||||
|
base = await seedBase(h.db, {
|
||||||
|
libraryPath: "/tmp/umt-lc-lib",
|
||||||
|
cachePath: "/tmp/umt-lc-dl",
|
||||||
|
qbUrl: "http://127.0.0.1:9",
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("清单里的事件都是已登记类型", () => {
|
||||||
|
for (const e of LIFECYCLE_EVENTS) expect(isEventType(e.type)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("订阅:已建(并发作品进入关注)/ 已改 / 暂停也算已改 / 删除发已取消", async () => {
|
||||||
|
const e = await seedEntity(h.db, { kind: "movie", title: "订阅事件片", year: 2024 });
|
||||||
|
const created = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
const id = created.body.data.id as string;
|
||||||
|
expect((await events("subscription.created")).filter((r) => r.payload.subscriptionId === id)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: { subscriptionId: id, mediaEntityId: e, mediaKind: "movie", origin: "user", status: "active" },
|
||||||
|
idempotency_key: `subscription.created/${id}`,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect((await events("media.watched")).find((r) => r.payload.mediaEntityId === e)).toMatchObject({
|
||||||
|
payload: { mediaEntityId: e, reason: "subscription" },
|
||||||
|
idempotency_key: `media.watched/${e}/subscription/${id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await h.api("PATCH", `/subscriptions/${id}`, { priority: "new_work" })).status).toBe(200);
|
||||||
|
expect((await h.api("POST", `/subscriptions/${id}/pause`)).status).toBe(200);
|
||||||
|
const updated = (await events("subscription.updated")).filter((r) => r.payload.subscriptionId === id);
|
||||||
|
expect(updated.map((r) => [r.payload.changes, r.payload.status])).toEqual([
|
||||||
|
[["priority"], "active"],
|
||||||
|
[["status"], "paused"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" })).status).toBe(200);
|
||||||
|
expect((await events("subscription.cancelled")).filter((r) => r.payload.subscriptionId === id)).toEqual([
|
||||||
|
expect.objectContaining({ payload: expect.objectContaining({ reason: "user_deleted" }) }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
|
||||||
|
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
|
||||||
|
const sub = await h.db
|
||||||
|
.insertInto("subscription")
|
||||||
|
.values({ media_entity_id: e, media_kind: "movie", status: "active" })
|
||||||
|
.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(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const completed = (await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id);
|
||||||
|
expect(completed).toHaveLength(1);
|
||||||
|
expect(completed[0]!.payload.reason).toBe("slot_satisfied");
|
||||||
|
expect(completed[0]!.idempotency_key).toMatch(new RegExp(`^subscription\\.completed/${sub.id}/`));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("整理结束:终态才发 organization.finished,同一次状态变化只发一次;进行中不发", async () => {
|
||||||
|
const e = await seedEntity(h.db, { kind: "movie", title: "整理事件片", year: 2022 });
|
||||||
|
const rec = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "running", media_entity_id: e })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitRecord(h.d, h.db, rec.id);
|
||||||
|
expect((await events("organization.finished")).filter((r) => r.payload.recordId === rec.id)).toEqual([]);
|
||||||
|
|
||||||
|
await h.db.updateTable("organization_record").set({ status: "succeeded" }).where("id", "=", rec.id).execute();
|
||||||
|
await emitRecord(h.d, h.db, rec.id);
|
||||||
|
await emitRecord(h.d, h.db, rec.id); // 重放:同一 change_seq
|
||||||
|
const fin = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
|
||||||
|
expect(fin).toHaveLength(1);
|
||||||
|
expect(fin[0]!.payload).toMatchObject({
|
||||||
|
outcome: "succeeded",
|
||||||
|
action: "copy",
|
||||||
|
trigger: "auto",
|
||||||
|
mediaEntityId: e,
|
||||||
|
downloadTaskId: null,
|
||||||
|
});
|
||||||
|
expect(fin[0]!.idempotency_key).toBe(`organization.finished/${rec.id}/${fin[0]!.payload.changeSeq}`);
|
||||||
|
// organization.changed 照旧每次都发
|
||||||
|
expect((await events("organization.changed")).filter((r) => r.payload.recordId === rec.id)).toHaveLength(3);
|
||||||
|
|
||||||
|
// 重新整理后失败:新的状态变化再发一条
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ status: "failed", reason: "target_exists" })
|
||||||
|
.where("id", "=", rec.id)
|
||||||
|
.execute();
|
||||||
|
await emitRecord(h.d, h.db, rec.id);
|
||||||
|
const again = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
|
||||||
|
expect(again.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([
|
||||||
|
["succeeded", null],
|
||||||
|
["failed", "target_exists"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("下载:完成 / 失败 / 移除各发一次(按任务行代数去重),其它状态不发", async () => {
|
||||||
|
const dt = await h.db
|
||||||
|
.insertInto("download_task")
|
||||||
|
.values({ instance_id: base.instanceId, torrent_hash: "a".repeat(40), name: "Some.Movie.2024.mkv" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "queued", "downloading");
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "completed", "removed");
|
||||||
|
const done = (await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id);
|
||||||
|
expect(done).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: {
|
||||||
|
downloadTaskId: dt.id,
|
||||||
|
planId: null,
|
||||||
|
mediaEntityId: null,
|
||||||
|
name: "Some.Movie.2024.mkv",
|
||||||
|
generation: 0,
|
||||||
|
},
|
||||||
|
idempotency_key: `download.completed/${dt.id}/g0`,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect((await events("download.removed")).filter((r) => r.payload.downloadTaskId === dt.id)).toEqual([
|
||||||
|
expect.objectContaining({ payload: expect.objectContaining({ from: "completed" }) }),
|
||||||
|
]);
|
||||||
|
// 复活后(代数 +1)再完成会再发
|
||||||
|
await h.db.updateTable("download_task").set({ generation: 1 }).where("id", "=", dt.id).execute();
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
|
||||||
|
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "failed");
|
||||||
|
expect((await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(2);
|
||||||
|
expect((await events("download.failed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("刮削:失败发 scrape.failed,成功发 scrape.completed", async () => {
|
||||||
|
const lib = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
|
||||||
|
const mk = async (status: "failed" | "succeeded") =>
|
||||||
|
h.db
|
||||||
|
.insertInto("scrape_record")
|
||||||
|
.values({
|
||||||
|
root_id: lib.id,
|
||||||
|
root_path: "/tmp/umt-lc-lib",
|
||||||
|
status,
|
||||||
|
reason: status === "failed" ? "scrape_no_metadata" : null,
|
||||||
|
written: status === "failed" ? 0 : 3,
|
||||||
|
finished_at: new Date(),
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const bad = await mk("failed");
|
||||||
|
const ok = await mk("succeeded");
|
||||||
|
await emitScrapeFinished(h.d, bad);
|
||||||
|
await emitScrapeFinished(h.d, bad);
|
||||||
|
await emitScrapeFinished(h.d, ok);
|
||||||
|
expect((await events("scrape.failed")).map((r) => r.payload)).toEqual([
|
||||||
|
expect.objectContaining({ scrapeRecordId: bad.id, reason: "scrape_no_metadata", status: "failed" }),
|
||||||
|
]);
|
||||||
|
expect((await events("scrape.completed")).map((r) => r.payload)).toEqual([
|
||||||
|
expect.objectContaining({ scrapeRecordId: ok.id, written: 3, status: "succeeded" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("站点不可达 / 恢复、元数据已刷新:payload 合法,带键的按键去重", async () => {
|
||||||
|
await emitSiteReachability(h.ctx, h.db, base.siteId, {
|
||||||
|
reachable: false,
|
||||||
|
reason: "auth_expired",
|
||||||
|
message: "登录失效",
|
||||||
|
});
|
||||||
|
await emitSiteReachability(
|
||||||
|
h.ctx,
|
||||||
|
h.db,
|
||||||
|
base.siteId,
|
||||||
|
{ reachable: true, reason: "health_check" },
|
||||||
|
"site.recovered/x",
|
||||||
|
);
|
||||||
|
await emitSiteReachability(
|
||||||
|
h.ctx,
|
||||||
|
h.db,
|
||||||
|
base.siteId,
|
||||||
|
{ reachable: true, reason: "health_check" },
|
||||||
|
"site.recovered/x",
|
||||||
|
);
|
||||||
|
expect((await events("site.unreachable")).map((r) => r.payload)).toEqual([
|
||||||
|
{ siteId: base.siteId, reason: "auth_expired", message: "登录失效" },
|
||||||
|
]);
|
||||||
|
expect(await events("site.recovered")).toHaveLength(1);
|
||||||
|
|
||||||
|
const e = await seedEntity(h.db, { kind: "series", title: "刷新剧", year: 2021 });
|
||||||
|
for (let i = 0; i < 2; i++)
|
||||||
|
await emitMetadataRefreshed(h.ctx, h.db, {
|
||||||
|
mediaEntityId: e,
|
||||||
|
full: true,
|
||||||
|
changedFields: ["overview"],
|
||||||
|
runId: "run-1",
|
||||||
|
});
|
||||||
|
expect((await events("media.metadata_refreshed")).map((r) => r.payload)).toEqual([
|
||||||
|
{ mediaEntityId: e, full: true, changedFields: ["overview"] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("补字幕订阅整理结束:中继投递时补字幕与其它订阅方都收到,入库类整理被认作补字幕候选", async () => {
|
||||||
|
await h.db.deleteFrom("outbox_event").execute();
|
||||||
|
const e = await seedEntity(h.db, { kind: "movie", title: "字幕订阅片", year: 2020 });
|
||||||
|
const recs = [];
|
||||||
|
for (const action of ["copy", "trash"] as const) {
|
||||||
|
const r = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({ trigger: "auto", actor_type: "system", action, status: "succeeded", media_entity_id: e })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await emitRecord(h.d, h.db, r.id);
|
||||||
|
recs.push(r.id);
|
||||||
|
}
|
||||||
|
const debug = vi.fn();
|
||||||
|
const ctx = { ...h.ctx, logger: { debug, warn: vi.fn() } } as unknown as ServerContext;
|
||||||
|
const other: ServerModule = {
|
||||||
|
name: "test-other",
|
||||||
|
registerRoutes() {},
|
||||||
|
registerTasks() {},
|
||||||
|
outboxRoutes: () => ({
|
||||||
|
"organization.finished": (ev) => ({
|
||||||
|
task: "otherTask",
|
||||||
|
input: { id: (ev.payload as { recordId: string }).recordId },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const enqueued: { task: string; key: string }[] = [];
|
||||||
|
const relay = new OutboxRelay({
|
||||||
|
db: h.db,
|
||||||
|
engine: {
|
||||||
|
enqueue: async (task, _input, o) => {
|
||||||
|
enqueued.push({ task, key: o.idempotencyKey });
|
||||||
|
return { runId: o.idempotencyKey, deduplicated: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: collectOutboxRoutes([...SERVER_MODULES, other], ctx),
|
||||||
|
});
|
||||||
|
await relay.pollOnce();
|
||||||
|
// 补字幕只认复制 / 移动 / 原地入库(进回收站不算),自动补字幕还没做,不建任务
|
||||||
|
expect(debug.mock.calls.map((c) => c[0])).toEqual([{ recordId: recs[0], mediaEntityId: e }]);
|
||||||
|
expect(enqueued.map((x) => x.task)).toEqual(["otherTask", "otherTask"]);
|
||||||
|
const pending = await h.db
|
||||||
|
.selectFrom("outbox_event")
|
||||||
|
.select(["type", "status"])
|
||||||
|
.where("type", "=", "organization.finished")
|
||||||
|
.execute();
|
||||||
|
expect(pending.map((p) => p.status)).toEqual(["dispatched", "dispatched"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("payload 不合法直接抛错,不写 outbox", async () => {
|
||||||
|
await expect(h.ctx.outbox.emit(h.db, "organization.finished", { recordId: "x" } as never)).rejects.toThrow(
|
||||||
|
/不符合 contracts/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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<string>(() => {}), { 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("自定义");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createRoute, z } from "@hono/zod-openapi";
|
import { createRoute, z } from "@hono/zod-openapi";
|
||||||
import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts";
|
import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts";
|
||||||
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
|
import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
|
import { buildHookRegistry, buildTaskRegistry, collectOutboxRoutes, DeferredTaskEngine } from "../src/context.ts";
|
||||||
import { createApp } from "../src/http/app.ts";
|
import { createApp } from "../src/http/app.ts";
|
||||||
import { requireScope } from "../src/http/auth.ts";
|
import { requireScope } from "../src/http/auth.ts";
|
||||||
import { EventBus } from "../src/http/sse.ts";
|
import { EventBus } from "../src/http/sse.ts";
|
||||||
@@ -103,7 +103,7 @@ describe("ServerModule 骨架", () => {
|
|||||||
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
|
expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("outbox 路由合并;同一事件被两个模块认领报错", () => {
|
it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => {
|
||||||
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
|
const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx));
|
||||||
expect(merged).toContain("test.ping");
|
expect(merged).toContain("test.ping");
|
||||||
expect(merged).toContain("release.resolved");
|
expect(merged).toContain("release.resolved");
|
||||||
@@ -111,6 +111,94 @@ describe("ServerModule 骨架", () => {
|
|||||||
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
|
expect(() => collectOutboxRoutes([pingModule, { ...pingModule, name: "dup" }], fakeCtx)).toThrow(/重复认领/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("生命周期广播事件可以被多个模块订阅:按模块顺序合成,各自的任务都投递,某个订阅方出错不连累别人", () => {
|
||||||
|
const { logger } = memoryLogger();
|
||||||
|
const ctx = { role: "all", logger } as unknown as ServerContext;
|
||||||
|
const sub = (name: string, fn: () => unknown): ServerModule => ({
|
||||||
|
name,
|
||||||
|
registerRoutes() {},
|
||||||
|
registerTasks() {},
|
||||||
|
outboxRoutes: () => ({ "organization.finished": fn as never }),
|
||||||
|
});
|
||||||
|
const routes = collectOutboxRoutes(
|
||||||
|
[
|
||||||
|
...SERVER_MODULES,
|
||||||
|
sub("a", () => ({ task: "taskA", input: { n: 1 } })),
|
||||||
|
sub("broken", () => {
|
||||||
|
throw new Error("坏了");
|
||||||
|
}),
|
||||||
|
sub("b", () => [{ task: "taskB", input: {} }]),
|
||||||
|
sub("none", () => null),
|
||||||
|
],
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const ev = {
|
||||||
|
id: "e1",
|
||||||
|
type: "organization.finished",
|
||||||
|
at: new Date(),
|
||||||
|
payload: {
|
||||||
|
recordId: "00000000-0000-4000-8000-000000000001",
|
||||||
|
outcome: "succeeded",
|
||||||
|
action: "copy",
|
||||||
|
trigger: "auto",
|
||||||
|
mediaEntityId: "00000000-0000-4000-8000-000000000002",
|
||||||
|
downloadTaskId: null,
|
||||||
|
reason: null,
|
||||||
|
changeSeq: "7",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// 补字幕(样板)订阅了整理结束,返回 null(自动补字幕还没做)
|
||||||
|
expect(routes["organization.finished"]!(ev)).toEqual([
|
||||||
|
{ task: "taskA", input: { n: 1 } },
|
||||||
|
{ task: "taskB", input: {} },
|
||||||
|
]);
|
||||||
|
// 只有补字幕一个订阅方时它自己的路由不建任务
|
||||||
|
expect(collectOutboxRoutes(SERVER_MODULES, ctx)["organization.finished"]!(ev)).toBeNull();
|
||||||
|
// 非广播事件仍只能一个模块认领(release.observed 由 sources-identity 认领)
|
||||||
|
expect(() => collectOutboxRoutes([...SERVER_MODULES, sub("x", () => null)], ctx)).not.toThrow();
|
||||||
|
const dup: ServerModule = { ...sub("y", () => null), outboxRoutes: () => ({ "release.observed": () => null }) };
|
||||||
|
expect(() => collectOutboxRoutes([...SERVER_MODULES, dup], ctx)).toThrow(/重复认领/);
|
||||||
|
// 广播事件都登记了 payload schema
|
||||||
|
expect(LIFECYCLE_EVENTS.length).toBeGreaterThanOrEqual(17);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("功能清单:外围功能模块都声明 manifest,与 contracts FEATURES 一一对应", () => {
|
||||||
|
const declared = SERVER_MODULES.filter((m) => m.manifest).map((m) => [m.name, m.manifest!.id]);
|
||||||
|
for (const [name, id] of declared) expect(id).toBe(name);
|
||||||
|
expect(declared.map(([n]) => n).sort()).toEqual(FEATURES.map((f) => f.id).sort());
|
||||||
|
const subtitles = SERVER_MODULES.find((m) => m.name === "subtitles")!;
|
||||||
|
expect(subtitles.manifest?.subscribes).toEqual(["organization.finished"]);
|
||||||
|
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 () => {
|
it("引擎就绪前调用给出明确错误", async () => {
|
||||||
const e = new DeferredTaskEngine();
|
const e = new DeferredTaskEngine();
|
||||||
expect(e.ready).toBe(false);
|
expect(e.ready).toBe(false);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { parseEventPayload } from "@umt/contracts";
|
||||||
import { zipSync } from "fflate";
|
import { zipSync } from "fflate";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
|
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
|
||||||
@@ -159,6 +160,18 @@ describe.skipIf(!hasPg)("补字幕:上传、写入、撤销(真实 PG + 临时
|
|||||||
|
|
||||||
const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`);
|
const hist = await api("GET", `/subtitles/writes?entityId=${entityId}`);
|
||||||
expect(hist.body.data.items).toHaveLength(2);
|
expect(hist.body.data.items).toHaveLength(2);
|
||||||
|
// 生命周期广播(ADR-006):每个写入一条 subtitle.written,payload 过 contracts schema
|
||||||
|
const events = await h.db
|
||||||
|
.selectFrom("outbox_event")
|
||||||
|
.select(["payload", "idempotency_key"])
|
||||||
|
.where("type", "=", "subtitle.written")
|
||||||
|
.orderBy("at")
|
||||||
|
.execute();
|
||||||
|
expect(events.map((e) => parseEventPayload("subtitle.written", e.payload))).toEqual([
|
||||||
|
{ writeId: w1.id, mediaEntityId: entityId, videoLocationId: loc.e1, language: "zh-Hant", source: "upload" },
|
||||||
|
{ writeId: w2.id, mediaEntityId: entityId, videoLocationId: loc.e2, language: "zh-Hans&en", source: "upload" },
|
||||||
|
]);
|
||||||
|
expect(events.map((e) => e.idempotency_key)).toEqual([`subtitle.written/${w1.id}`, `subtitle.written/${w2.id}`]);
|
||||||
|
|
||||||
expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy();
|
expect((await api("POST", `/subtitles/writes/${w2.id}/undo`)).body.data.undoneAt).toBeTruthy();
|
||||||
expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕");
|
expect(await readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕");
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const UNIT = [
|
|||||||
"test/search/work-match.test.ts",
|
"test/search/work-match.test.ts",
|
||||||
"test/auth.test.ts",
|
"test/auth.test.ts",
|
||||||
"test/health.test.ts",
|
"test/health.test.ts",
|
||||||
|
"test/hooks/hook-registry.test.ts",
|
||||||
|
"test/hooks/resource-selection.test.ts",
|
||||||
"test/sources-identity/identity-differential.test.ts",
|
"test/sources-identity/identity-differential.test.ts",
|
||||||
"test/sources-identity/enrich-sources.test.ts",
|
"test/sources-identity/enrich-sources.test.ts",
|
||||||
"test/invoke-limits.test.ts",
|
"test/invoke-limits.test.ts",
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { PublicOrganizationRecord } from "@umt/contracts";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createSlotRegistry, type SlotEntity, slots, visibleActions } from "~/lib/slots.ts";
|
||||||
|
import { SlotPanels, useSlotActions } from "./slot-mounts.tsx";
|
||||||
|
|
||||||
|
// 前端插槽(ADR-006):按位置注册、按 order 与注册顺序排列、默认直接显示(没有开关)、挂载点渲染。
|
||||||
|
|
||||||
|
const entity: SlotEntity = { id: "e1", title: "示例剧", kind: "series", status: "active" };
|
||||||
|
const record = (over: Partial<PublicOrganizationRecord> = {}) =>
|
||||||
|
({
|
||||||
|
id: "r1",
|
||||||
|
status: "succeeded",
|
||||||
|
action: "copy",
|
||||||
|
media: { entityId: "e1", title: "示例剧" },
|
||||||
|
...over,
|
||||||
|
}) as unknown as PublicOrganizationRecord;
|
||||||
|
|
||||||
|
describe("插槽注册表", () => {
|
||||||
|
it("按 order 排序,同序保持注册顺序;同一位置 id 重复报错", () => {
|
||||||
|
const reg = createSlotRegistry();
|
||||||
|
const Panel = (name: string) => () => createElement("p", null, name);
|
||||||
|
reg.register("entity.panels", { id: "b", feature: "x", Component: Panel("B") });
|
||||||
|
reg.register("entity.panels", { id: "a", feature: "x", order: 10, Component: Panel("A") });
|
||||||
|
reg.register("entity.panels", { id: "c", feature: "x", Component: Panel("C") });
|
||||||
|
expect(reg.entries("entity.panels").map((e) => e.id)).toEqual(["a", "b", "c"]);
|
||||||
|
expect(reg.entries("settings.sections")).toEqual([]);
|
||||||
|
expect(() => reg.register("entity.panels", { id: "a", feature: "x", Component: Panel("A2") })).toThrow(/已有 a/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("面板类挂载点按顺序渲染全部组件,拿到页面给的 props;没有注册时什么都不渲染", () => {
|
||||||
|
const reg = createSlotRegistry();
|
||||||
|
reg.register("entity.panels", {
|
||||||
|
id: "p",
|
||||||
|
feature: "x",
|
||||||
|
Component: ({ entity: e }) => createElement("section", null, `面板:${e.title}`),
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(SlotPanels, { position: "entity.panels", props: { entity }, registry: reg }),
|
||||||
|
);
|
||||||
|
expect(html).toBe("<section>面板:示例剧</section>");
|
||||||
|
expect(
|
||||||
|
renderToStaticMarkup(
|
||||||
|
createElement(SlotPanels, { position: "entity.panels", props: { entity }, registry: createSlotRegistry() }),
|
||||||
|
),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("动作类:visible 过滤,缺省显示;挂载点给出可见项", () => {
|
||||||
|
const reg = createSlotRegistry();
|
||||||
|
const Dialog = () => null;
|
||||||
|
reg.register("entity.actions", { id: "always", feature: "x", label: "总在", Dialog });
|
||||||
|
reg.register("entity.actions", {
|
||||||
|
id: "active-only",
|
||||||
|
feature: "x",
|
||||||
|
label: "未合并才有",
|
||||||
|
visible: ({ entity: e }) => e.status !== "merged",
|
||||||
|
Dialog,
|
||||||
|
});
|
||||||
|
expect(visibleActions(reg, "entity.actions", { entity }).map((a) => a.id)).toEqual(["always", "active-only"]);
|
||||||
|
expect(visibleActions(reg, "entity.actions", { entity: { ...entity, status: "merged" } }).map((a) => a.id)).toEqual(
|
||||||
|
["always"],
|
||||||
|
);
|
||||||
|
const Probe = () => {
|
||||||
|
const s = useSlotActions("entity.actions", reg);
|
||||||
|
return createElement(
|
||||||
|
"ul",
|
||||||
|
null,
|
||||||
|
s.items({ entity }).map((a) => createElement("li", { key: a.id }, a.label)),
|
||||||
|
s.dialog,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
expect(renderToStaticMarkup(createElement(Probe))).toBe("<ul><li>总在</li><li>未合并才有</li></ul>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("补字幕样板挂在两个真实位置", () => {
|
||||||
|
it("作品页操作区「补字幕」:合并后的作品不显示", () => {
|
||||||
|
expect(visibleActions(slots, "entity.actions", { entity }).map((a) => [a.feature, a.label])).toEqual([
|
||||||
|
["subtitles", "补字幕"],
|
||||||
|
]);
|
||||||
|
expect(visibleActions(slots, "entity.actions", { entity: { ...entity, status: "merged" } })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("整理记录行菜单「补字幕」:只给成功 / 部分成功、认到作品的入库类整理", () => {
|
||||||
|
const labels = (r: PublicOrganizationRecord) =>
|
||||||
|
visibleActions(slots, "organize.record.actions", { record: r }).map((a) => a.label);
|
||||||
|
expect(labels(record())).toEqual(["补字幕"]);
|
||||||
|
expect(labels(record({ status: "partial", action: "move" }))).toEqual(["补字幕"]);
|
||||||
|
expect(labels(record({ status: "failed" }))).toEqual([]);
|
||||||
|
expect(labels(record({ action: "trash" }))).toEqual([]);
|
||||||
|
expect(labels(record({ media: null }))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { type ComponentType, type ReactNode, useState } from "react";
|
||||||
|
import "~/features/index.ts";
|
||||||
|
import {
|
||||||
|
type ActionPosition,
|
||||||
|
type ActionSlot,
|
||||||
|
type PanelPosition,
|
||||||
|
type PanelSlot,
|
||||||
|
type SlotProps,
|
||||||
|
type SlotRegistry,
|
||||||
|
slots,
|
||||||
|
visibleActions,
|
||||||
|
} from "~/lib/slots.ts";
|
||||||
|
|
||||||
|
// 插槽挂载点(ADR-006)。功能在 app/features/*.tsx 里注册,这里只负责在页面上渲染;没有注册时什么都不渲染。
|
||||||
|
|
||||||
|
/** 面板类插槽:按顺序渲染所有注册的组件 */
|
||||||
|
export function SlotPanels<P extends PanelPosition>({
|
||||||
|
position,
|
||||||
|
props,
|
||||||
|
registry = slots,
|
||||||
|
}: {
|
||||||
|
position: P;
|
||||||
|
props: SlotProps[P];
|
||||||
|
registry?: SlotRegistry;
|
||||||
|
}) {
|
||||||
|
const entries = registry.entries(position) as PanelSlot<P>[];
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{entries.map(({ id, Component }) => (
|
||||||
|
<Component key={id} {...props} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动作类插槽:返回可见的项(页面按自己的样式渲染成按钮或菜单项)和当前打开的对话框。
|
||||||
|
* 对话框由页面渲染在菜单外面(菜单关了对话框也不会跟着卸载)。
|
||||||
|
*/
|
||||||
|
export function useSlotActions<P extends ActionPosition>(
|
||||||
|
position: P,
|
||||||
|
registry: SlotRegistry = slots,
|
||||||
|
): {
|
||||||
|
items: (props: SlotProps[P]) => ActionSlot<P>[];
|
||||||
|
open: (entry: ActionSlot<P>, props: SlotProps[P]) => void;
|
||||||
|
dialog: ReactNode;
|
||||||
|
} {
|
||||||
|
const [active, setActive] = useState<{ entry: ActionSlot<P>; props: SlotProps[P] } | null>(null);
|
||||||
|
// 泛型位置的 props 与 IntrinsicAttributes 交不出来,这里按具体对象渲染
|
||||||
|
const Dialog = active?.entry.Dialog as ComponentType<{ onClose: () => void }> | undefined;
|
||||||
|
return {
|
||||||
|
items: (props) => visibleActions(registry, position, props),
|
||||||
|
open: (entry, props) => setActive({ entry, props }),
|
||||||
|
dialog: Dialog && active ? <Dialog {...(active.props as object)} onClose={() => setActive(null)} /> : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// 功能模块的前端插槽注册汇总(ADR-006)。新增功能:在本目录加 <功能>.tsx 导出 register(registry),在这里调用一次。
|
||||||
|
import { slots } from "~/lib/slots.ts";
|
||||||
|
import { registerSubtitles } from "./subtitles.tsx";
|
||||||
|
|
||||||
|
registerSubtitles(slots);
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Captions } from "lucide-react";
|
||||||
|
import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx";
|
||||||
|
import type { SlotRegistry } from "~/lib/slots.ts";
|
||||||
|
|
||||||
|
// 补字幕(功能模块样板,ADR-006):作品页操作区「补字幕」、整理记录行菜单「补字幕」,都打开同一个补字幕对话框。
|
||||||
|
|
||||||
|
export function registerSubtitles(registry: SlotRegistry): void {
|
||||||
|
registry.register("entity.actions", {
|
||||||
|
id: "subtitles.entity",
|
||||||
|
feature: "subtitles",
|
||||||
|
label: "补字幕",
|
||||||
|
icon: Captions,
|
||||||
|
visible: ({ entity }) => entity.status !== "merged",
|
||||||
|
Dialog: ({ entity, onClose }) => (
|
||||||
|
<SubtitleDialog entityId={entity.id} title={entity.title} open onOpenChange={(o) => !o && onClose()} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
registry.register("organize.record.actions", {
|
||||||
|
id: "subtitles.record",
|
||||||
|
feature: "subtitles",
|
||||||
|
label: "补字幕",
|
||||||
|
icon: Captions,
|
||||||
|
// 入库成功(或部分成功)且认到作品的整理才有视频可配字幕
|
||||||
|
visible: ({ record }) =>
|
||||||
|
!!record.media &&
|
||||||
|
(record.status === "succeeded" || record.status === "partial") &&
|
||||||
|
["copy", "move", "in_place"].includes(record.action),
|
||||||
|
Dialog: ({ record, onClose }) => (
|
||||||
|
<SubtitleDialog
|
||||||
|
entityId={record.media!.entityId}
|
||||||
|
title={record.media!.title}
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => !o && onClose()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { MediaKind, PublicOrganizationRecord, SlotPosition } from "@umt/contracts";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
// 前端插槽(ADR-006):功能模块按位置注册组件,页面在挂载点渲染。默认直接显示,不做开关(内部用,做了就是要用)。
|
||||||
|
// 注册写在 app/features/*.tsx,由 app/features/index.ts 汇总;挂载点组件在 components/slots/slot-mounts.tsx。
|
||||||
|
// 位置分两类:
|
||||||
|
// - 动作类(entity.actions、organize.record.actions):一个按钮 / 菜单项,点了打开功能自己的对话框;
|
||||||
|
// - 面板类(entity.panels、settings.sections、sidebar.entry):直接渲染组件。
|
||||||
|
|
||||||
|
/** 插槽里拿到的作品(作品页) */
|
||||||
|
export interface SlotEntity {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind: MediaKind;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlotProps {
|
||||||
|
"sidebar.entry": Record<string, never>;
|
||||||
|
"entity.actions": { entity: SlotEntity };
|
||||||
|
"entity.panels": { entity: SlotEntity };
|
||||||
|
"organize.record.actions": { record: PublicOrganizationRecord };
|
||||||
|
"settings.sections": Record<string, never>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionPosition = "entity.actions" | "organize.record.actions";
|
||||||
|
export type PanelPosition = Exclude<SlotPosition, ActionPosition>;
|
||||||
|
|
||||||
|
interface SlotBase {
|
||||||
|
/** 全局唯一,建议 <功能>.<位置> */
|
||||||
|
id: string;
|
||||||
|
/** 所属功能(contracts FEATURES 的 id) */
|
||||||
|
feature: string;
|
||||||
|
/** 越小越靠前,缺省 100;同序按注册顺序 */
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionSlot<P extends ActionPosition = ActionPosition> extends SlotBase {
|
||||||
|
label: string;
|
||||||
|
icon?: ComponentType<{ className?: string }>;
|
||||||
|
/** 不显示时返回 false(缺省显示) */
|
||||||
|
visible?: (props: SlotProps[P]) => boolean;
|
||||||
|
/** 点了之后渲染的对话框;关闭时调 onClose */
|
||||||
|
Dialog: ComponentType<SlotProps[P] & { onClose: () => void }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelSlot<P extends PanelPosition = PanelPosition> extends SlotBase {
|
||||||
|
Component: ComponentType<SlotProps[P]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SlotEntry<P extends SlotPosition> = P extends ActionPosition
|
||||||
|
? ActionSlot<P>
|
||||||
|
: P extends PanelPosition
|
||||||
|
? PanelSlot<P>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export interface SlotRegistry {
|
||||||
|
register<P extends SlotPosition>(position: P, entry: SlotEntry<P>): void;
|
||||||
|
entries<P extends SlotPosition>(position: P): SlotEntry<P>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSlotRegistry(): SlotRegistry {
|
||||||
|
const byPos = new Map<SlotPosition, SlotBase[]>();
|
||||||
|
return {
|
||||||
|
register(position, entry) {
|
||||||
|
const list = byPos.get(position) ?? [];
|
||||||
|
if (list.some((e) => e.id === entry.id)) throw new Error(`插槽 ${position} 已有 ${entry.id}`);
|
||||||
|
byPos.set(position, [...list, entry]);
|
||||||
|
},
|
||||||
|
entries<P extends SlotPosition>(position: P) {
|
||||||
|
const list = (byPos.get(position) ?? []) as SlotEntry<P>[];
|
||||||
|
// 稳定排序:order 相同保持注册顺序
|
||||||
|
return list
|
||||||
|
.map((e, i) => ({ e, i }))
|
||||||
|
.sort((a, b) => (a.e.order ?? 100) - (b.e.order ?? 100) || a.i - b.i)
|
||||||
|
.map((x) => x.e);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用里唯一的插槽表(features/index.ts 往这里注册) */
|
||||||
|
export const slots: SlotRegistry = createSlotRegistry();
|
||||||
|
|
||||||
|
/** 动作类插槽里当前可见的项 */
|
||||||
|
export function visibleActions<P extends ActionPosition>(
|
||||||
|
registry: SlotRegistry,
|
||||||
|
position: P,
|
||||||
|
props: SlotProps[P],
|
||||||
|
): ActionSlot<P>[] {
|
||||||
|
return (registry.entries(position) as ActionSlot<P>[]).filter((a) => a.visible?.(props) ?? true);
|
||||||
|
}
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CalendarPlus,
|
CalendarPlus,
|
||||||
Captions,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
@@ -89,9 +88,9 @@ import {
|
|||||||
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
|
||||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||||
import { EmbyBadge } from "~/components/search/work-card.tsx";
|
import { EmbyBadge } from "~/components/search/work-card.tsx";
|
||||||
|
import { SlotPanels, useSlotActions } from "~/components/slots/slot-mounts.tsx";
|
||||||
import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
|
import { CELL_LABEL, type CellKind, cellKind } from "~/components/subscriptions/model.ts";
|
||||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||||
import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx";
|
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
import { useUiPrefs } from "~/lib/ui-prefs.ts";
|
||||||
@@ -1110,7 +1109,7 @@ export default function LibraryDetailPage() {
|
|||||||
const entity = useEntityDetail(id);
|
const entity = useEntityDetail(id);
|
||||||
const [merging, setMerging] = useState(false);
|
const [merging, setMerging] = useState(false);
|
||||||
const [subscribing, setSubscribing] = useState(false);
|
const [subscribing, setSubscribing] = useState(false);
|
||||||
const [subtitling, setSubtitling] = useState(false);
|
const entityActions = useSlotActions("entity.actions");
|
||||||
const launcher = useReleaseSearchLauncher();
|
const launcher = useReleaseSearchLauncher();
|
||||||
const ms = mediaServers.useList();
|
const ms = mediaServers.useList();
|
||||||
|
|
||||||
@@ -1147,6 +1146,7 @@ export default function LibraryDetailPage() {
|
|||||||
}
|
}
|
||||||
const d = detail.data;
|
const d = detail.data;
|
||||||
const e = d.entity;
|
const e = d.entity;
|
||||||
|
const slotEntity = { id: e.id, title: e.title, kind: e.kind, status: e.status };
|
||||||
const redirected = entity.data?.redirectedFrom;
|
const redirected = entity.data?.redirectedFrom;
|
||||||
const credits = {
|
const credits = {
|
||||||
cast: creditList(metaValue(e.metadata, "cast")),
|
cast: creditList(metaValue(e.metadata, "cast")),
|
||||||
@@ -1200,12 +1200,13 @@ export default function LibraryDetailPage() {
|
|||||||
合并作品
|
合并作品
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{e.status !== "merged" ? (
|
{/* 作品页操作区插槽(ADR-006):补字幕等功能模块的入口 */}
|
||||||
<Button variant="outline" size="sm" onClick={() => setSubtitling(true)}>
|
{entityActions.items({ entity: slotEntity }).map((a) => (
|
||||||
<Captions />
|
<Button key={a.id} variant="outline" size="sm" onClick={() => entityActions.open(a, { entity: slotEntity })}>
|
||||||
补字幕
|
{a.icon ? <a.icon /> : null}
|
||||||
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
))}
|
||||||
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
<EntityNamingButton entityId={e.id} kind={e.kind} />
|
||||||
</div>
|
</div>
|
||||||
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
<TechInfo className="-mt-3 mb-5" items={[{ label: "作品 ID", value: e.id, wide: true }]} />
|
||||||
@@ -1230,6 +1231,8 @@ export default function LibraryDetailPage() {
|
|||||||
<UnitsPanel entity={entity} detail={d} />
|
<UnitsPanel entity={entity} detail={d} />
|
||||||
) : null}
|
) : null}
|
||||||
<DownloadsPanel detail={d} />
|
<DownloadsPanel detail={d} />
|
||||||
|
{/* 作品页面板区插槽(ADR-006) */}
|
||||||
|
<SlotPanels position="entity.panels" props={{ entity: slotEntity }} />
|
||||||
</div>
|
</div>
|
||||||
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
|
||||||
{subscribing ? (
|
{subscribing ? (
|
||||||
@@ -1244,9 +1247,7 @@ export default function LibraryDetailPage() {
|
|||||||
onClose={() => setSubscribing(false)}
|
onClose={() => setSubscribing(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{subtitling ? (
|
{entityActions.dialog}
|
||||||
<SubtitleDialog entityId={e.id} title={e.title} open onOpenChange={(o) => !o && setSubtitling(false)} />
|
|
||||||
) : null}
|
|
||||||
{launcher.dialog}
|
{launcher.dialog}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ import {
|
|||||||
} from "~/components/organize/record-parts.ts";
|
} from "~/components/organize/record-parts.ts";
|
||||||
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
import { SourceSelection } from "~/components/organize/source-selection.tsx";
|
||||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||||
|
import { useSlotActions } from "~/components/slots/slot-mounts.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
import { reasonLine } from "~/lib/reasons.ts";
|
import { reasonLine } from "~/lib/reasons.ts";
|
||||||
|
import type { ActionSlot } from "~/lib/slots.ts";
|
||||||
import { useSse } from "~/lib/sse.ts";
|
import { useSse } from "~/lib/sse.ts";
|
||||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||||
|
|
||||||
@@ -222,12 +224,17 @@ function RowMenu({
|
|||||||
onReorganize,
|
onReorganize,
|
||||||
onDelete,
|
onDelete,
|
||||||
onOnlyEntity,
|
onOnlyEntity,
|
||||||
|
slotActions,
|
||||||
|
onSlotAction,
|
||||||
}: {
|
}: {
|
||||||
r: Rec;
|
r: Rec;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onReorganize: () => void;
|
onReorganize: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onOnlyEntity: () => void;
|
onOnlyEntity: () => void;
|
||||||
|
/** 整理记录行操作插槽(ADR-006):功能模块注册的菜单项 */
|
||||||
|
slotActions: ActionSlot<"organize.record.actions">[];
|
||||||
|
onSlotAction: (a: ActionSlot<"organize.record.actions">) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const run = (fn: () => void) => () => {
|
const run = (fn: () => void) => () => {
|
||||||
@@ -276,6 +283,11 @@ function RowMenu({
|
|||||||
只看这个作品
|
只看这个作品
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
{slotActions.map((a) => (
|
||||||
|
<MenuItem key={a.id} icon={a.icon ? <a.icon /> : null} onClick={run(() => onSlotAction(a))}>
|
||||||
|
{a.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
{!isRunning(r) ? (
|
{!isRunning(r) ? (
|
||||||
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
<MenuItem icon={<Trash2 />} onClick={run(onDelete)} danger>
|
||||||
删除…
|
删除…
|
||||||
@@ -356,6 +368,7 @@ function StatsLine({
|
|||||||
|
|
||||||
export default function OrganizePage() {
|
export default function OrganizePage() {
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
const recordActions = useSlotActions("organize.record.actions");
|
||||||
const f = useQueryState(RECORD_FILTER_KEYS);
|
const f = useQueryState(RECORD_FILTER_KEYS);
|
||||||
const { set: setFilter, values: v } = f;
|
const { set: setFilter, values: v } = f;
|
||||||
const [qInput, setQInput] = useState(v.q);
|
const [qInput, setQInput] = useState(v.q);
|
||||||
@@ -636,6 +649,8 @@ export default function OrganizePage() {
|
|||||||
v.group ? { mediaEntityId: r.media.entityId, group: v.group } : { mediaEntityId: r.media.entityId },
|
v.group ? { mediaEntityId: r.media.entityId, group: v.group } : { mediaEntityId: r.media.entityId },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
slotActions={recordActions.items({ record: r })}
|
||||||
|
onSlotAction={(a) => recordActions.open(a, { record: r })}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -771,6 +786,7 @@ export default function OrganizePage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{recordActions.dialog}
|
||||||
<DeleteRecordsDialog
|
<DeleteRecordsDialog
|
||||||
records={toDelete ?? []}
|
records={toDelete ?? []}
|
||||||
open={!!toDelete}
|
open={!!toDelete}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# ADR-006:生命周期事件、链式钩子与功能模块约定
|
||||||
|
|
||||||
|
- 状态:采纳(2026-10-06,用户确认方向)。
|
||||||
|
- 关联:ADR-002(outbox 与任务引擎)、`packages/contracts/src/events.ts`(`LIFECYCLE_EVENTS`)、`packages/contracts/src/hooks.ts`(`HOOK_POINTS`)、`packages/contracts/src/features.ts`(`FEATURES`、`SLOT_POSITIONS`)、`apps/server/src/hooks/`、`apps/web/app/lib/slots.ts`。
|
||||||
|
- 参照:MoviePilot v3 `app/schemas/types.py` 的 `EventType`(广播)与 `ChainEventType`(链式),分发在 `app/runtime/events.py`。
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
外围功能(补字幕、以后的入库后通知、自动补字幕等)需要在业务节点上「知道发生了什么」或「插一手」。MoviePilot 用插件系统 + 两类事件解决。我们是内部工具,用户定了:
|
||||||
|
|
||||||
|
- 不做插件系统、插件市场、启停开关;
|
||||||
|
- 做「功能模块」约定:功能清单只给智能体知道;补齐生命周期事件(参考 MP 的两类事件);前端插槽要做、默认直接显示。
|
||||||
|
|
||||||
|
## 决定
|
||||||
|
|
||||||
|
### 1. 两类事件
|
||||||
|
|
||||||
|
| | 广播事件 | 链式钩子 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 对照 MP | `EventType`(transfer.complete、subscribe.added …) | `ChainEventType`(name.recognize、transfer.rename …) |
|
||||||
|
| 时机 | 事后通知:事情已经发生并提交 | 事中:业务流程走到挂点时同步调用 |
|
||||||
|
| 传递 | 业务事务里写 `outbox_event`,中继投递成任务(异步、至少一次) | 进程内直接调用,按注册顺序 |
|
||||||
|
| 能做什么 | 订阅方自己建任务做后续动作 | 改写一个值(rewrite)或拒绝(intercept) |
|
||||||
|
| 失败 | 订阅任务按引擎重试;路由函数抛错只跳过该订阅方 | 钩子抛错 / 超时只记日志、跳过该钩子,主流程照常 |
|
||||||
|
| 去重 | outbox 幂等键(`<type>/<部分…>`),订阅任务用引擎幂等键 | 不需要 |
|
||||||
|
|
||||||
|
**何时用哪类**:只想在「X 发生之后」做点事(发通知、补字幕、预热缓存)→ 订阅广播事件;要改变正在进行的决定(换个名字、换个候选、不让整理、晚点完成)→ 注册链式钩子。拿不准就用广播:它不在主流程里,坏了也不影响主流程。
|
||||||
|
|
||||||
|
**事务边界**:
|
||||||
|
|
||||||
|
- 广播事件在业务事务**里**写 outbox(`ctx.outbox.emit(trx, …)`),与业务写入一起提交或回滚;payload 只放 ID 和少量判断用的事实,订阅方按 ID 回查业务表(事件可能晚到、重复到)。
|
||||||
|
- 钩子可能在业务事务里被调用(订阅完成判定就在行锁里):只做内存里的判断与改写,**不要发外部请求、不要写库**;缺省每个钩子 2 秒上限。需要查外部的,改成订阅广播事件后异步做。
|
||||||
|
|
||||||
|
### 2. 广播事件清单
|
||||||
|
|
||||||
|
payload schema 都在 `EVENT_PAYLOADS`(`emit` 时校验,不合法直接抛错)。已有事件保持原样;`download.status_changed` / `organization.changed` 仍是状态流(SSE 与增量游标用,每次变化都发),下表是给功能模块订阅的生命周期事件。
|
||||||
|
|
||||||
|
| 事件 | 含义 | 发出位置 | 幂等键 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `release.resolved` | 资源识别完成(已有) | sources-identity `resolve.ts` 等 | 已有 |
|
||||||
|
| `subscription.created` | 订阅已建 | 订阅 API 新建、策略自动建(`acquisition.ts`) | `subscription.created/<id>` |
|
||||||
|
| `subscription.updated` | 订阅已改(参数、暂停、恢复、重新订阅;`changes` 列字段) | 订阅 API 修改、状态动作 | 无(每次改动一条) |
|
||||||
|
| `subscription.completed` | 订阅已完成 | `completeSubscription`、停止日期到达 | `…/<id>/<历史行 id>`、`…/<id>/stop_after:<日期>` |
|
||||||
|
| `subscription.cancelled` | 订阅已取消(删除即取消) | 订阅 API 删除 / 改状态为 cancelled | 无 |
|
||||||
|
| `download.submitted` | 下载已提交(已有) | `downloads.ts` 提交 | 已有 |
|
||||||
|
| `download.completed` / `download.failed` / `download.removed` | 下载完成 / 失败 / 移除 | `downloads.ts` `emitStatus`(与 `download.status_changed` 同事务) | `<type>/<任务 id>/g<代数>` |
|
||||||
|
| `organization.finished` | 整理结束,`outcome` = succeeded / partial / failed(统一 `organization.changed` 的终态) | `emitRecord`(所有整理记录写入都经过它) | `organization.finished/<记录 id>/<change_seq>` |
|
||||||
|
| `scrape.completed` / `scrape.failed` | 刮削完成(含部分 / 跳过)/ 失败 | `scrape.ts` `runScrapeRecord` 收尾 | `<type>/<记录 id>/<结束时间>` |
|
||||||
|
| `subtitle.written` | 字幕已写入(每个文件一条) | 补字幕 `applySubtitles` | `subtitle.written/<写入 id>` |
|
||||||
|
| `media.watched` | 作品进入关注(建订阅、开始下载、用户搜索 / 手动认定) | 调用方:订阅 API、`submitDownload`、`resolveRelease` | `media.watched/<作品>/<原因>/<触发对象 id>` |
|
||||||
|
| `media.metadata_refreshed` | 作品元数据已刷新 | `refreshMetadata` 任务处理器(调用方) | `…/<作品>/<任务 run id>` |
|
||||||
|
| `media.episode_structure_changed` | 季集结构已切换(剧集组) | `setEpisodeGroup` 事务内 | `…/<作品>/<时间>` |
|
||||||
|
| `site.unreachable` / `site.recovered` | 站点不可达 / 恢复 | 列表抓取连续失败到阈值(与待处理同口径)、登录失效、站点检测结果跨越 | 列表抓取带 `…/<站点>/<快照 id>`,其余只在跨越时发 |
|
||||||
|
|
||||||
|
幂等键用斜杠分隔,与已有的 `<type>:<id>` 键分开(`download.completed:<id>` 已被 `download.status_changed` 占用,outbox 幂等键全表唯一)。
|
||||||
|
|
||||||
|
**订阅**:模块在 `outboxRoutes` 里写 `type → 路由函数`。生命周期事件可以有多个订阅方(`collectOutboxRoutes` 按模块顺序合成),其它事件(内部流程,如 `release.observed → resolveObservation`)仍只能一个模块认领。路由函数返回任务、任务数组或 `null`(不建任务);中继对第一个任务用事件的幂等键、其余用 `<键>#<任务名>`。没有订阅方的生命周期事件也会被中继认领、标记已投递,并镜像到 SSE(`worker/index.ts` 的镜像前缀)。
|
||||||
|
|
||||||
|
### 3. 链式钩子清单
|
||||||
|
|
||||||
|
挂点名称与语义在 `HOOK_POINTS`,入参 / 返回值类型在 `apps/server/src/hooks/points.ts`。
|
||||||
|
|
||||||
|
| 挂点 | 类型 | 位置 | 能做什么 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `name.recognize` | rewrite | `resolveRelease`(标题解析后、召回前)、`identifyParsed`(整理 / 接管按名称识别) | 改写解析结果(标题、年份、季集、外部 ID) |
|
||||||
|
| `organize.rename` | rewrite | `planImport`,模板命名生成目标之后(保留原名 / 原地入库不调) | 改写主视频目标文件名;同主干的附属文件跟着改;结果带路径分隔符或空名的钩子跳过;改名后撞名转 `duplicate_target` |
|
||||||
|
| `organize.intercept` | intercept | `planImport`,写整理记录之前(预演也调) | 返回原因即拒绝:记录为失败,`reason = intercepted:<原因>` |
|
||||||
|
| `resource.selection` | rewrite | 订阅 / 收录版本重评(`reevaluate`)择优排序之后 | 重排或过滤候选;不认识的 ID 忽略,不能加入新候选 |
|
||||||
|
| `subscription.completion.check` | intercept | `completeSubscription`,标记完成之前 | 追加完成条件:返回原因即这次不完成,订阅继续追踪 |
|
||||||
|
|
||||||
|
语义:
|
||||||
|
|
||||||
|
- 注册在模块启动时完成:`ServerModule.registerHooks(hooks, ctx)`,`bootstrap` 按 `SERVER_MODULES` 顺序调用;执行顺序即注册顺序(不设优先级数字,需要先后就调整模块顺序)。
|
||||||
|
- rewrite:每个钩子拿到上一个钩子的结果,返回新值或 `undefined`(不改)。intercept:第一个返回非空原因的钩子拒绝,后面不再执行。
|
||||||
|
- 每个钩子单独限时(缺省 2 秒,可在注册时改),超时或抛错记 warn 日志、跳过这个钩子;拦截类出错按放行处理。
|
||||||
|
- 没有注册时调用方直接用原值(`hasHook` 为 false,不构造上下文)。
|
||||||
|
|
||||||
|
MP 的其它链式事件(`transfer.overwrite.check`、`resource.download`、`discover.source`、`recommend.source` 等)等有真实需求再加:在 `HOOK_POINTS` 登记、在 `points.ts` 写类型、在业务节点调用。
|
||||||
|
|
||||||
|
### 4. 功能模块约定
|
||||||
|
|
||||||
|
一个外围功能 = 服务端一个 `ServerModule` + contracts 里一份 manifest + (可选)前端插槽注册:
|
||||||
|
|
||||||
|
- **manifest**(`FEATURES`):名称、说明、怎么用、订阅的事件、提供的能力、页面入口、插槽位置、钩子。服务端模块 `manifest: featureManifest("<id>")`;`modules.test.ts` 核对模块与清单一一对应、订阅的事件与 `outboxRoutes` 一致。
|
||||||
|
- **功能清单只给智能体**:系统提示「能力地图」一节末尾列出外围功能(`renderFeatures`),能力只列本次可用的。**不做开关**:功能都直接启用。
|
||||||
|
- **前端插槽**(`apps/web/app/lib/slots.ts`):位置见 `SLOT_POSITIONS`(侧栏入口、作品页操作区、作品页面板区、整理记录行操作、设置分区)。动作类位置注册 `{ label, icon, visible?, Dialog }`,页面渲染成按钮 / 菜单项、点了打开对话框;面板类位置注册组件,挂载点按顺序渲染。注册写在 `app/features/<功能>.tsx`,`app/features/index.ts` 汇总。**默认直接显示,不做开关**。已挂载:作品页操作区、作品页面板区(`library.$id.tsx`)、整理记录行菜单(`organize.tsx`);侧栏与设置分区先只定义位置。
|
||||||
|
- 样板:补字幕(`modules/subtitles`)——manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志不建任务)、写入后发 `subtitle.written`、前端在作品页操作区和整理记录行菜单挂「补字幕」。
|
||||||
|
|
||||||
|
## 不做
|
||||||
|
|
||||||
|
- 插件系统、热加载、插件市场、功能开关。
|
||||||
|
- 钩子优先级数字、钩子里做外部请求。
|
||||||
|
- 把广播事件放进内存队列(MP 做法):我们已有 outbox,事件要能跨进程、跨重启。
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。
|
参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。
|
||||||
|
|
||||||
|
功能模块样板(ADR-006,2026-10-06):本模块已声明 manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志)、写入后发 `subtitle.written`;前端经插槽挂在作品页操作区与整理记录行菜单。做自动补字幕时把订阅路由换成真实任务即可。
|
||||||
|
|
||||||
## 拆分
|
## 拆分
|
||||||
|
|
||||||
| 层面 | 放在哪 |
|
| 层面 | 放在哪 |
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export {
|
|||||||
type IntentRoute,
|
type IntentRoute,
|
||||||
needsApproval,
|
needsApproval,
|
||||||
type RouteStep,
|
type RouteStep,
|
||||||
|
renderFeatures,
|
||||||
} from "./layers/capabilities.ts";
|
} from "./layers/capabilities.ts";
|
||||||
export { renderEnvironment } from "./layers/environment.ts";
|
export { renderEnvironment } from "./layers/environment.ts";
|
||||||
export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts";
|
export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, type KnownCapabilityId } from "@umt/contracts";
|
import { AUTHORIZED_KINDS, CAPABILITY_CATALOG, FEATURES, type KnownCapabilityId } from "@umt/contracts";
|
||||||
import type { PromptSection } from "../types.ts";
|
import type { PromptSection } from "../types.ts";
|
||||||
|
|
||||||
// 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用",
|
// 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用",
|
||||||
@@ -190,6 +190,20 @@ export function renderRoutes(available: ReadonlySet<string>): string[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 功能清单(ADR-006):外围功能各一行,说明做什么、入口在哪、智能体能不能做。清单来自 contracts 的 FEATURES
|
||||||
|
* (服务端模块的 manifest 指向同一份),功能都已启用、没有开关;能力只列本次可用的。
|
||||||
|
*/
|
||||||
|
export function renderFeatures(available: ReadonlySet<string>): string[] {
|
||||||
|
return [
|
||||||
|
"- 外围功能(都已启用,没有开关;用户问到时按这里指路):",
|
||||||
|
...FEATURES.map((f) => {
|
||||||
|
const caps = f.capabilities.filter((c) => available.has(c));
|
||||||
|
return ` - ${f.name}:${f.summary}${f.usage}${caps.length ? `(能力:${caps.join("、")})` : ""}`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export function capabilityLayer(capabilities: readonly string[] | undefined): PromptSection {
|
export function capabilityLayer(capabilities: readonly string[] | undefined): PromptSection {
|
||||||
if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] };
|
if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] };
|
||||||
const available = new Set(capabilities);
|
const available = new Set(capabilities);
|
||||||
@@ -202,6 +216,7 @@ export function capabilityLayer(capabilities: readonly string[] | undefined): Pr
|
|||||||
"按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。",
|
"按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。",
|
||||||
...routes,
|
...routes,
|
||||||
...CAPABILITY_TIPS.map((t) => `- ${t}`),
|
...CAPABILITY_TIPS.map((t) => `- ${t}`),
|
||||||
|
...renderFeatures(available),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { CAPABILITY_CATALOG, FEATURES } from "@umt/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildLayeredPrompt, renderFeatures } from "../src/index.ts";
|
||||||
|
|
||||||
|
// 功能清单(ADR-006):外围功能的 manifest 进系统提示「能力地图」一节,让智能体知道有哪些功能、入口在哪;没有开关。
|
||||||
|
|
||||||
|
const section = (p: string, title: string) => {
|
||||||
|
const start = p.indexOf(`## ${title}`);
|
||||||
|
const rest = p.slice(start);
|
||||||
|
const next = rest.indexOf("\n## ", 3);
|
||||||
|
return next < 0 ? rest : rest.slice(0, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("功能清单进系统提示", () => {
|
||||||
|
it("能力地图里列出每个外围功能的名称、说明与入口", () => {
|
||||||
|
const p = buildLayeredPrompt({ kind: "chat", role: "x", goal: "y", capabilities: ["download.list"] });
|
||||||
|
const map = section(p, "能力地图");
|
||||||
|
expect(map).toContain("外围功能(都已启用,没有开关");
|
||||||
|
for (const f of FEATURES) expect(map).toContain(` - ${f.name}:${f.summary}`);
|
||||||
|
expect(map).toContain("作品页「补字幕」");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("功能的能力只列本次可用的;没给的能力 ID 不出现在清单里", () => {
|
||||||
|
const lines = renderFeatures(new Set(["web.search"]));
|
||||||
|
const web = lines.find((l) => l.includes("网页搜索"))!;
|
||||||
|
expect(web).toContain("(能力:web.search)");
|
||||||
|
const text = lines.join("\n");
|
||||||
|
for (const id of Object.keys(CAPABILITY_CATALOG)) if (id !== "web.search") expect(text).not.toContain(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("没有能力的会话(视觉子流程)不带功能清单", () => {
|
||||||
|
const p = buildLayeredPrompt({ kind: "vision", role: "x", goal: "y" });
|
||||||
|
expect(p).not.toContain("外围功能");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,8 +4,11 @@ import { Id, NonNegInt, Timestamp } from "./common.ts";
|
|||||||
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts";
|
import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts";
|
||||||
import { IdentifyCompletedPayload } from "./files.ts";
|
import { IdentifyCompletedPayload } from "./files.ts";
|
||||||
import { EvidenceLevel } from "./identity.ts";
|
import { EvidenceLevel } from "./identity.ts";
|
||||||
import { OrganizationStatus } from "./organization.ts";
|
import { OrganizationAction, OrganizationStatus, OrganizationTrigger } from "./organization.ts";
|
||||||
|
import { SubscriptionMediaKind, SubscriptionOrigin, SubscriptionStatus } from "./policy.ts";
|
||||||
|
import { ScrapeStatus, ScrapeTrigger } from "./scrape.ts";
|
||||||
import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.ts";
|
import { FeedDuePayload, FeedGapSuspectedPayload, FeedHealth, ReleaseObservedPayload } from "./sources.ts";
|
||||||
|
import { SubtitleLanguage, SubtitleSource } from "./subtitles.ts";
|
||||||
|
|
||||||
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
|
/** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after=<seq> 补读 */
|
||||||
export const EventEnvelope = z.object({
|
export const EventEnvelope = z.object({
|
||||||
@@ -24,10 +27,11 @@ export type EventEnvelope<P = unknown> = {
|
|||||||
/**
|
/**
|
||||||
* 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema。
|
* 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema。
|
||||||
*
|
*
|
||||||
* 没有单独的 download.completed / organization.completed 事件,约定用现有事件表达(W2 收口):
|
* 状态流事件(`download.status_changed`、`organization.changed`)每次状态变化都发,给 SSE 与增量游标用;
|
||||||
* - "下载完成" = `download.status_changed` 且 `to === "completed"`(outbox 幂等键 `download.completed:<id>`,只发一次;
|
* 生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)只在业务节点发一次,给功能模块订阅:
|
||||||
* acquisition-organize 据此入队 verifyAndImport);
|
* - 下载完成 = `download.completed`(与 `download.status_changed → completed` 同事务发出;acquisition-organize
|
||||||
* - "入库完成" = `organization.changed` 且 `status === "succeeded"`(或 partial);`changeSeq` 与公开增量 API 游标一致。
|
* 仍按后者入队 verifyAndImport,幂等键 `download.completed:<id>` 属于后者);
|
||||||
|
* - 整理结束 = `organization.finished`,`outcome` 区分成功 / 部分成功 / 失败。
|
||||||
*/
|
*/
|
||||||
export const EventType = {
|
export const EventType = {
|
||||||
heartbeat: "system.heartbeat",
|
heartbeat: "system.heartbeat",
|
||||||
@@ -59,6 +63,24 @@ export const EventType = {
|
|||||||
decisionUpdated: "decision.updated",
|
decisionUpdated: "decision.updated",
|
||||||
|
|
||||||
identifyCompleted: "identify.completed",
|
identifyCompleted: "identify.completed",
|
||||||
|
|
||||||
|
// ── 生命周期广播事件(ADR-006):业务节点事后通知,经 outbox,至少一次 ──
|
||||||
|
subscriptionCreated: "subscription.created",
|
||||||
|
subscriptionUpdated: "subscription.updated",
|
||||||
|
subscriptionCompleted: "subscription.completed",
|
||||||
|
subscriptionCancelled: "subscription.cancelled",
|
||||||
|
downloadCompleted: "download.completed",
|
||||||
|
downloadFailed: "download.failed",
|
||||||
|
downloadRemoved: "download.removed",
|
||||||
|
organizationFinished: "organization.finished",
|
||||||
|
scrapeCompleted: "scrape.completed",
|
||||||
|
scrapeFailed: "scrape.failed",
|
||||||
|
subtitleWritten: "subtitle.written",
|
||||||
|
mediaWatched: "media.watched",
|
||||||
|
mediaMetadataRefreshed: "media.metadata_refreshed",
|
||||||
|
mediaEpisodeStructureChanged: "media.episode_structure_changed",
|
||||||
|
siteUnreachable: "site.unreachable",
|
||||||
|
siteRecovered: "site.recovered",
|
||||||
} as const;
|
} as const;
|
||||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||||
|
|
||||||
@@ -172,6 +194,99 @@ export const DecisionUpdatedPayload = z.object({
|
|||||||
status: DecisionStatus,
|
status: DecisionStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 生命周期广播事件 payload(ADR-006)──
|
||||||
|
// payload 只放业务 ID 与订阅方做判断要用的少量事实;详情由订阅方按 ID 查业务表(事件可能晚到、重复到)。
|
||||||
|
|
||||||
|
const SubscriptionRef = z.object({
|
||||||
|
subscriptionId: Id,
|
||||||
|
mediaEntityId: Id.nullable(),
|
||||||
|
mediaKind: SubscriptionMediaKind,
|
||||||
|
});
|
||||||
|
/** 订阅已建(用户 / 智能体 / 策略) */
|
||||||
|
export const SubscriptionCreatedPayload = SubscriptionRef.extend({
|
||||||
|
origin: SubscriptionOrigin,
|
||||||
|
status: SubscriptionStatus,
|
||||||
|
});
|
||||||
|
/** 订阅已改:参数或状态(暂停 / 恢复 / 重新订阅);changes 是改动的字段名 */
|
||||||
|
export const SubscriptionUpdatedPayload = SubscriptionRef.extend({
|
||||||
|
status: SubscriptionStatus,
|
||||||
|
changes: z.array(z.string()),
|
||||||
|
});
|
||||||
|
/** 订阅已完成:reason 如 slot_satisfied / download_accepted / stop_after_reached */
|
||||||
|
export const SubscriptionCompletedPayload = SubscriptionRef.extend({ reason: z.string() });
|
||||||
|
/** 订阅已取消(删除订阅即取消,行保留) */
|
||||||
|
export const SubscriptionCancelledPayload = SubscriptionRef.extend({ reason: z.string() });
|
||||||
|
|
||||||
|
const DownloadRef = z.object({
|
||||||
|
downloadTaskId: Id,
|
||||||
|
planId: Id.nullable(),
|
||||||
|
mediaEntityId: Id.nullable(),
|
||||||
|
name: z.string(),
|
||||||
|
/** 任务行代数:removed 行复活后 +1 */
|
||||||
|
generation: NonNegInt,
|
||||||
|
});
|
||||||
|
/** 下载已完成(种子下完,尚未整理) */
|
||||||
|
export const DownloadCompletedPayload = DownloadRef;
|
||||||
|
/** 下载失败 */
|
||||||
|
export const DownloadFailedPayload = DownloadRef.extend({ from: DownloadStatus.nullable() });
|
||||||
|
/** 下载已移除(用户删除 / 清理 / 下载器里找不到) */
|
||||||
|
export const DownloadRemovedPayload = DownloadRef.extend({ from: DownloadStatus.nullable() });
|
||||||
|
|
||||||
|
/** 整理结束:一条整理记录进入 succeeded / partial / failed(统一 organization.changed 的终态) */
|
||||||
|
export const OrganizationFinishedPayload = z.object({
|
||||||
|
recordId: Id,
|
||||||
|
outcome: z.enum(["succeeded", "partial", "failed"]),
|
||||||
|
action: OrganizationAction,
|
||||||
|
trigger: OrganizationTrigger,
|
||||||
|
mediaEntityId: Id.nullable(),
|
||||||
|
downloadTaskId: Id.nullable(),
|
||||||
|
reason: z.string().nullable(),
|
||||||
|
changeSeq: z.string().regex(/^\d+$/),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ScrapeRef = z.object({
|
||||||
|
scrapeRecordId: Id,
|
||||||
|
mediaEntityId: Id.nullable(),
|
||||||
|
organizationRecordId: Id.nullable(),
|
||||||
|
trigger: ScrapeTrigger,
|
||||||
|
status: ScrapeStatus,
|
||||||
|
written: NonNegInt,
|
||||||
|
failed: NonNegInt,
|
||||||
|
});
|
||||||
|
/** 刮削完成(succeeded / partial / skipped) */
|
||||||
|
export const ScrapeCompletedPayload = ScrapeRef;
|
||||||
|
/** 刮削失败 */
|
||||||
|
export const ScrapeFailedPayload = ScrapeRef.extend({ reason: z.string().nullable() });
|
||||||
|
|
||||||
|
/** 字幕已写入媒体库(一条写入历史一条事件) */
|
||||||
|
export const SubtitleWrittenPayload = z.object({
|
||||||
|
writeId: Id,
|
||||||
|
mediaEntityId: Id,
|
||||||
|
videoLocationId: Id,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
source: SubtitleSource,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 作品进入关注(建订阅、开始下载、用户搜索 / 手动认定);reason 为触发来源 */
|
||||||
|
export const MediaWatchedPayload = z.object({ mediaEntityId: Id, reason: z.string() });
|
||||||
|
/** 作品元数据已刷新;changedFields 为本次变化的字段 */
|
||||||
|
export const MediaMetadataRefreshedPayload = z.object({
|
||||||
|
mediaEntityId: Id,
|
||||||
|
full: z.boolean(),
|
||||||
|
changedFields: z.array(z.string()),
|
||||||
|
});
|
||||||
|
/** 季集结构已切换(TMDB 默认季 ⇄ 剧集组);null 表示默认季 */
|
||||||
|
export const MediaEpisodeStructureChangedPayload = z.object({
|
||||||
|
mediaEntityId: Id,
|
||||||
|
fromGroupId: z.string().nullable(),
|
||||||
|
toGroupId: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 站点不可达:登录失效、列表抓取连续失败或检测失败;reason 为原因码 */
|
||||||
|
export const SiteUnreachablePayload = z.object({ siteId: Id, reason: z.string(), message: z.string().nullable() });
|
||||||
|
/** 站点恢复 */
|
||||||
|
export const SiteRecoveredPayload = z.object({ siteId: Id, reason: z.string() });
|
||||||
|
|
||||||
/** 事件类型 → payload schema */
|
/** 事件类型 → payload schema */
|
||||||
export const EVENT_PAYLOADS = {
|
export const EVENT_PAYLOADS = {
|
||||||
"system.heartbeat": HeartbeatPayload,
|
"system.heartbeat": HeartbeatPayload,
|
||||||
@@ -195,6 +310,22 @@ export const EVENT_PAYLOADS = {
|
|||||||
"decision.created": DecisionCreatedPayload,
|
"decision.created": DecisionCreatedPayload,
|
||||||
"decision.updated": DecisionUpdatedPayload,
|
"decision.updated": DecisionUpdatedPayload,
|
||||||
"identify.completed": IdentifyCompletedPayload,
|
"identify.completed": IdentifyCompletedPayload,
|
||||||
|
"subscription.created": SubscriptionCreatedPayload,
|
||||||
|
"subscription.updated": SubscriptionUpdatedPayload,
|
||||||
|
"subscription.completed": SubscriptionCompletedPayload,
|
||||||
|
"subscription.cancelled": SubscriptionCancelledPayload,
|
||||||
|
"download.completed": DownloadCompletedPayload,
|
||||||
|
"download.failed": DownloadFailedPayload,
|
||||||
|
"download.removed": DownloadRemovedPayload,
|
||||||
|
"organization.finished": OrganizationFinishedPayload,
|
||||||
|
"scrape.completed": ScrapeCompletedPayload,
|
||||||
|
"scrape.failed": ScrapeFailedPayload,
|
||||||
|
"subtitle.written": SubtitleWrittenPayload,
|
||||||
|
"media.watched": MediaWatchedPayload,
|
||||||
|
"media.metadata_refreshed": MediaMetadataRefreshedPayload,
|
||||||
|
"media.episode_structure_changed": MediaEpisodeStructureChangedPayload,
|
||||||
|
"site.unreachable": SiteUnreachablePayload,
|
||||||
|
"site.recovered": SiteRecoveredPayload,
|
||||||
} as const satisfies Record<EventType, z.ZodType>;
|
} as const satisfies Record<EventType, z.ZodType>;
|
||||||
|
|
||||||
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
|
export type EventPayload<T extends EventType> = z.infer<(typeof EVENT_PAYLOADS)[T]>;
|
||||||
@@ -210,6 +341,89 @@ export const TypedEventEnvelope = z.discriminatedUnion(
|
|||||||
) as unknown as [z.ZodObject, ...z.ZodObject[]],
|
) as unknown as [z.ZodObject, ...z.ZodObject[]],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生命周期广播事件清单(ADR-006):给功能清单、智能体提示与文档用。
|
||||||
|
* 已有的 release.resolved 也算一条(资源识别完成)。emittedBy 是发出的模块,说明写在什么时候发。
|
||||||
|
*/
|
||||||
|
export const LIFECYCLE_EVENTS: readonly { type: EventType; label: string; emittedBy: string; when: string }[] = [
|
||||||
|
{ type: "release.resolved", label: "资源识别完成", emittedBy: "sources-identity", when: "来源资源认到本地作品" },
|
||||||
|
{
|
||||||
|
type: "subscription.created",
|
||||||
|
label: "订阅已建",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "新建订阅(含策略自动建)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "subscription.updated",
|
||||||
|
label: "订阅已改",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "改参数、暂停、恢复、重新订阅",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "subscription.completed",
|
||||||
|
label: "订阅已完成",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "收录版本全部满足或到达停止日期",
|
||||||
|
},
|
||||||
|
{ type: "subscription.cancelled", label: "订阅已取消", emittedBy: "acquisition-organize", when: "删除订阅" },
|
||||||
|
{ type: "download.submitted", label: "下载已提交", emittedBy: "acquisition-organize", when: "种子交给下载器" },
|
||||||
|
{ type: "download.completed", label: "下载已完成", emittedBy: "acquisition-organize", when: "下载器报告下完" },
|
||||||
|
{ type: "download.failed", label: "下载失败", emittedBy: "acquisition-organize", when: "下载任务进入失败" },
|
||||||
|
{
|
||||||
|
type: "download.removed",
|
||||||
|
label: "下载已移除",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "任务被删除或在下载器里消失",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "organization.finished",
|
||||||
|
label: "整理结束",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "整理记录成功、部分成功或失败",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "scrape.completed",
|
||||||
|
label: "刮削完成",
|
||||||
|
emittedBy: "acquisition-organize",
|
||||||
|
when: "刮削记录成功 / 部分成功 / 跳过",
|
||||||
|
},
|
||||||
|
{ type: "scrape.failed", label: "刮削失败", emittedBy: "acquisition-organize", when: "刮削记录失败" },
|
||||||
|
{ type: "subtitle.written", label: "字幕已写入", emittedBy: "subtitles", when: "补字幕写入一个字幕文件" },
|
||||||
|
{
|
||||||
|
type: "media.watched",
|
||||||
|
label: "作品进入关注",
|
||||||
|
emittedBy: "sources-identity / acquisition-organize",
|
||||||
|
when: "建订阅、开始下载、搜索认定作品",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "media.metadata_refreshed",
|
||||||
|
label: "作品元数据已刷新",
|
||||||
|
emittedBy: "sources-identity",
|
||||||
|
when: "刷新元数据任务完成",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "media.episode_structure_changed",
|
||||||
|
label: "季集结构已切换",
|
||||||
|
emittedBy: "sources-identity",
|
||||||
|
when: "作品切换剧集组",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "site.unreachable",
|
||||||
|
label: "站点不可达",
|
||||||
|
emittedBy: "sources-identity",
|
||||||
|
when: "登录失效、列表连续抓取失败或检测失败",
|
||||||
|
},
|
||||||
|
{ type: "site.recovered", label: "站点恢复", emittedBy: "sources-identity", when: "不可达的站点再次正常" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生命周期事件的 outbox 幂等键:`<type>/<部分…>`。用斜杠与已有的 `<type>:<id>` 键分开
|
||||||
|
* (`download.completed:<id>` 已被 download.status_changed 占用,outbox 幂等键全表唯一)。
|
||||||
|
*/
|
||||||
|
export function lifecycleKey(type: EventType, ...parts: (string | number)[]): string {
|
||||||
|
return [type, ...parts].join("/");
|
||||||
|
}
|
||||||
|
|
||||||
export function isEventType(type: string): type is EventType {
|
export function isEventType(type: string): type is EventType {
|
||||||
return Object.hasOwn(EVENT_PAYLOADS, type);
|
return Object.hasOwn(EVENT_PAYLOADS, type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import type { KnownCapabilityId } from "./capability.ts";
|
||||||
|
import type { EventType } from "./events.ts";
|
||||||
|
import type { HookPoint } from "./hooks.ts";
|
||||||
|
|
||||||
|
// 功能模块约定(ADR-006):外围功能各自声明一份 manifest,汇总成功能清单。
|
||||||
|
// 功能清单只给智能体看(系统提示的「功能清单」一节),不做开关;前端插槽默认直接显示。
|
||||||
|
|
||||||
|
/** 前端插槽位置(apps/web/app/lib/slots.ts 按这些位置注册组件) */
|
||||||
|
export const SLOT_POSITIONS = {
|
||||||
|
"sidebar.entry": "侧栏入口",
|
||||||
|
"entity.actions": "作品页操作区",
|
||||||
|
"entity.panels": "作品页面板区",
|
||||||
|
"organize.record.actions": "整理记录行操作",
|
||||||
|
"settings.sections": "设置分区",
|
||||||
|
} as const;
|
||||||
|
export type SlotPosition = keyof typeof SLOT_POSITIONS;
|
||||||
|
|
||||||
|
export interface FeatureManifest {
|
||||||
|
/** 与服务端模块名一致(modules/<id>/) */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** 一句话说明这个功能做什么 */
|
||||||
|
summary: string;
|
||||||
|
/** 怎么用:入口在哪、智能体能做什么、不能做什么 */
|
||||||
|
usage: string;
|
||||||
|
/** 订阅的生命周期事件 */
|
||||||
|
subscribes: readonly EventType[];
|
||||||
|
/** 提供的能力(智能体可调用) */
|
||||||
|
capabilities: readonly KnownCapabilityId[];
|
||||||
|
/** 页面入口(界面上的位置) */
|
||||||
|
pages: readonly string[];
|
||||||
|
/** 挂在哪些前端插槽 */
|
||||||
|
slots: readonly SlotPosition[];
|
||||||
|
/** 注册的链式钩子 */
|
||||||
|
hooks: readonly HookPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 功能清单。新增外围功能:在这里加一份,服务端模块 manifest 指向它(featureManifest(id)) */
|
||||||
|
export const FEATURES = [
|
||||||
|
{
|
||||||
|
id: "subtitles",
|
||||||
|
name: "补字幕",
|
||||||
|
summary: "给媒体库里的视频补字幕:上传或从 SubHD / 字幕库在线搜索下载,预览配对后写到视频同目录,可看历史、撤销。",
|
||||||
|
usage:
|
||||||
|
"入口在作品页「补字幕」和整理记录行菜单「补字幕」;字幕站在站点页单列一组。智能体没有补字幕能力,用户问到时指引去作品页操作;整理结束事件已订阅,整理后自动补字幕还没做。",
|
||||||
|
subscribes: ["organization.finished"],
|
||||||
|
capabilities: [],
|
||||||
|
pages: ["作品页「补字幕」", "整理记录行菜单「补字幕」", "站点页「字幕站」"],
|
||||||
|
slots: ["entity.actions", "organize.record.actions"],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "browser",
|
||||||
|
name: "原站视图",
|
||||||
|
summary: "在服务端浏览器里打开站点原页面:人工接管、过验证、从页面下载种子或字幕。",
|
||||||
|
usage: "入口在站点页「原站视图」;智能体可以读原站页面、点击、下载种子,挑战页走视觉子流程。",
|
||||||
|
subscribes: [],
|
||||||
|
capabilities: [
|
||||||
|
"browser.snapshot",
|
||||||
|
"browser.navigate",
|
||||||
|
"browser.interact",
|
||||||
|
"browser.download",
|
||||||
|
"browser.solve_challenge",
|
||||||
|
],
|
||||||
|
pages: ["站点页「原站视图」"],
|
||||||
|
slots: [],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "desktop",
|
||||||
|
name: "桌面模式",
|
||||||
|
summary: "原站视图的远程桌面:直接操作浏览器容器的桌面,处理复杂验证。",
|
||||||
|
usage: "入口在原站视图的桌面模式;只给人用,智能体不操作。",
|
||||||
|
subscribes: [],
|
||||||
|
capabilities: [],
|
||||||
|
pages: ["原站视图「桌面模式」"],
|
||||||
|
slots: [],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "websearch",
|
||||||
|
name: "网页搜索",
|
||||||
|
summary: "用 Brave 搜索站外公开信息,并读取搜索结果的正文(结果是不可信数据)。",
|
||||||
|
usage: "在设置 → 智能体 → 网页搜索里填 Key 后可用;智能体先搜索,再读其中一条结果的正文。",
|
||||||
|
subscribes: [],
|
||||||
|
capabilities: ["web.search", "web.fetch"],
|
||||||
|
pages: ["设置 → 智能体 → 网页搜索"],
|
||||||
|
slots: [],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "mcp",
|
||||||
|
name: "MCP 服务",
|
||||||
|
summary: "把能力以 MCP 工具对外提供,外部客户端用 API Key 接入,授权与智能体相同。",
|
||||||
|
usage: "在设置 → 智能体 → 工具与 MCP 启用并选允许的 API Key;智能体自己不需要经 MCP 调用。",
|
||||||
|
subscribes: [],
|
||||||
|
capabilities: [],
|
||||||
|
pages: ["设置 → 智能体 → 工具与 MCP"],
|
||||||
|
slots: [],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "schedules",
|
||||||
|
name: "定时任务与巡检",
|
||||||
|
summary: "各模块的周期任务统一调度;内置智能体巡检模板(默认关闭)。",
|
||||||
|
usage: "在日志 → 定时任务里查看、启停;智能体只能起草未启用的巡检,由用户检查后启用。",
|
||||||
|
subscribes: [],
|
||||||
|
capabilities: ["schedule.draft_check"],
|
||||||
|
pages: ["日志 → 定时任务"],
|
||||||
|
slots: [],
|
||||||
|
hooks: [],
|
||||||
|
},
|
||||||
|
] as const satisfies readonly FeatureManifest[];
|
||||||
|
|
||||||
|
export type FeatureId = (typeof FEATURES)[number]["id"];
|
||||||
|
|
||||||
|
export function featureManifest(id: FeatureId): FeatureManifest {
|
||||||
|
return FEATURES.find((f) => f.id === id)!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// 链式钩子挂点(ADR-006,参考 MoviePilot ChainEventType):同步、按注册顺序执行、可改写或拦截。
|
||||||
|
// 这里只登记挂点名称与语义(给功能清单、智能体提示与文档用);入参 / 返回值类型在服务端
|
||||||
|
// apps/server/src/hooks/points.ts,因为它们用到 domain 的解析结果等类型。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* - rewrite:每个钩子拿到上一个钩子的结果,返回新值或 undefined(不改);
|
||||||
|
* - intercept:任一钩子返回拒绝原因即停止,后面的钩子不再执行。
|
||||||
|
*/
|
||||||
|
export type HookKind = "rewrite" | "intercept";
|
||||||
|
|
||||||
|
export const HOOK_POINTS = {
|
||||||
|
"name.recognize": {
|
||||||
|
kind: "rewrite",
|
||||||
|
label: "名称识别",
|
||||||
|
when: "资源标题解析之后、按名称召回作品之前",
|
||||||
|
effect: "改写解析结果(标题、年份、季集、外部 ID)",
|
||||||
|
},
|
||||||
|
"organize.rename": {
|
||||||
|
kind: "rewrite",
|
||||||
|
label: "整理改名",
|
||||||
|
when: "按命名模板生成目标路径之后",
|
||||||
|
effect: "改写主视频的目标文件名(同主干的字幕等附属文件跟着改)",
|
||||||
|
},
|
||||||
|
"organize.intercept": {
|
||||||
|
kind: "intercept",
|
||||||
|
label: "整理拦截",
|
||||||
|
when: "整理计划生成后、写整理记录之前",
|
||||||
|
effect: "返回原因即拒绝本次整理(记录为失败,原因 intercepted:<原因>)",
|
||||||
|
},
|
||||||
|
"resource.selection": {
|
||||||
|
kind: "rewrite",
|
||||||
|
label: "资源选择",
|
||||||
|
when: "订阅 / 收录版本重评时候选排好序之后、逐条评估之前",
|
||||||
|
effect: "调整候选顺序或过滤掉一部分(不能加入新候选)",
|
||||||
|
},
|
||||||
|
"subscription.completion.check": {
|
||||||
|
kind: "intercept",
|
||||||
|
label: "订阅完成判定",
|
||||||
|
when: "订阅满足完成条件、标记完成之前",
|
||||||
|
effect: "追加完成条件:返回原因即本次不完成,订阅继续追踪",
|
||||||
|
},
|
||||||
|
} as const satisfies Record<string, { kind: HookKind; label: string; when: string; effect: string }>;
|
||||||
|
|
||||||
|
export type HookPoint = keyof typeof HOOK_POINTS;
|
||||||
@@ -14,6 +14,8 @@ export * from "./download-board.ts";
|
|||||||
export * from "./episode-mapping.ts";
|
export * from "./episode-mapping.ts";
|
||||||
export * from "./events.ts";
|
export * from "./events.ts";
|
||||||
export * from "./execution.ts";
|
export * from "./execution.ts";
|
||||||
|
export * from "./features.ts";
|
||||||
|
export * from "./hooks.ts";
|
||||||
export * from "./identity.ts";
|
export * from "./identity.ts";
|
||||||
export * from "./instances.ts";
|
export * from "./instances.ts";
|
||||||
export * from "./library-versions.ts";
|
export * from "./library-versions.ts";
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ export interface OutboxRoute {
|
|||||||
priority?: number;
|
priority?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 返回 null 表示该事件无需建任务,直接标记已投递 */
|
/**
|
||||||
export type OutboxRouteFn = (event: OutboxEvent) => OutboxRoute | null;
|
* 返回 null 表示该事件无需建任务,直接标记已投递。
|
||||||
|
* 返回数组 = 广播给多个订阅方(ADR-006):第一个任务用事件的幂等键,其余用 `<键>#<任务名>`,重投时由引擎幂等去重。
|
||||||
|
*/
|
||||||
|
export type OutboxRouteFn = (event: OutboxEvent) => OutboxRoute | readonly OutboxRoute[] | null;
|
||||||
|
|
||||||
export interface OutboxRelayOptions {
|
export interface OutboxRelayOptions {
|
||||||
db: AnyDb;
|
db: AnyDb;
|
||||||
@@ -82,11 +85,13 @@ export class OutboxRelay {
|
|||||||
for (const ev of rows.rows) {
|
for (const ev of rows.rows) {
|
||||||
try {
|
try {
|
||||||
const routeFn = this.opts.routes[ev.type];
|
const routeFn = this.opts.routes[ev.type];
|
||||||
const route = routeFn ? routeFn(ev) : null;
|
const routed = routeFn ? routeFn(ev) : null;
|
||||||
if (route) {
|
const routes = (routed === null ? [] : Array.isArray(routed) ? routed : [routed]) as readonly OutboxRoute[];
|
||||||
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
|
const baseKey = ev.idempotency_key ?? OutboxRelay.idempotencyKey(ev.id);
|
||||||
|
const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId;
|
||||||
|
for (const [i, route] of routes.entries()) {
|
||||||
await this.opts.engine.enqueue(route.task, route.input, {
|
await this.opts.engine.enqueue(route.task, route.input, {
|
||||||
idempotencyKey: ev.idempotency_key ?? OutboxRelay.idempotencyKey(ev.id),
|
idempotencyKey: i === 0 ? baseKey : `${baseKey}#${route.task}`,
|
||||||
traceId: route.traceId ?? (typeof payloadTrace === "string" ? payloadTrace : randomUUID()),
|
traceId: route.traceId ?? (typeof payloadTrace === "string" ? payloadTrace : randomUUID()),
|
||||||
...(route.priority === undefined ? {} : { priority: route.priority }),
|
...(route.priority === undefined ? {} : { priority: route.priority }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { sql } from "kysely";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { type EnqueueOptions, OutboxRelay, type TaskEngine } from "../src/index.ts";
|
||||||
|
import {
|
||||||
|
connectDb,
|
||||||
|
dropSchemas,
|
||||||
|
migrateBizSchema,
|
||||||
|
pgAvailable,
|
||||||
|
resetSchemas,
|
||||||
|
type TestDb,
|
||||||
|
testSchemas,
|
||||||
|
} from "./helpers.ts";
|
||||||
|
|
||||||
|
// 生命周期广播(ADR-006):一条 outbox 事件路由给多个订阅方任务。第一个任务用事件的幂等键,
|
||||||
|
// 其余用 <键>#<任务名>;失败重投时已入队的由引擎幂等键去重,没入队的补上。
|
||||||
|
|
||||||
|
const schemas = testSchemas("outbox_bc");
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("OutboxRelay 广播给多个订阅方", () => {
|
||||||
|
let db: TestDb;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await resetSchemas(schemas);
|
||||||
|
db = connectDb(schemas.biz);
|
||||||
|
await migrateBizSchema(db, schemas.biz);
|
||||||
|
}, 30_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await db?.destroy();
|
||||||
|
await dropSchemas(schemas);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("每个订阅方各入队一次,幂等键按订阅方区分;中途失败重投时同键去重", async () => {
|
||||||
|
await sql`INSERT INTO outbox_event (type, payload, idempotency_key)
|
||||||
|
VALUES ('thing.done', '{"id":"t1"}'::jsonb, 'thing.done/t1')`.execute(db);
|
||||||
|
const keys = new Map<string, number>();
|
||||||
|
let failB = true;
|
||||||
|
const engine: Pick<TaskEngine, "enqueue"> = {
|
||||||
|
enqueue: async (task: string, _input: unknown, opts: EnqueueOptions) => {
|
||||||
|
if (task === "b" && failB) {
|
||||||
|
failB = false;
|
||||||
|
throw new Error("引擎暂时不可用");
|
||||||
|
}
|
||||||
|
keys.set(opts.idempotencyKey, (keys.get(opts.idempotencyKey) ?? 0) + 1);
|
||||||
|
return { runId: opts.idempotencyKey, deduplicated: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const relay = new OutboxRelay({
|
||||||
|
db,
|
||||||
|
engine,
|
||||||
|
routes: {
|
||||||
|
"thing.done": (ev) => [
|
||||||
|
{ task: "a", input: ev.payload },
|
||||||
|
{ task: "b", input: ev.payload },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const first = await relay.pollOnce();
|
||||||
|
expect(first).toEqual({ published: 0, failed: 1 });
|
||||||
|
// 退避期过了再投:a 用同一把键再入一次(真实引擎按幂等键返回已有 run),b 补上
|
||||||
|
await sql`UPDATE outbox_event SET next_attempt_at = now()`.execute(db);
|
||||||
|
expect(await relay.pollOnce()).toEqual({ published: 1, failed: 0 });
|
||||||
|
expect([...keys.keys()].sort()).toEqual(["thing.done/t1", "thing.done/t1#b"]);
|
||||||
|
expect(keys.get("thing.done/t1")).toBe(2);
|
||||||
|
expect(keys.get("thing.done/t1#b")).toBe(1);
|
||||||
|
const row = await sql<{ status: string }>`SELECT status FROM outbox_event`.execute(db);
|
||||||
|
expect(row.rows).toEqual([{ status: "dispatched" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("单个订阅方保持原来的键;返回空数组 / null 直接标记已投递", async () => {
|
||||||
|
await sql`DELETE FROM outbox_event`.execute(db);
|
||||||
|
await sql`INSERT INTO outbox_event (type, payload, idempotency_key) VALUES
|
||||||
|
('one.sub', '{}'::jsonb, 'k-one'), ('no.sub', '{}'::jsonb, 'k-none')`.execute(db);
|
||||||
|
const keys: string[] = [];
|
||||||
|
const relay = new OutboxRelay({
|
||||||
|
db,
|
||||||
|
engine: {
|
||||||
|
enqueue: async (_t: string, _i: unknown, o: EnqueueOptions) => {
|
||||||
|
keys.push(o.idempotencyKey);
|
||||||
|
return { runId: "x", deduplicated: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
routes: { "one.sub": () => [{ task: "a", input: {} }], "no.sub": () => [] },
|
||||||
|
});
|
||||||
|
expect(await relay.pollOnce()).toEqual({ published: 2, failed: 0 });
|
||||||
|
expect(keys).toEqual(["k-one"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user