From 1db689d9a716e3fae709f3cd47d5a1a9b0d8f705 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:27:02 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E5=B9=BF=E6=92=AD=E4=BA=8B=E4=BB=B6=E4=B8=8E=E9=93=BE?= =?UTF-8?q?=E5=BC=8F=E9=92=A9=E5=AD=90=E8=AE=BE=E6=96=BD=EF=BC=88ADR-006?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性) 的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点; FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置 - outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅, 没有订阅方时也被中继认领并镜像到 SSE - 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销; ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks - ADR-006 与 server README「如何写一个功能模块」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/README.md | 29 +++ apps/server/src/context.ts | 61 ++++- apps/server/src/hooks/index.ts | 44 ++++ apps/server/src/hooks/points.ts | 81 +++++++ apps/server/src/hooks/registry.ts | 126 +++++++++++ apps/server/src/modules/types.ts | 24 +- apps/server/src/worker/index.ts | 14 +- apps/server/test/hooks/hook-registry.test.ts | 175 +++++++++++++++ apps/server/vitest.config.ts | 2 + docs/adr/ADR-006-lifecycle-events.md | 93 ++++++++ packages/contracts/src/events.ts | 224 ++++++++++++++++++- packages/contracts/src/features.ts | 119 ++++++++++ packages/contracts/src/hooks.ts | 44 ++++ packages/contracts/src/index.ts | 2 + packages/tasks/src/outbox-relay.ts | 17 +- packages/tasks/test/outbox-broadcast.test.ts | 88 ++++++++ 16 files changed, 1121 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/hooks/index.ts create mode 100644 apps/server/src/hooks/points.ts create mode 100644 apps/server/src/hooks/registry.ts create mode 100644 apps/server/test/hooks/hook-registry.test.ts create mode 100644 docs/adr/ADR-006-lifecycle-events.md create mode 100644 packages/contracts/src/features.ts create mode 100644 packages/contracts/src/hooks.ts create mode 100644 packages/tasks/test/outbox-broadcast.test.ts diff --git a/apps/server/README.md b/apps/server/README.md index d32662ae..0fa90cfa 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -10,6 +10,7 @@ src/ context.ts bootstrap():ServerContext、任务注册表(模块 + 空壳兜底)、outbox 路由合并、连接引擎 http/app.ts createApp():系统路由 + 逐个挂 SERVER_MODULES 的 registerRoutes worker/index.ts startWorker():引擎 worker + OutboxRelay + 各模块 start() + hooks/ 链式钩子注册表与调用入口(ADR-006) modules/ types.ts ServerModule / ServerContext / OutboxWriter / SecretStore / SettingsReader(共享,不随子任务改) index.ts SERVER_MODULES 清单 @@ -64,3 +65,31 @@ export const sourcesIdentityModule: ServerModule = { `ServerContext` 字段:`db`(Kysely)、`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` 连接。 + +## 如何写一个功能模块(ADR-006) + +外围功能(补字幕这类)按下面的约定接进来,不做插件系统、不做开关。样板是 `modules/subtitles/`。 + +1. **登记 manifest**:在 `packages/contracts/src/features.ts` 的 `FEATURES` 里加一份(名称、说明、怎么用、订阅的事件、能力、页面入口、插槽、钩子)。它会进智能体系统提示「能力地图」末尾的功能清单;`test/modules.test.ts` 核对模块与清单一一对应。 +2. **建模块**:`modules//index.ts` 导出 `ServerModule`,`name` 与 manifest id 相同,写 `manifest: featureManifest("")`,加进 `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/.tsx` 用插槽注册(作品页操作区 / 面板区、整理记录行操作、侧栏、设置分区),在 `features/index.ts` 调一次;默认直接显示。 +7. **智能体能力**(可选):照常在 contracts 定义能力、在 `registerCapabilities` 挂处理器,并登记 Skill 与能力地图(见仓库根 CLAUDE.md)。 + diff --git a/apps/server/src/context.ts b/apps/server/src/context.ts index d895fea1..bd184e0f 100644 --- a/apps/server/src/context.ts +++ b/apps/server/src/context.ts @@ -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 { createDefaultRegistry, type LogSink, OpenWorkflowEngine, type OutboxRelay, + type OutboxRoute, type OutboxRouteFn, type TaskEngine, TaskRegistry, } from "@umt/tasks"; import type { Logger } from "pino"; import type { Config } from "./config.ts"; +import { HookRegistry } from "./hooks/registry.ts"; import type { EventBusPort } from "./http/sse.ts"; import { assertCapabilityCount, createCapabilityRegistry } from "./modules/agent/capability-registry.ts"; import { createSecretStore, createSettingsReader } from "./modules/auth-settings/provided.ts"; @@ -97,24 +99,66 @@ export function buildTaskRegistry(modules: readonly ServerModule[], ctx: ServerC return registry; } -/** 合并各模块的 outbox 路由;同一事件 type 只能由一个模块认领 */ +const BROADCAST_TYPES = new Set(LIFECYCLE_EVENTS.map((e) => e.type)); + +/** + * 合并各模块的 outbox 路由。生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)可以有多个订阅方,按模块顺序合成一个路由: + * 各订阅方各自返回任务(或 null),合起来交给中继逐个入队;某个订阅方的路由函数抛错只记日志,不连累别人。 + * 其它事件(内部流程,如 release.observed → resolveObservation)仍只能由一个模块认领。 + */ export function collectOutboxRoutes( modules: readonly ServerModule[], ctx: ServerContext, ): Record { - const routes: Record = {}; - const owner = new Map(); + const subscribers = new Map(); for (const m of modules) { for (const [type, fn] of Object.entries(m.outboxRoutes?.(ctx) ?? {})) { - const prev = owner.get(type); - if (prev) throw new Error(`outbox 事件 ${type} 被模块 ${prev} 与 ${m.name} 重复认领`); - owner.set(type, m.name); - routes[type] = fn; + const prev = subscribers.get(type) ?? []; + if (prev.length && !BROADCAST_TYPES.has(type)) + throw new Error(`outbox 事件 ${type} 被模块 ${prev[0]!.module} 与 ${m.name} 重复认领`); + subscribers.set(type, [...prev, { module: m.name, fn }]); } } + const routes: Record = {}; + 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; + 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; } +/** 链式钩子注册表:按模块顺序调 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 { role: ServerRole; config: Config; @@ -158,6 +202,7 @@ export async function bootstrap(opts: BootstrapOptions): Promise { }; const tasks = buildTaskRegistry(opts.modules, ctx); for (const m of opts.modules) m.registerCapabilities?.(ctx.capabilities, ctx); + ctx.hooks = buildHookRegistry(opts.modules, ctx); // 装配了 agent 模块(即完整模块清单)时,首批 14 个能力必须全部挂上处理器 if (opts.modules.some((m) => m.name === "agent")) assertCapabilityCount(ctx.capabilities); engine.attach( diff --git a/apps/server/src/hooks/index.ts b/apps/server/src/hooks/index.ts new file mode 100644 index 00000000..84d7733f --- /dev/null +++ b/apps/server/src/hooks/index.ts @@ -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, point: HookPoint): boolean { + return ctx.hooks?.has(point) ?? false; +} + +/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */ +export async function rewriteHook

( + ctx: Pick, + point: P, + value: HookValue

, + context: HookContext

| (() => HookContext

), + accept?: (v: HookValue

) => boolean, +): Promise> { + const h = ctx.hooks; + if (!h?.has(point)) return value; + return h.runRewrite(point, value, typeof context === "function" ? context() : context, accept); +} + +/** 拦截:返回第一个拒绝,没有钩子或都放行时返回 null */ +export async function interceptHook

( + ctx: Pick, + point: P, + context: HookContext

| (() => HookContext

), +): Promise { + const h = ctx.hooks; + if (!h?.has(point)) return null; + return h.runIntercept(point, typeof context === "function" ? context() : context); +} diff --git a/apps/server/src/hooks/points.ts b/apps/server/src/hooks/points.ts new file mode 100644 index 00000000..c49c8846 --- /dev/null +++ b/apps/server/src/hooks/points.ts @@ -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; + }; + }; +} + +type KindOf

= (typeof HOOK_POINTS)[P]["kind"]; +export type RewritePoint = { [P in HookPoint]: KindOf

extends "rewrite" ? P : never }[HookPoint]; +export type InterceptPoint = { [P in HookPoint]: KindOf

extends "intercept" ? P : never }[HookPoint]; + +export type HookValue

= HookPointTypes[P]["value"]; +export type HookContext

= Readonly; + +type MaybePromise = T | Promise; +/** 返回新值;返回 undefined 表示不改 */ +export type RewriteHook

= ( + value: HookValue

, + context: HookContext

, +) => MaybePromise | undefined>; +/** 返回拒绝原因;返回 null / undefined 表示放行 */ +export type InterceptHook

= ( + context: HookContext

, +) => MaybePromise; diff --git a/apps/server/src/hooks/registry.ts b/apps/server/src/hooks/registry.ts new file mode 100644 index 00000000..5da11441 --- /dev/null +++ b/apps/server/src/hooks/registry.ts @@ -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

(point: P, fn: RewriteHook

, opts?: HookOptions): void; + intercept

(point: P, fn: InterceptHook

, opts?: HookOptions): void; +} + +interface Entry { + owner: string; + name: string; + timeoutMs: number; + // biome-ignore lint/suspicious/noExplicitAny: 按挂点分表存,调用时由 run* 的类型参数约束 + fn: (...args: any[]) => unknown; +} + +const TIMEOUT = Symbol("timeout"); +const FAILED = Symbol("failed"); + +export interface HookRejection { + reason: string; + /** 拒绝的钩子名 */ + by: string; +} + +export class HookRegistry { + private readonly hooks = new Map(); + + constructor(private readonly logger?: Pick) {} + + /** 给某个模块的注册入口(owner 进日志) */ + forModule(owner: string): HookRegistrar { + const add = (point: HookPoint, kind: "rewrite" | "intercept", fn: Entry["fn"], opts: HookOptions = {}) => { + if (HOOK_POINTS[point].kind !== kind) + throw new Error(`钩子挂点 ${point} 是 ${HOOK_POINTS[point].kind} 类,不能用 ${kind} 注册`); + const list = this.hooks.get(point) ?? []; + list.push({ + owner, + name: opts.name ?? `${owner}#${list.filter((e) => e.owner === owner).length + 1}`, + timeoutMs: opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS, + fn, + }); + this.hooks.set(point, list); + }; + return { + rewrite: (point, fn, opts) => add(point, "rewrite", fn, opts), + intercept: (point, fn, opts) => add(point, "intercept", fn, opts), + }; + } + + has(point: HookPoint): boolean { + return (this.hooks.get(point)?.length ?? 0) > 0; + } + + /** 已注册的钩子(挂点 → 钩子名),给功能清单与诊断用 */ + list(): Record { + return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)])); + } + + /** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */ + async runRewrite

( + point: P, + value: HookValue

, + context: HookContext

, + accept?: (v: HookValue

) => boolean, + ): Promise> { + let cur = value; + for (const e of this.hooks.get(point) ?? []) { + const r = await this.call(point, e, [cur, context]); + if (r === undefined || r === TIMEOUT || r === FAILED) continue; + if (accept && !accept(r as HookValue

)) { + this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过"); + continue; + } + cur = r as HookValue

; + } + return cur; + } + + /** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */ + async runIntercept

(point: P, context: HookContext

): Promise { + for (const e of this.hooks.get(point) ?? []) { + const r = await this.call(point, e, [context]); + if (typeof r === "string" && r.trim()) return { reason: r.trim(), by: e.name }; + } + return null; + } + + private async call(point: HookPoint, e: Entry, args: unknown[]): Promise { + let timer: ReturnType | undefined; + try { + const r = await Promise.race([ + Promise.resolve().then(() => e.fn(...args)), + new Promise((resolve) => { + timer = setTimeout(() => resolve(TIMEOUT), e.timeoutMs); + }), + ]); + if (r === TIMEOUT) this.logger?.warn({ point, hook: e.name, timeoutMs: e.timeoutMs }, "钩子超时,已跳过"); + return r; + } catch (err) { + this.logger?.warn( + { point, hook: e.name, err: { message: err instanceof Error ? err.message : String(err) } }, + "钩子出错,已跳过", + ); + return FAILED; + } finally { + clearTimeout(timer); + } + } +} diff --git a/apps/server/src/modules/types.ts b/apps/server/src/modules/types.ts index 2c5951ca..df6838a6 100644 --- a/apps/server/src/modules/types.ts +++ b/apps/server/src/modules/types.ts @@ -1,10 +1,18 @@ import type { OpenAPIHono } from "@hono/zod-openapi"; 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 { OutboxRelay, OutboxRouteFn, TaskEngine, TaskRegistry } from "@umt/tasks"; import type { Logger } from "pino"; import type { Config } from "../config.ts"; +import type { HookRegistrar, HookRegistry } from "../hooks/registry.ts"; import type { AuthEnv } from "../http/auth.ts"; import type { EventBusPort } from "../http/sse.ts"; @@ -72,6 +80,11 @@ export interface ServerContext { settings: SettingsReader; /** 能力注册中心(Web/Pi/CLI/MCP 共用一条 invoke 路径) */ capabilities: CapabilityRegistry; + /** + * 链式钩子(ADR-006):bootstrap 装配,模块在 registerHooks 里注册;业务节点经 hooks/index.ts 的 + * rewriteHook / interceptHook 调用。测试里手搭的上下文可以没有(等同于没有注册)。 + */ + hooks?: HookRegistry; } export interface ServerModule { @@ -84,8 +97,15 @@ export interface ServerModule { * 同名任务只能由一个模块注册;没人注册的 §3.1-8 任务由 @umt/tasks 的空壳兜底。 */ registerTasks(registry: TaskRegistry, ctx: ServerContext): void; - /** 所有角色:outbox 事件 type → 任务路由;同一 type 只能由一个模块认领 */ + /** + * 所有角色:outbox 事件 type → 任务路由。生命周期广播事件(ADR-006)可以被多个模块订阅, + * 中继按模块顺序把每个订阅方的路由都投递一遍;返回 null 表示这条事件本模块不处理。 + */ outboxRoutes?(ctx: ServerContext): Record; + /** 外围功能的说明(功能清单只给智能体看,不做开关);取 contracts 的 featureManifest(id) */ + manifest?: FeatureManifest; + /** 所有角色:注册链式钩子(同步、按注册顺序、可改写或拦截;见 hooks/registry.ts) */ + registerHooks?(hooks: HookRegistrar, ctx: ServerContext): void; /** 所有角色:用 contracts 的 capabilitySpec(id) 注册能力处理器 */ registerCapabilities?(registry: CapabilityRegistry, ctx: ServerContext): void; /** worker / all 角色:启动轮询器、定时器等后台循环(引擎与中继已启动) */ diff --git a/apps/server/src/worker/index.ts b/apps/server/src/worker/index.ts index 14761bcc..26aa9823 100644 --- a/apps/server/src/worker/index.ts +++ b/apps/server/src/worker/index.ts @@ -18,8 +18,20 @@ import { /** * outbox → SSE 桥:这些前缀的 outbox 事件在中继投递成功后镜像到实时总线(payload 原样,带业务 id), * 首页、收件箱、任务、来源与下载页据此失效刷新。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([EventType.feedDue]); 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), diff --git a/apps/server/test/hooks/hook-registry.test.ts b/apps/server/test/hooks/hook-registry.test.ts new file mode 100644 index 00000000..3d6d84af --- /dev/null +++ b/apps/server/test/hooks/hook-registry.test.ts @@ -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((r) => setTimeout(() => r("迟到.mkv"), 5_000)), { + timeoutMs: 30, + }); + m.rewrite("organize.rename", (n) => `${n}.ok`); + const t0 = Date.now(); + const out = await reg.runRewrite("organize.rename", "A.mkv", { + targetPath: "/l/A.mkv", + sourcePath: "/s/A.mkv", + mediaEntityId: "e", + mediaType: "movie", + season: null, + episodes: null, + }); + expect(out).toBe("A.mkv.ok"); + expect(Date.now() - t0).toBeLessThan(2_000); + expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "slow#1", timeoutMs: 30 }); + }); + + it("没有注册时零开销:不构造上下文、原样返回同一个对象;没有注册表(手搭上下文)同样", async () => { + const ctxBuilder = vi.fn(() => recognizeCtx); + const p = parsed("t"); + const empty = new HookRegistry(); + // 别的挂点有钩子不影响这里 + empty.forModule("m").intercept("organize.intercept", () => "x"); + expect(hasHook({ hooks: empty }, "name.recognize")).toBe(false); + expect(await rewriteHook({ hooks: empty }, "name.recognize", p, ctxBuilder)).toBe(p); + expect(await rewriteHook({}, "name.recognize", p, ctxBuilder)).toBe(p); + expect(await interceptHook({}, "subscription.completion.check", () => completionCtx)).toBeNull(); + expect(ctxBuilder).not.toHaveBeenCalled(); + }); + + it("调用方给了 accept 时,结果不可用的钩子跳过,后面的钩子拿到的是上一个可用值", async () => { + const { logger, warn } = warnings(); + const reg = new HookRegistry(logger); + const m = reg.forModule("m"); + m.rewrite("organize.rename", (n) => `../${n}`); + m.rewrite("organize.rename", (n) => `${n}.ok`); + const c = { + targetPath: "/a", + sourcePath: "/b", + mediaEntityId: "e", + mediaType: "movie" as const, + season: null, + episodes: null, + }; + expect(await rewriteHook({ hooks: reg }, "organize.rename", "A.mkv", c, (v) => !v.includes("/"))).toBe("A.mkv.ok"); + expect(warn.mock.calls[0]![0]).toMatchObject({ point: "organize.rename", hook: "m#1" }); + }); + + it("用错挂点类型注册直接报错", () => { + const reg = new HookRegistry(); + // biome-ignore lint/suspicious/noExplicitAny: 故意绕过类型检查 + const m = reg.forModule("m") as any; + expect(() => m.rewrite("organize.intercept", () => "x")).toThrow(/intercept 类/); + }); +}); diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index 7c6b5034..75b2c5d2 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -14,6 +14,8 @@ const UNIT = [ "test/search/work-match.test.ts", "test/auth.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/enrich-sources.test.ts", "test/invoke-limits.test.ts", diff --git a/docs/adr/ADR-006-lifecycle-events.md b/docs/adr/ADR-006-lifecycle-events.md new file mode 100644 index 00000000..ac4cbb1a --- /dev/null +++ b/docs/adr/ADR-006-lifecycle-events.md @@ -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 幂等键(`/<部分…>`),订阅任务用引擎幂等键 | 不需要 | + +**何时用哪类**:只想在「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/` | +| `subscription.updated` | 订阅已改(参数、暂停、恢复、重新订阅;`changes` 列字段) | 订阅 API 修改、状态动作 | 无(每次改动一条) | +| `subscription.completed` | 订阅已完成 | `completeSubscription`、停止日期到达 | `…//<历史行 id>`、`…//stop_after:<日期>` | +| `subscription.cancelled` | 订阅已取消(删除即取消) | 订阅 API 删除 / 改状态为 cancelled | 无 | +| `download.submitted` | 下载已提交(已有) | `downloads.ts` 提交 | 已有 | +| `download.completed` / `download.failed` / `download.removed` | 下载完成 / 失败 / 移除 | `downloads.ts` `emitStatus`(与 `download.status_changed` 同事务) | `/<任务 id>/g<代数>` | +| `organization.finished` | 整理结束,`outcome` = succeeded / partial / failed(统一 `organization.changed` 的终态) | `emitRecord`(所有整理记录写入都经过它) | `organization.finished/<记录 id>/` | +| `scrape.completed` / `scrape.failed` | 刮削完成(含部分 / 跳过)/ 失败 | `scrape.ts` `runScrapeRecord` 收尾 | `/<记录 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>`,其余只在跨越时发 | + +幂等键用斜杠分隔,与已有的 `:` 键分开(`download.completed:` 已被 `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("")`;`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,事件要能跨进程、跨重启。 diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 92fb7a08..dbf81f15 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -4,8 +4,11 @@ import { Id, NonNegInt, Timestamp } from "./common.ts"; import { DownloadStatus, PauseReason, TaskKind, TaskStatus } from "./execution.ts"; import { IdentifyCompletedPayload } from "./files.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 { SubtitleLanguage, SubtitleSource } from "./subtitles.ts"; /** SSE 事件信封:seq 在进程内单调递增,客户端断线后用 ?after= 补读 */ export const EventEnvelope = z.object({ @@ -24,10 +27,11 @@ export type EventEnvelope

= { /** * 已知事件类型:`域.动作`。新增事件先在这里加类型与 payload schema。 * - * 没有单独的 download.completed / organization.completed 事件,约定用现有事件表达(W2 收口): - * - "下载完成" = `download.status_changed` 且 `to === "completed"`(outbox 幂等键 `download.completed:`,只发一次; - * acquisition-organize 据此入队 verifyAndImport); - * - "入库完成" = `organization.changed` 且 `status === "succeeded"`(或 partial);`changeSeq` 与公开增量 API 游标一致。 + * 状态流事件(`download.status_changed`、`organization.changed`)每次状态变化都发,给 SSE 与增量游标用; + * 生命周期广播事件(LIFECYCLE_EVENTS,ADR-006)只在业务节点发一次,给功能模块订阅: + * - 下载完成 = `download.completed`(与 `download.status_changed → completed` 同事务发出;acquisition-organize + * 仍按后者入队 verifyAndImport,幂等键 `download.completed:` 属于后者); + * - 整理结束 = `organization.finished`,`outcome` 区分成功 / 部分成功 / 失败。 */ export const EventType = { heartbeat: "system.heartbeat", @@ -59,6 +63,24 @@ export const EventType = { decisionUpdated: "decision.updated", 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; export type EventType = (typeof EventType)[keyof typeof EventType]; @@ -172,6 +194,99 @@ export const DecisionUpdatedPayload = z.object({ 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 */ export const EVENT_PAYLOADS = { "system.heartbeat": HeartbeatPayload, @@ -195,6 +310,22 @@ export const EVENT_PAYLOADS = { "decision.created": DecisionCreatedPayload, "decision.updated": DecisionUpdatedPayload, "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; export type EventPayload = z.infer<(typeof EVENT_PAYLOADS)[T]>; @@ -210,6 +341,89 @@ export const TypedEventEnvelope = z.discriminatedUnion( ) 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 幂等键:`/<部分…>`。用斜杠与已有的 `:` 键分开 + * (`download.completed:` 已被 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 { return Object.hasOwn(EVENT_PAYLOADS, type); } diff --git a/packages/contracts/src/features.ts b/packages/contracts/src/features.ts new file mode 100644 index 00000000..7c671fb5 --- /dev/null +++ b/packages/contracts/src/features.ts @@ -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: 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)!; +} diff --git a/packages/contracts/src/hooks.ts b/packages/contracts/src/hooks.ts new file mode 100644 index 00000000..c4ebc160 --- /dev/null +++ b/packages/contracts/src/hooks.ts @@ -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; + +export type HookPoint = keyof typeof HOOK_POINTS; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 23409032..b0733398 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,6 +14,8 @@ export * from "./download-board.ts"; export * from "./episode-mapping.ts"; export * from "./events.ts"; export * from "./execution.ts"; +export * from "./features.ts"; +export * from "./hooks.ts"; export * from "./identity.ts"; export * from "./instances.ts"; export * from "./library-versions.ts"; diff --git a/packages/tasks/src/outbox-relay.ts b/packages/tasks/src/outbox-relay.ts index 1569e22f..c21676cd 100644 --- a/packages/tasks/src/outbox-relay.ts +++ b/packages/tasks/src/outbox-relay.ts @@ -21,8 +21,11 @@ export interface OutboxRoute { 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 { db: AnyDb; @@ -82,11 +85,13 @@ export class OutboxRelay { for (const ev of rows.rows) { try { const routeFn = this.opts.routes[ev.type]; - const route = routeFn ? routeFn(ev) : null; - if (route) { - const payloadTrace = (ev.payload as { traceId?: unknown } | null)?.traceId; + const routed = routeFn ? routeFn(ev) : null; + const routes = (routed === null ? [] : Array.isArray(routed) ? routed : [routed]) as readonly OutboxRoute[]; + 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, { - idempotencyKey: ev.idempotency_key ?? OutboxRelay.idempotencyKey(ev.id), + idempotencyKey: i === 0 ? baseKey : `${baseKey}#${route.task}`, traceId: route.traceId ?? (typeof payloadTrace === "string" ? payloadTrace : randomUUID()), ...(route.priority === undefined ? {} : { priority: route.priority }), }); diff --git a/packages/tasks/test/outbox-broadcast.test.ts b/packages/tasks/test/outbox-broadcast.test.ts new file mode 100644 index 00000000..6b2aa697 --- /dev/null +++ b/packages/tasks/test/outbox-broadcast.test.ts @@ -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(); + let failB = true; + const engine: Pick = { + 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"]); + }); +}); From 7aa5182f3d60f627e30cbf73c6c07a75e254d829 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:27:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E5=8A=9F=E8=83=BD=E6=B8=85?= =?UTF-8?q?=E5=8D=95=E8=BF=9B=E6=99=BA=E8=83=BD=E4=BD=93=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=EF=BC=8C=E8=A1=A5=E5=AD=97=E5=B9=95=E6=8C=89=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=BA=A6=E5=AE=9A=E6=94=B9=E4=B8=BA=E6=A0=B7?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外围功能模块(补字幕、原站视图、桌面模式、网页搜索、MCP、定时任务)声明 manifest,与 FEATURES 一一对应; 系统提示「能力地图」末尾列出外围功能(没有开关,能力只列本次可用的) - 补字幕:订阅 organization.finished(认出值得补字幕的整理,自动补字幕还没做,不建任务), 写入字幕后在同一事务发 subtitle.written;原有行为不变 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- apps/server/src/modules/browser/index.ts | 2 + apps/server/src/modules/desktop/index.ts | 2 + apps/server/src/modules/mcp/index.ts | 2 + apps/server/src/modules/schedules/index.ts | 2 + apps/server/src/modules/subtitles/events.ts | 28 ++++++ apps/server/src/modules/subtitles/index.ts | 17 +++- apps/server/src/modules/subtitles/service.ts | 37 +++++--- apps/server/src/modules/websearch/index.ts | 2 + apps/server/test/modules.test.ts | 94 ++++++++++++++++++- .../test/subtitles/subtitle-upload.test.ts | 13 +++ docs/plan/subtitles.md | 2 + packages/agent/src/prompt/index.ts | 1 + .../agent/src/prompt/layers/capabilities.ts | 17 +++- packages/agent/test/features-prompt.test.ts | 35 +++++++ 14 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 apps/server/src/modules/subtitles/events.ts create mode 100644 packages/agent/test/features-prompt.test.ts diff --git a/apps/server/src/modules/browser/index.ts b/apps/server/src/modules/browser/index.ts index 56cb43a1..bf33ad40 100644 --- a/apps/server/src/modules/browser/index.ts +++ b/apps/server/src/modules/browser/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import { sourcesIdentityModule } from "../sources-identity/index.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { registerBrowserCapabilities } from "./capabilities.ts"; @@ -36,6 +37,7 @@ export function createBrowserModule(opts: BrowserModuleOptions = {}): ServerModu }; return { name: "browser", + manifest: featureManifest("browser"), service, registerRoutes(app, ctx) { registerBrowserRoutes(app, () => service(ctx)); diff --git a/apps/server/src/modules/desktop/index.ts b/apps/server/src/modules/desktop/index.ts index 2dbe9fd1..8f1256c1 100644 --- a/apps/server/src/modules/desktop/index.ts +++ b/apps/server/src/modules/desktop/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import { browserServiceFor } from "../browser/index.ts"; import type { ServerContext, ServerModule } from "../types.ts"; import { dbSessionChecks, desktopUpstream, registerDesktopRoutes } from "./desktop.ts"; @@ -11,6 +12,7 @@ export function createDesktopModule(): ServerModule { let closeAll: (() => void) | null = null; return { name: "desktop", + manifest: featureManifest("desktop"), registerRoutes(app, ctx: ServerContext) { const r = registerDesktopRoutes(app, { upstream: desktopUpstream({ diff --git a/apps/server/src/modules/mcp/index.ts b/apps/server/src/modules/mcp/index.ts index 0b81571d..0236cbe7 100644 --- a/apps/server/src/modules/mcp/index.ts +++ b/apps/server/src/modules/mcp/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import denoJson from "../../../deno.json" with { type: "json" }; import type { ServerModule } from "../types.ts"; import { createMcpConfigStore } from "./config-store.ts"; @@ -13,6 +14,7 @@ export function createMcpModule(opts: { version?: string } = {}): ServerModule & const table = new McpSessionTable(); return { name: "mcp", + manifest: featureManifest("mcp"), sessions: table, registerRoutes(app, ctx) { registerMcpRoutes(app, ctx, { diff --git a/apps/server/src/modules/schedules/index.ts b/apps/server/src/modules/schedules/index.ts index 89ecf795..d21dffcf 100644 --- a/apps/server/src/modules/schedules/index.ts +++ b/apps/server/src/modules/schedules/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import type { ServerContext, ServerModule } from "../types.ts"; import { builtinAgentCheckDefs } from "./agent-checks.ts"; import { registerScheduleCapabilities } from "./capabilities.ts"; @@ -26,6 +27,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server let scheduler: Scheduler | null = null; return { name: "schedules", + manifest: featureManifest("schedules"), scheduler: () => scheduler, registerRoutes(app, ctx) { registerScheduleRoutes(app, ctx, () => scheduleRegistry(ctx)); diff --git a/apps/server/src/modules/subtitles/events.ts b/apps/server/src/modules/subtitles/events.ts new file mode 100644 index 00000000..806dbaa8 --- /dev/null +++ b/apps/server/src/modules/subtitles/events.ts @@ -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): Record { + 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; + }, + }; +} diff --git a/apps/server/src/modules/subtitles/index.ts b/apps/server/src/modules/subtitles/index.ts index 3a4cd20c..fac59325 100644 --- a/apps/server/src/modules/subtitles/index.ts +++ b/apps/server/src/modules/subtitles/index.ts @@ -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 { route } from "../agent/http.ts"; import { human } from "../browser/routes.ts"; import { sourcesIdentityModule } from "../sources-identity/index.ts"; import type { ServerModule } from "../types.ts"; +import { subtitlesOutboxRoutes } from "./events.ts"; import { downloadFromSession, downloadOnline, @@ -13,10 +20,15 @@ import { } from "./online/index.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 { return { name: "subtitles", + manifest: featureManifest("subtitles"), registerRoutes(app, ctx) { const tag = { tag: "subtitles" }; const rt = () => sourcesIdentityModule.runtime(ctx); @@ -69,6 +81,7 @@ export function createSubtitlesModule(): ServerModule { route(app, Api.subtitles.undo, ({ params }) => undoSubtitleWrite(ctx, params.id), tag); }, registerTasks() {}, + outboxRoutes: (ctx) => subtitlesOutboxRoutes(ctx), }; } diff --git a/apps/server/src/modules/subtitles/service.ts b/apps/server/src/modules/subtitles/service.ts index a430e786..5f963f91 100644 --- a/apps/server/src/modules/subtitles/service.ts +++ b/apps/server/src/modules/subtitles/service.ts @@ -1,15 +1,16 @@ import { copyFile, mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import type { - SubtitleApplyRequest, - SubtitleApplyResult, - SubtitleLanguage, - SubtitleParsedItem, - SubtitleParseResponse, - SubtitleSource, - SubtitleTarget, - SubtitleTargetsResponse, - SubtitleWriteView, +import { + lifecycleKey, + type SubtitleApplyRequest, + type SubtitleApplyResult, + type SubtitleLanguage, + type SubtitleParsedItem, + type SubtitleParseResponse, + type SubtitleSource, + type SubtitleTarget, + type SubtitleTargetsResponse, + type SubtitleWriteView, } from "@umt/contracts"; import type { SubtitleWriteTable } from "@umt/db"; import { @@ -259,7 +260,7 @@ export async function applySubtitles( }) .execute(); } - return await trx + const w = await trx .insertInto("subtitle_write") .values({ id, @@ -277,6 +278,20 @@ export async function applySubtitles( }) .returningAll() .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)); } catch (e) { diff --git a/apps/server/src/modules/websearch/index.ts b/apps/server/src/modules/websearch/index.ts index 20b47c50..b06f27ed 100644 --- a/apps/server/src/modules/websearch/index.ts +++ b/apps/server/src/modules/websearch/index.ts @@ -1,3 +1,4 @@ +import { featureManifest } from "@umt/contracts"; import type { ServerContext, ServerModule } from "../types.ts"; import { registerWebSearchCapabilities } from "./capabilities.ts"; import { registerWebSearchRoutes } from "./routes.ts"; @@ -24,6 +25,7 @@ export function createWebSearchModule(opts: WebSearchModuleOptions = {}): Server }; return { name: "websearch", + manifest: featureManifest("websearch"), service, registerRoutes(app, ctx) { registerWebSearchRoutes(app, () => service(ctx)); diff --git a/apps/server/test/modules.test.ts b/apps/server/test/modules.test.ts index f8e9181d..66e52e0c 100644 --- a/apps/server/test/modules.test.ts +++ b/apps/server/test/modules.test.ts @@ -1,8 +1,8 @@ import { createRoute, z } from "@hono/zod-openapi"; -import { apiResponse, CAPABILITY_IDS, capabilitySpec } from "@umt/contracts"; +import { apiResponse, CAPABILITY_IDS, capabilitySpec, FEATURES, LIFECYCLE_EVENTS } from "@umt/contracts"; import { defineTask, TASK_NAMES, TaskRegistry } from "@umt/tasks"; 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 { requireScope } from "../src/http/auth.ts"; import { EventBus } from "../src/http/sse.ts"; @@ -103,7 +103,7 @@ describe("ServerModule 骨架", () => { expect(TASK_NAMES.filter((n) => !owned.has(n))).toEqual([]); }); - it("outbox 路由合并;同一事件被两个模块认领报错", () => { + it("outbox 路由合并;同一非广播事件被两个模块认领报错", () => { const merged = Object.keys(collectOutboxRoutes([...SERVER_MODULES, pingModule], fakeCtx)); expect(merged).toContain("test.ping"); expect(merged).toContain("release.resolved"); @@ -111,6 +111,94 @@ describe("ServerModule 骨架", () => { 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 () => { const e = new DeferredTaskEngine(); expect(e.ready).toBe(false); diff --git a/apps/server/test/subtitles/subtitle-upload.test.ts b/apps/server/test/subtitles/subtitle-upload.test.ts index 268b3e13..3a1bfd1b 100644 --- a/apps/server/test/subtitles/subtitle-upload.test.ts +++ b/apps/server/test/subtitles/subtitle-upload.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { parseEventPayload } from "@umt/contracts"; import { zipSync } from "fflate"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; 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}`); 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 readFile(join(dir, "示例剧 - S01E02.chi.zh-cn.srt"), "utf8")).toBe("旧字幕"); diff --git a/docs/plan/subtitles.md b/docs/plan/subtitles.md index 8a0e6a8f..52f0fe25 100644 --- a/docs/plan/subtitles.md +++ b/docs/plan/subtitles.md @@ -2,6 +2,8 @@ 参考 MoviePilot 插件「海拉鲁字幕大师」(ifsherlock/MoviePilot-Plugins `plugins.v3/subtitlemanualupload` 1.2.6)。本项目只取**手动上传**和**在线搜索**;调轴、AI 翻译联动、入库后自动补字幕不做。自动补字幕以后接进我们自己的工作流(整理完成、订阅缺字幕、智能体调用),复用本模块的能力,不照插件的事件队列做。 +功能模块样板(ADR-006,2026-10-06):本模块已声明 manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志)、写入后发 `subtitle.written`;前端经插槽挂在作品页操作区与整理记录行菜单。做自动补字幕时把订阅路由换成真实任务即可。 + ## 拆分 | 层面 | 放在哪 | diff --git a/packages/agent/src/prompt/index.ts b/packages/agent/src/prompt/index.ts index 814d7c9a..46458695 100644 --- a/packages/agent/src/prompt/index.ts +++ b/packages/agent/src/prompt/index.ts @@ -18,6 +18,7 @@ export { type IntentRoute, needsApproval, type RouteStep, + renderFeatures, } from "./layers/capabilities.ts"; export { renderEnvironment } from "./layers/environment.ts"; export { AGENT_REPORT_FORMAT, REPORT_FORMAT } from "./layers/report.ts"; diff --git a/packages/agent/src/prompt/layers/capabilities.ts b/packages/agent/src/prompt/layers/capabilities.ts index 0202b62b..7966e230 100644 --- a/packages/agent/src/prompt/layers/capabilities.ts +++ b/packages/agent/src/prompt/layers/capabilities.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"; // 第 3 层:能力地图,按用户意图组织("要查健康 → …","要查下载 → …")。工具描述里已有每个能力"何时用 / 何时不用", @@ -190,6 +190,20 @@ export function renderRoutes(available: ReadonlySet): string[] { return out; } +/** + * 功能清单(ADR-006):外围功能各一行,说明做什么、入口在哪、智能体能不能做。清单来自 contracts 的 FEATURES + * (服务端模块的 manifest 指向同一份),功能都已启用、没有开关;能力只列本次可用的。 + */ +export function renderFeatures(available: ReadonlySet): 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 { if (!capabilities?.length) return { layer: "capabilities", title: "能力地图", lines: [] }; const available = new Set(capabilities); @@ -202,6 +216,7 @@ export function capabilityLayer(capabilities: readonly string[] | undefined): Pr "按用户意图选路线,箭头是常用顺序;只做回答问题需要的几步。标「需要批准」的没有授权时会被拒绝或出审批卡。", ...routes, ...CAPABILITY_TIPS.map((t) => `- ${t}`), + ...renderFeatures(available), ], }; } diff --git a/packages/agent/test/features-prompt.test.ts b/packages/agent/test/features-prompt.test.ts new file mode 100644 index 00000000..6de8327e --- /dev/null +++ b/packages/agent/test/features-prompt.test.ts @@ -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("外围功能"); + }); +}); From 079c3eafe9453d545feed8cd06bfa6979b00602a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:27:14 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E4=B8=9A=E5=8A=A1=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E5=8F=91=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=B9=B6=E8=B0=83=E7=94=A8=E9=93=BE=E5=BC=8F=E9=92=A9?= =?UTF-8?q?=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 订阅已建 / 已改 / 已完成 / 已取消,下载完成 / 失败 / 移除,整理结束(统一 organization.changed 终态), 刮削完成 / 失败,作品进入关注,元数据已刷新(任务调用方),季集结构切换,站点不可达 / 恢复: 都在调用方事务内写 outbox,带幂等键 - 钩子挂点:name.recognize(resolveRelease / identifyParsed)、organize.rename 与 organize.intercept(planImport)、 resource.selection(订阅重评)、subscription.completion.check(completeSubscription) - 测试:各节点事件 payload 与去重、整理改名 / 拦截 / 出错不影响整理、名称识别改写、资源选择、订阅完成拦截 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../routes/subscriptions.ts | 47 ++- .../services/acquisition.ts | 51 ++- .../services/downloads.ts | 9 +- .../services/lifecycle-events.ts | 140 ++++++++ .../services/organize-hooks.ts | 89 +++++ .../services/organize-plan.ts | 17 +- .../acquisition-organize/services/scrape.ts | 2 + .../services/subscriptions.ts | 31 +- .../sources-identity/service/account.ts | 9 + .../service/episode-groups.ts | 20 +- .../service/lifecycle-events.ts | 55 +++ .../sources-identity/service/resolve.ts | 29 +- .../sources-identity/service/snapshot.ts | 26 +- .../src/modules/sources-identity/tasks.ts | 14 +- .../test/hooks/resource-selection.test.ts | 45 +++ .../test/lifecycle/lifecycle-events.test.ts | 314 ++++++++++++++++++ .../lifecycle/name-recognize-hook.test.ts | 68 ++++ .../test/lifecycle/organize-hooks.test.ts | 112 +++++++ 18 files changed, 1049 insertions(+), 29 deletions(-) create mode 100644 apps/server/src/modules/acquisition-organize/services/lifecycle-events.ts create mode 100644 apps/server/src/modules/acquisition-organize/services/organize-hooks.ts create mode 100644 apps/server/src/modules/sources-identity/service/lifecycle-events.ts create mode 100644 apps/server/test/hooks/resource-selection.test.ts create mode 100644 apps/server/test/lifecycle/lifecycle-events.test.ts create mode 100644 apps/server/test/lifecycle/name-recognize-hook.test.ts create mode 100644 apps/server/test/lifecycle/organize-hooks.test.ts diff --git a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts index 9b5bcb49..a0a62ea9 100644 --- a/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/routes/subscriptions.ts @@ -14,6 +14,7 @@ import type { SubscriptionRow } from "@umt/db"; import type { Context } from "hono"; import type { AuthEnv } from "../../../http/auth.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 type { ModuleApp } from "../../types.ts"; import { ensureSubscriptionSlot } from "../services/acquisition.ts"; @@ -29,6 +30,7 @@ import { slotsOfSub, subscriptionCoverage, } from "../services/coverage.ts"; +import { emitSubscriptionEvent } from "../services/lifecycle-events.ts"; import { loadRuleCatalog } from "../services/rules.ts"; import { CALENDAR_MAX_DAYS, subscriptionCalendar } from "../services/subscription-calendar.ts"; import { resetSubscription } from "../services/subscription-reset.ts"; @@ -376,6 +378,9 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { // 订阅总有自己的收录版本:策略模板,没有就建一个默认版本(质量预设随后由 applyDerived 写入) await ensureSubscriptionSlot(trx, r); 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; }); 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 (norm) await applyDerived(trx, r, norm, d.ports.now(), subParams(cur)); else if (body.status !== undefined) await syncPrivateRuleGroup(trx, r, d.ports.now()); + await emitSubscriptionChange(trx, cur, r, Object.keys(body)); return r; }); invalidateCoverage(r.id); @@ -446,6 +452,21 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { { 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 ( sub: SubscriptionRow, @@ -456,16 +477,22 @@ export function registerSubscriptionRoutes(app: ModuleApp, d: AoDeps) { const now = d.ports.now(); const searchOn = (subParams(sub).searchIntervalHours ?? DEFAULT_SEARCH_INTERVAL_HOURS) > 0; const r = await db() - .updateTable("subscription") - .set({ - status: to, - status_reason: reason, - ...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}), - ...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}), - }) - .where("id", "=", sub.id) - .returningAll() - .executeTakeFirstOrThrow(); + .transaction() + .execute(async (trx) => { + const r = await trx + .updateTable("subscription") + .set({ + status: to, + status_reason: reason, + ...(to === "paused" || to === "cancelled" ? { next_search_at: null } : {}), + ...(extra.nextSearchNow && searchOn ? { next_search_at: now } : {}), + }) + .where("id", "=", sub.id) + .returningAll() + .executeTakeFirstOrThrow(); + await emitSubscriptionChange(trx, sub, r, ["status"], reason); + return r; + }); // 私有规则组随订阅删除(取消),重新订阅时按 params 重建 if (to === "cancelled" || sub.status === "cancelled") await syncPrivateRuleGroup(db(), r, now); invalidateCoverage(sub.id); diff --git a/apps/server/src/modules/acquisition-organize/services/acquisition.ts b/apps/server/src/modules/acquisition-organize/services/acquisition.ts index 7688565b..73686d7c 100644 --- a/apps/server/src/modules/acquisition-organize/services/acquisition.ts +++ b/apps/server/src/modules/acquisition-organize/services/acquisition.ts @@ -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 { sql } from "@umt/db"; import { @@ -23,6 +29,7 @@ import { unitsOverlap, } from "@umt/domain"; import { toContractSlotEvaluation } from "@umt/mapping"; +import { hasHook, rewriteHook } from "../../../hooks/index.ts"; import { hashJson, jsonb, startOfDay } from "../util.ts"; import { type AoDeps, @@ -38,6 +45,7 @@ import { recordCandidate, } from "./common.ts"; import { autoFillCheck, type ForceFill } from "./evaluate-autofill.ts"; +import { emitSubscriptionEvent } from "./lifecycle-events.ts"; import { contextOf, episodeResolutionOf, parsedOf, unitKeysOf } from "./release-facts.ts"; import { entityOriginalLanguage, loadRuleCatalog, type RuleCatalog } from "./rules.ts"; import { @@ -430,7 +438,12 @@ async function reevaluate(c: Ctx, input: EvaluateInput): Promise !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 } : {}; for (const r of ordered) await evaluateRelease(c, r, [], scope); return c.out; @@ -500,6 +513,33 @@ async function rankReleases( })).map((x) => byParsed.get(x.candidate)!); } +/** + * 链式钩子 resource.selection(ADR-006):在择优排序之后调整顺序或过滤。钩子只能重排 / 删减, + * 返回里不认识的 ID 忽略、重复的只取第一次;没有钩子时原样返回。 + */ +export async function selectResources( + c: Ctx, + rels: SourceReleaseRow[], + context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null }, +): Promise { + if (!hasHook(c.d.ctx, "resource.selection") || rels.length === 0) return rels; + const byId = new Map(rels.map((r) => [r.id, r])); + const picked = await rewriteHook( + c.d.ctx, + "resource.selection", + rels.map((r) => ({ sourceReleaseId: r.id, title: r.title, parsed: parsedOf(r) })), + context, + ); + const out: SourceReleaseRow[] = []; + for (const p of picked) { + const r = byId.get(p.sourceReleaseId); + if (!r) continue; + byId.delete(p.sourceReleaseId); + out.push(r); + } + return out; +} + /** 评估范围:只评某个收录版本,或只评某个订阅的收录版本;缺省评作品的全部收录版本 */ interface EvalScope { slotId?: string | undefined; @@ -600,8 +640,9 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon policy_revision_id: pol.row.id, created_by: `policy:${pol.row.policy_key}`, }) - .returning("id") + .returningAll() .executeTakeFirstOrThrow(); + await emitSubscriptionEvent(c.d, trx, "subscription.created", sub); await createSlotsFromTemplates(trx, { entityId: entity.id, subscriptionId: sub.id, @@ -634,6 +675,10 @@ async function evaluateRelease(c: Ctx, rel: SourceReleaseRow, eventUnits: readon .where("id", "=", s.id) .execute(); 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"; } } diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index aa572fbf..3876cf6b 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -35,6 +35,7 @@ import { import { mergeObservedDownloadState, toDomainDownloadStatus } from "@umt/mapping"; import { withAttemptToken } from "@umt/tasks"; 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 { errMessage, jsonb } from "../util.ts"; import { parsedOf } from "./acquisition.ts"; @@ -52,6 +53,7 @@ import { timeSlot, upsertDecision, } from "./common.ts"; +import { emitDownloadLifecycle } from "./lifecycle-events.ts"; import { matchesQbSource, qbSourceTags } from "./organize-config.ts"; import { hnrSatisfied, retentionElapsed } from "./retention.ts"; import { @@ -212,6 +214,8 @@ async function emitStatus( ? { 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(); // 开始下载 = 作品进入关注范围:从没刷新过元数据的(自动建档时不在关注范围)补排一次 full 刷新(集数两层,见 watched.ts) 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 client = await d.ports.downloader(topo.inst); const tag = topo.inst.managed_tag; diff --git a/apps/server/src/modules/acquisition-organize/services/lifecycle-events.ts b/apps/server/src/modules/acquisition-organize/services/lifecycle-events.ts new file mode 100644 index 00000000..62726a39 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/lifecycle-events.ts @@ -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; + +/** 订阅已建 / 已改 / 已完成 / 已取消 */ +export async function emitSubscriptionEvent( + d: AoDeps, + exec: Exec, + type: SubEvent, + sub: SubRef, + extra: { reason?: string; changes?: string[]; key?: string } = {}, +): Promise { + 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> = { + 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 { + 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 { + 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 { + 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), + }); +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts b/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts new file mode 100644 index 00000000..7c3348d9 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/organize-hooks.ts @@ -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, + plan: OrganizePlan, + base: { mediaEntityId: string; mediaType: "movie" | "tv" }, +): Promise { + if (!hasHook(ctx, "organize.rename")) return 0; + const renamed = new Map(); + for (const main of plan.files.filter((f) => f.role === "main")) { + const name = basename(main.dst); + // 返回带路径分隔符或空名的钩子结果跳过(记日志),其余钩子照常 + const next = await rewriteHook( + ctx, + "organize.rename", + name, + { + targetPath: main.dst, + sourcePath: main.src, + mediaEntityId: base.mediaEntityId, + mediaType: base.mediaType, + season: main.season ?? null, + episodes: main.episodes ?? null, + }, + usableName, + ); + if (next === name) continue; + const oldStem = name.slice(0, name.length - extname(name).length); + const newStem = next.slice(0, next.length - extname(next).length); + const to = join(dirname(main.dst), next); + renamed.set(main.dst, to); + main.dst = to; + for (const f of plan.files) { + if (f.mainSrc !== main.src) continue; + const side = basename(f.dst); + if (!side.startsWith(oldStem)) continue; + const nd = join(dirname(f.dst), newStem + side.slice(oldStem.length)); + renamed.set(f.dst, nd); + f.dst = nd; + } + } + if (renamed.size === 0) return 0; + for (const c of plan.conflicts) if (c.dst && renamed.has(c.dst)) c.dst = renamed.get(c.dst)!; + for (const pc of plan.preconditions) + if (pc.kind === "target_absent" || pc.kind === "verify_after_copy") + pc.paths = pc.paths.map((p) => renamed.get(p) ?? p); + const byDst = new Map(); + for (const f of plan.files) byDst.set(f.dst, [...(byDst.get(f.dst) ?? []), f]); + for (const [dst, fs] of byDst) { + if (fs.length < 2 || plan.conflicts.some((c) => c.kind === "duplicate_target" && c.dst === dst)) continue; + for (const f of fs) f.action = "hold"; + plan.conflicts.push({ kind: "duplicate_target", blocking: true, dst, detail: fs.map((f) => f.src).join(" | ") }); + } + plan.requiresApproval = plan.conflicts.some((c) => c.blocking); + return renamed.size; +} + +/** organize.intercept:返回拒绝原因(含拒绝的钩子名),放行返回 null */ +export async function organizeIntercept( + ctx: Pick, + plan: OrganizePlan, + base: { + mediaEntityId: string; + mediaType: "movie" | "tv"; + action: string; + trigger: string; + downloadTaskId: string | null; + }, +) { + return interceptHook(ctx, "organize.intercept", () => ({ + ...base, + files: plan.files.map((f) => ({ src: f.src, dst: f.dst, kind: f.kind, role: f.role })), + })); +} diff --git a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts index b2d5871b..3710dbe2 100644 --- a/apps/server/src/modules/acquisition-organize/services/organize-plan.ts +++ b/apps/server/src/modules/acquisition-organize/services/organize-plan.ts @@ -42,10 +42,12 @@ import { publishedAcquisitionPolicies, unitKeysOf } from "./acquisition.ts"; import { classifyEntity } from "./classification.ts"; import { type AoDeps, type Exec, hostPathOf, loadInstances, upsertDecision } from "./common.ts"; import { applySlotEvent } from "./downloads.ts"; +import { emitOrganizationFinished } from "./lifecycle-events.ts"; import { effectiveNaming, ensureNamingVersion, loadNamingEntity, namingByVersion } from "./naming.ts"; import { resolveOrganizeSource } from "./organize-config.ts"; import { PathGuardError } from "./organize-guard.ts"; import { successfulOrganizeCounts } from "./organize-history.ts"; +import { applyRenameHook, organizeIntercept } from "./organize-hooks.ts"; import { planTransfer } from "./organize-transfer.ts"; // 入库:冻结整理计划(organization_record + 文件明细)→ 逐文件可恢复执行(操作账本 fencing)→ // 资产/位置/路径绑定同事务登记 → Slot 推进 → Move 后移除 qB 任务记录(不删数据)。 @@ -925,8 +927,19 @@ export async function planImport(d: AoDeps, req: ImportRequest): Promise { const r = await exec .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) .executeTakeFirstOrThrow(); 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), mediaEntityId: r.media_entity_id, }); + // 终态另发生命周期广播(ADR-006):整理成功 / 部分成功 / 失败 + await emitOrganizationFinished(d, exec, r); } /** diff --git a/apps/server/src/modules/acquisition-organize/services/scrape.ts b/apps/server/src/modules/acquisition-organize/services/scrape.ts index 95d9ff26..69951c1f 100644 --- a/apps/server/src/modules/acquisition-organize/services/scrape.ts +++ b/apps/server/src/modules/acquisition-organize/services/scrape.ts @@ -30,6 +30,7 @@ import { } from "../../sources-identity/service/episode-resolution.ts"; import { errMessage, jsonb } from "../util.ts"; import type { AoDeps } from "./common.ts"; +import { emitScrapeFinished } from "./lifecycle-events.ts"; import { type EntityForNfo, episodeMetadataMissing, @@ -930,6 +931,7 @@ export async function runScrapeRecord( .execute() .catch(() => {}); if (dirs.size) void d.ctx.events.publish("files.changed", { rootId: rec.root_id, dirs: [...dirs] }); + await emitScrapeFinished(d, row); return scrapeRecordView(row); }; const fail = (reason: string) => finish({ status: "failed", reason, files: [] }); diff --git a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts index d1857262..1c300bc8 100644 --- a/apps/server/src/modules/acquisition-organize/services/subscriptions.ts +++ b/apps/server/src/modules/acquisition-organize/services/subscriptions.ts @@ -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 { sql } from "@umt/db"; import { @@ -20,8 +26,10 @@ import { type UnitRange, } from "@umt/domain"; import { slotSpecFromRow } from "@umt/mapping"; +import { interceptHook } from "../../../hooks/index.ts"; import { errMessage, jsonb, toApi } from "../util.ts"; import { type AoDeps, type Exec, upsertDecision } from "./common.ts"; +import { emitSubscriptionEvent } from "./lifecycle-events.ts"; import { contextOf, parsedOf, unitKeysOf } from "./release-facts.ts"; import { entityOriginalLanguage, loadRuleCatalog, mergeHard, type RuleCatalog, resolveRuleChain } from "./rules.ts"; @@ -868,6 +876,18 @@ export async function completeSubscription( reason: string, progress: Record = {}, ): Promise { + // 链式钩子 subscription.completion.check(ADR-006):追加完成条件,拒绝时订阅继续追踪 + const veto = await interceptHook(d.ctx, "subscription.completion.check", () => ({ + subscriptionId: sub.id, + mediaEntityId: sub.media_entity_id, + mediaKind: sub.media_kind, + reason, + progress, + })); + if (veto) { + d.ctx.logger.info({ subscriptionId: sub.id, reason, veto }, "订阅完成被钩子拦下,继续追踪"); + return false; + } const done = await trx .updateTable("subscription") .set({ status: "completed", status_reason: reason, next_search_at: null }) @@ -877,7 +897,7 @@ export async function completeSubscription( .executeTakeFirst(); if (!done) return false; await clearSubscriptionDeferrals(trx, done.id); - await trx + const history = await trx .insertInto("subscription_history") .values({ subscription_id: done.id, @@ -890,7 +910,12 @@ export async function completeSubscription( progress: jsonb(progress), 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 }, "订阅完成,已写历史"); return true; } diff --git a/apps/server/src/modules/sources-identity/service/account.ts b/apps/server/src/modules/sources-identity/service/account.ts index cb3327c5..2292ce3d 100644 --- a/apps/server/src/modules/sources-identity/service/account.ts +++ b/apps/server/src/modules/sources-identity/service/account.ts @@ -30,6 +30,7 @@ import type { SourcesRuntime } from "../runtime.ts"; import { capabilitiesOf, LOGIN_KINDS } from "../site-kinds.ts"; import { type Actor, audit, closeDecisions, type Exec, openDecision, SYSTEM } from "./common.ts"; import { cookieFromBrowser, writeBrowserCookie } from "./credentials.ts"; +import { emitSiteReachability } from "./lifecycle-events.ts"; import { browserOnlyCookiesOf, resolveUserAgent, type UserAgentDefaults } from "./user-agent.ts"; // W9-A 站点中心(docs/design/site-center.md):凭证写入、刷新登录(账密 takelogin / Cookie / API Key 校验)、 @@ -865,12 +866,20 @@ export async function checkSiteHealth( ? (HEALTH_OF[status] ?? "degraded") : site.health_status; 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 trx .updateTable("site") .set({ last_health: jsonb(last), last_health_at: now, health_status: healthStatus }) .where("id", "=", site.id) .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) { await trx diff --git a/apps/server/src/modules/sources-identity/service/episode-groups.ts b/apps/server/src/modules/sources-identity/service/episode-groups.ts index 4d642228..18b34644 100644 --- a/apps/server/src/modules/sources-identity/service/episode-groups.ts +++ b/apps/server/src/modules/sources-identity/service/episode-groups.ts @@ -8,12 +8,13 @@ import { TmdbError, type TmdbRichDetail, } from "@umt/adapters"; -import type { - EpisodeGroupList, - EpisodeGroupSetResult, - EpisodeStructureCurrent, - EpisodeStructureSeason, - EpisodeStructureSeasons, +import { + type EpisodeGroupList, + type EpisodeGroupSetResult, + type EpisodeStructureCurrent, + type EpisodeStructureSeason, + type EpisodeStructureSeasons, + lifecycleKey, } from "@umt/contracts"; import { sql } from "@umt/db"; import { ApiException } from "../../../http/errors.ts"; @@ -357,6 +358,13 @@ export async function setEpisodeGroup( }, ); 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 }; }); let refresh = "refreshed"; diff --git a/apps/server/src/modules/sources-identity/service/lifecycle-events.ts b/apps/server/src/modules/sources-identity/service/lifecycle-events.ts new file mode 100644 index 00000000..5964d907 --- /dev/null +++ b/apps/server/src/modules/sources-identity/service/lifecycle-events.ts @@ -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 | Transaction; + +/** + * 作品进入关注:建订阅、开始下载、用户搜索 / 手动认定时由调用方发。 + * 同一作品同一触发对象(订阅、下载计划、资源)只发一次;订阅方自己判断作品是否已经处理过。 + */ +export async function emitMediaWatched( + ctx: Pick, + exec: Exec, + mediaEntityId: string, + reason: string, + subjectId: string, +): Promise { + await ctx.outbox.emit( + exec, + "media.watched", + { mediaEntityId, reason }, + { idempotencyKey: lifecycleKey("media.watched", mediaEntityId, reason, subjectId) }, + ); +} + +/** 作品元数据刷新完成(刷新任务的调用方发;按任务 run 去重) */ +export async function emitMetadataRefreshed( + ctx: Pick, + exec: Exec, + input: { mediaEntityId: string; full: boolean; changedFields: string[]; runId: string }, +): Promise { + 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, + exec: Exec, + siteId: string, + change: { reachable: false; reason: string; message: string | null } | { reachable: true; reason: string }, + key?: string, +): Promise { + 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); +} diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 280a60cf..ea776c5b 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -21,6 +21,7 @@ import { toParsedColumns, toReleaseResolutionStatus, } from "@umt/mapping"; +import { rewriteHook } from "../../../hooks/index.ts"; import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { addSiteAliases, type SiteAliasOutcome } from "./alias-guard.ts"; @@ -52,6 +53,7 @@ import { openGroupDecision, refreshGroupDecision, } from "./identity-decisions.ts"; +import { emitMediaWatched } from "./lifecycle-events.ts"; import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.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, }; } - 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); // 站点给出的外部 ID(列表 / 详情页的 IMDb、豆瓣、TMDB 链接)作为显式 ID 证据参与打分 const siteIds = siteExplicitIds(release.raw); @@ -909,6 +922,9 @@ async function bindAuto( } else if (userAsked && result.mediaEntityId) { 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; } catch (e) { if (e instanceof IdentityConflictError && attempt < 2) continue; @@ -1160,13 +1176,20 @@ export interface NameIdentifyResult { */ export async function identifyParsed( rt: SourcesRuntime, - parsed: ParsedRelease, + input: ParsedRelease, opts: { trigger?: ResolveTrigger; evidence?: Record } = {}, ): Promise { const { ctx } = rt; 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"; + // 链式钩子 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 lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null); let remote: MetadataCandidate[] = []; diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index d0dbee3d..e315e00a 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; 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 { parseRelease } from "@umt/domain"; import { siteReleaseToSourceRelease } from "@umt/mapping"; @@ -10,6 +10,7 @@ import { siteCan } from "../site-kinds.ts"; import { enqueueRelogin } from "./account.ts"; import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; +import { emitSiteReachability } from "./lifecycle-events.ts"; // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 // 所有请求经站点共享限流器;结果按 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"]) .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, { kind: "source_unreachable", 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 failStreak = status === "failed" ? prevStreak + 1 : 0; 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 .updateTable("site") .set({ diff --git a/apps/server/src/modules/sources-identity/tasks.ts b/apps/server/src/modules/sources-identity/tasks.ts index f6287bc6..7c943a7d 100644 --- a/apps/server/src/modules/sources-identity/tasks.ts +++ b/apps/server/src/modules/sources-identity/tasks.ts @@ -5,6 +5,7 @@ import type { SourcesRuntime } from "./runtime.ts"; import { refreshSiteAccount } from "./service/account.ts"; import { AUTO_ENRICH_TASK, runAutoEnrich } from "./service/enrich.ts"; import { ingestFeed } from "./service/feeds.ts"; +import { emitMetadataRefreshed } from "./service/lifecycle-events.ts"; import { refreshMetadata } from "./service/metadata.ts"; import { resolveRelease } from "./service/resolve.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() }), retryPolicy: NETWORK_RETRY, 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({ name: "searchSubscription", diff --git a/apps/server/test/hooks/resource-selection.test.ts b/apps/server/test/hooks/resource-selection.test.ts new file mode 100644 index 00000000..f1429e52 --- /dev/null +++ b/apps/server/test/hooks/resource-selection.test.ts @@ -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"]); + }); +}); diff --git a/apps/server/test/lifecycle/lifecycle-events.test.ts b/apps/server/test/lifecycle/lifecycle-events.test.ts new file mode 100644 index 00000000..4d81ca09 --- /dev/null +++ b/apps/server/test/lifecycle/lifecycle-events.test.ts @@ -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>; + + /** 某类事件的全部行;顺带确认 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/, + ); + }); +}); diff --git a/apps/server/test/lifecycle/name-recognize-hook.test.ts b/apps/server/test/lifecycle/name-recognize-hook.test.ts new file mode 100644 index 00000000..a85c8f16 --- /dev/null +++ b/apps/server/test/lifecycle/name-recognize-hook.test.ts @@ -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" }]); + }); +}); diff --git a/apps/server/test/lifecycle/organize-hooks.test.ts b/apps/server/test/lifecycle/organize-hooks.test.ts new file mode 100644 index 00000000..b2008dac --- /dev/null +++ b/apps/server/test/lifecycle/organize-hooks.test.ts @@ -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(() => {}), { 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("自定义"); + }); +}); From 62119a9b306c28d29c3170393e5c39dd129ca848 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 6 Oct 2026 19:27:14 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(web):=20=E5=89=8D=E7=AB=AF=E6=8F=92?= =?UTF-8?q?=E6=A7=BD=E8=AE=BE=E6=96=BD=EF=BC=8C=E4=BD=9C=E5=93=81=E9=A1=B5?= =?UTF-8?q?=E4=B8=8E=E6=95=B4=E7=90=86=E8=AE=B0=E5=BD=95=E8=A1=8C=E6=8C=82?= =?UTF-8?q?=E3=80=8C=E8=A1=A5=E5=AD=97=E5=B9=95=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/slots.ts 按位置注册(侧栏入口、作品页操作区 / 面板区、整理记录行操作、设置分区),默认直接显示; components/slots 挂载点:面板类直接渲染,动作类渲染成按钮 / 菜单项、对话框在菜单外渲染 - 作品页操作区与面板区、整理记录行菜单接入插槽;补字幕改由 features/subtitles.tsx 注册, 作品页按钮行为不变,整理记录行菜单新增「补字幕」(成功 / 部分成功的入库类整理) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../app/components/slots/slot-mounts.test.ts | 95 +++++++++++++++++++ apps/web/app/components/slots/slot-mounts.tsx | 57 +++++++++++ apps/web/app/features/index.ts | 5 + apps/web/app/features/subtitles.tsx | 37 ++++++++ apps/web/app/lib/slots.ts | 91 ++++++++++++++++++ apps/web/app/routes/console/library.$id.tsx | 23 ++--- apps/web/app/routes/console/organize.tsx | 16 ++++ 7 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/components/slots/slot-mounts.test.ts create mode 100644 apps/web/app/components/slots/slot-mounts.tsx create mode 100644 apps/web/app/features/index.ts create mode 100644 apps/web/app/features/subtitles.tsx create mode 100644 apps/web/app/lib/slots.ts diff --git a/apps/web/app/components/slots/slot-mounts.test.ts b/apps/web/app/components/slots/slot-mounts.test.ts new file mode 100644 index 00000000..23bc4136 --- /dev/null +++ b/apps/web/app/components/slots/slot-mounts.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 = {}) => + ({ + 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("

面板:示例剧
"); + 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("
  • 总在
  • 未合并才有
"); + }); +}); + +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([]); + }); +}); diff --git a/apps/web/app/components/slots/slot-mounts.tsx b/apps/web/app/components/slots/slot-mounts.tsx new file mode 100644 index 00000000..0b79445f --- /dev/null +++ b/apps/web/app/components/slots/slot-mounts.tsx @@ -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

({ + position, + props, + registry = slots, +}: { + position: P; + props: SlotProps[P]; + registry?: SlotRegistry; +}) { + const entries = registry.entries(position) as PanelSlot

[]; + if (entries.length === 0) return null; + return ( + <> + {entries.map(({ id, Component }) => ( + + ))} + + ); +} + +/** + * 动作类插槽:返回可见的项(页面按自己的样式渲染成按钮或菜单项)和当前打开的对话框。 + * 对话框由页面渲染在菜单外面(菜单关了对话框也不会跟着卸载)。 + */ +export function useSlotActions

( + position: P, + registry: SlotRegistry = slots, +): { + items: (props: SlotProps[P]) => ActionSlot

[]; + open: (entry: ActionSlot

, props: SlotProps[P]) => void; + dialog: ReactNode; +} { + const [active, setActive] = useState<{ entry: ActionSlot

; 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 ?

setActive(null)} /> : null, + }; +} diff --git a/apps/web/app/features/index.ts b/apps/web/app/features/index.ts new file mode 100644 index 00000000..0ba75a24 --- /dev/null +++ b/apps/web/app/features/index.ts @@ -0,0 +1,5 @@ +// 功能模块的前端插槽注册汇总(ADR-006)。新增功能:在本目录加 <功能>.tsx 导出 register(registry),在这里调用一次。 +import { slots } from "~/lib/slots.ts"; +import { registerSubtitles } from "./subtitles.tsx"; + +registerSubtitles(slots); diff --git a/apps/web/app/features/subtitles.tsx b/apps/web/app/features/subtitles.tsx new file mode 100644 index 00000000..d3ec6afe --- /dev/null +++ b/apps/web/app/features/subtitles.tsx @@ -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 }) => ( + !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 }) => ( + !o && onClose()} + /> + ), + }); +} diff --git a/apps/web/app/lib/slots.ts b/apps/web/app/lib/slots.ts new file mode 100644 index 00000000..7db52599 --- /dev/null +++ b/apps/web/app/lib/slots.ts @@ -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; + "entity.actions": { entity: SlotEntity }; + "entity.panels": { entity: SlotEntity }; + "organize.record.actions": { record: PublicOrganizationRecord }; + "settings.sections": Record; +} + +export type ActionPosition = "entity.actions" | "organize.record.actions"; +export type PanelPosition = Exclude; + +interface SlotBase { + /** 全局唯一,建议 <功能>.<位置> */ + id: string; + /** 所属功能(contracts FEATURES 的 id) */ + feature: string; + /** 越小越靠前,缺省 100;同序按注册顺序 */ + order?: number; +} + +export interface ActionSlot

extends SlotBase { + label: string; + icon?: ComponentType<{ className?: string }>; + /** 不显示时返回 false(缺省显示) */ + visible?: (props: SlotProps[P]) => boolean; + /** 点了之后渲染的对话框;关闭时调 onClose */ + Dialog: ComponentType void }>; +} + +export interface PanelSlot

extends SlotBase { + Component: ComponentType; +} + +export type SlotEntry

= P extends ActionPosition + ? ActionSlot

+ : P extends PanelPosition + ? PanelSlot

+ : never; + +export interface SlotRegistry { + register

(position: P, entry: SlotEntry

): void; + entries

(position: P): SlotEntry

[]; +} + +export function createSlotRegistry(): SlotRegistry { + const byPos = new Map(); + 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

(position: P) { + const list = (byPos.get(position) ?? []) as SlotEntry

[]; + // 稳定排序: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

( + registry: SlotRegistry, + position: P, + props: SlotProps[P], +): ActionSlot

[] { + return (registry.entries(position) as ActionSlot

[]).filter((a) => a.visible?.(props) ?? true); +} diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 86d5fdc1..e6ebe975 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -14,7 +14,6 @@ import { ArrowLeft, CalendarClock, CalendarPlus, - Captions, ChevronRight, ExternalLink, FolderTree, @@ -89,9 +88,9 @@ import { import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx"; import { useReleaseSearchLauncher } from "~/components/search/search-sections.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 { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx"; -import { SubtitleDialog } from "~/components/subtitles/subtitle-dialog.tsx"; import { Button } from "~/components/ui/button.tsx"; import { errorMessage } from "~/lib/api.ts"; import { useUiPrefs } from "~/lib/ui-prefs.ts"; @@ -1110,7 +1109,7 @@ export default function LibraryDetailPage() { const entity = useEntityDetail(id); const [merging, setMerging] = useState(false); const [subscribing, setSubscribing] = useState(false); - const [subtitling, setSubtitling] = useState(false); + const entityActions = useSlotActions("entity.actions"); const launcher = useReleaseSearchLauncher(); const ms = mediaServers.useList(); @@ -1147,6 +1146,7 @@ export default function LibraryDetailPage() { } const d = detail.data; const e = d.entity; + const slotEntity = { id: e.id, title: e.title, kind: e.kind, status: e.status }; const redirected = entity.data?.redirectedFrom; const credits = { cast: creditList(metaValue(e.metadata, "cast")), @@ -1200,12 +1200,13 @@ export default function LibraryDetailPage() { 合并作品 ) : null} - {e.status !== "merged" ? ( - - ) : null} + ))} @@ -1230,6 +1231,8 @@ export default function LibraryDetailPage() { ) : null} + {/* 作品页面板区插槽(ADR-006) */} + {subscribing ? ( @@ -1244,9 +1247,7 @@ export default function LibraryDetailPage() { onClose={() => setSubscribing(false)} /> ) : null} - {subtitling ? ( - !o && setSubtitling(false)} /> - ) : null} + {entityActions.dialog} {launcher.dialog} ); diff --git a/apps/web/app/routes/console/organize.tsx b/apps/web/app/routes/console/organize.tsx index 4eda2afd..0c565439 100644 --- a/apps/web/app/routes/console/organize.tsx +++ b/apps/web/app/routes/console/organize.tsx @@ -66,9 +66,11 @@ import { } from "~/components/organize/record-parts.ts"; import { SourceSelection } from "~/components/organize/source-selection.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 { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx"; import { reasonLine } from "~/lib/reasons.ts"; +import type { ActionSlot } from "~/lib/slots.ts"; import { useSse } from "~/lib/sse.ts"; import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; @@ -222,12 +224,17 @@ function RowMenu({ onReorganize, onDelete, onOnlyEntity, + slotActions, + onSlotAction, }: { r: Rec; onOpen: () => void; onReorganize: () => void; onDelete: () => void; onOnlyEntity: () => void; + /** 整理记录行操作插槽(ADR-006):功能模块注册的菜单项 */ + slotActions: ActionSlot<"organize.record.actions">[]; + onSlotAction: (a: ActionSlot<"organize.record.actions">) => void; }) { const [open, setOpen] = useState(false); const run = (fn: () => void) => () => { @@ -276,6 +283,11 @@ function RowMenu({ 只看这个作品 ) : null} + {slotActions.map((a) => ( + : null} onClick={run(() => onSlotAction(a))}> + {a.label} + + ))} {!isRunning(r) ? ( } onClick={run(onDelete)} danger> 删除… @@ -356,6 +368,7 @@ function StatsLine({ export default function OrganizePage() { const [params, setParams] = useSearchParams(); + const recordActions = useSlotActions("organize.record.actions"); const f = useQueryState(RECORD_FILTER_KEYS); const { set: setFilter, values: v } = f; 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 }, ) } + slotActions={recordActions.items({ record: r })} + onSlotAction={(a) => recordActions.open(a, { record: r })} /> ), }, @@ -771,6 +786,7 @@ export default function OrganizePage() { }} /> ) : null} + {recordActions.dialog}