- 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
9.7 KiB
ADR-006:生命周期事件、链式钩子与功能模块约定
- 状态:采纳(2026-10-06,用户确认方向)。
- 关联:ADR-002(outbox 与任务引擎)、
packages/contracts/src/events.ts(LIFECYCLE_EVENTS)、packages/contracts/src/hooks.ts(HOOK_POINTS)、packages/contracts/src/features.ts(FEATURES、SLOT_POSITIONS)、apps/server/src/hooks/、apps/web/app/lib/slots.ts。 - 参照:MoviePilot v3
app/schemas/types.py的EventType(广播)与ChainEventType(链式),分发在app/runtime/events.py。
背景
外围功能(补字幕、以后的入库后通知、自动补字幕等)需要在业务节点上「知道发生了什么」或「插一手」。MoviePilot 用插件系统 + 两类事件解决。我们是内部工具,用户定了:
- 不做插件系统、插件市场、启停开关;
- 做「功能模块」约定:功能清单只给智能体知道;补齐生命周期事件(参考 MP 的两类事件);前端插槽要做、默认直接显示。
决定
1. 两类事件
| 广播事件 | 链式钩子 | |
|---|---|---|
| 对照 MP | EventType(transfer.complete、subscribe.added …) |
ChainEventType(name.recognize、transfer.rename …) |
| 时机 | 事后通知:事情已经发生并提交 | 事中:业务流程走到挂点时同步调用 |
| 传递 | 业务事务里写 outbox_event,中继投递成任务(异步、至少一次) |
进程内直接调用,按注册顺序 |
| 能做什么 | 订阅方自己建任务做后续动作 | 改写一个值(rewrite)或拒绝(intercept) |
| 失败 | 订阅任务按引擎重试;路由函数抛错只跳过该订阅方 | 钩子抛错 / 超时只记日志、跳过该钩子,主流程照常 |
| 去重 | outbox 幂等键(<type>/<部分…>),订阅任务用引擎幂等键 |
不需要 |
何时用哪类:只想在「X 发生之后」做点事(发通知、补字幕、预热缓存)→ 订阅广播事件;要改变正在进行的决定(换个名字、换个候选、不让整理、晚点完成)→ 注册链式钩子。拿不准就用广播:它不在主流程里,坏了也不影响主流程。
事务边界:
- 广播事件在业务事务里写 outbox(
ctx.outbox.emit(trx, …)),与业务写入一起提交或回滚;payload 只放 ID 和少量判断用的事实,订阅方按 ID 回查业务表(事件可能晚到、重复到)。 - 钩子可能在业务事务里被调用(订阅完成判定就在行锁里):只做内存里的判断与改写,不要发外部请求、不要写库;缺省每个钩子 2 秒上限。需要查外部的,改成订阅广播事件后异步做。
2. 广播事件清单
payload schema 都在 EVENT_PAYLOADS(emit 时校验,不合法直接抛错)。已有事件保持原样;download.status_changed / organization.changed 仍是状态流(SSE 与增量游标用,每次变化都发),下表是给功能模块订阅的生命周期事件。
| 事件 | 含义 | 发出位置 | 幂等键 |
|---|---|---|---|
release.resolved |
资源识别完成(已有) | sources-identity resolve.ts 等 |
已有 |
subscription.created |
订阅已建 | 订阅 API 新建、策略自动建(acquisition.ts) |
subscription.created/<id> |
subscription.updated |
订阅已改(参数、暂停、恢复、重新订阅;changes 列字段) |
订阅 API 修改、状态动作 | 无(每次改动一条) |
subscription.completed |
订阅已完成 | completeSubscription、停止日期到达 |
…/<id>/<历史行 id>、…/<id>/stop_after:<日期> |
subscription.cancelled |
订阅已取消(删除即取消) | 订阅 API 删除 / 改状态为 cancelled | 无 |
download.submitted |
下载已提交(已有) | downloads.ts 提交 |
已有 |
download.completed / download.failed / download.removed |
下载完成 / 失败 / 移除 | downloads.ts emitStatus(与 download.status_changed 同事务) |
<type>/<任务 id>/g<代数> |
organization.finished |
整理结束,outcome = succeeded / partial / failed(统一 organization.changed 的终态) |
emitRecord(所有整理记录写入都经过它) |
organization.finished/<记录 id>/<change_seq> |
scrape.completed / scrape.failed |
刮削完成(含部分 / 跳过)/ 失败 | scrape.ts runScrapeRecord 收尾 |
<type>/<记录 id>/<结束时间> |
subtitle.written |
字幕已写入(每个文件一条) | 补字幕 applySubtitles |
subtitle.written/<写入 id> |
media.watched |
作品进入关注(建订阅、开始下载、用户搜索 / 手动认定) | 调用方:订阅 API、submitDownload、resolveRelease |
media.watched/<作品>/<原因>/<触发对象 id> |
media.metadata_refreshed |
作品元数据已刷新 | refreshMetadata 任务处理器(调用方) |
…/<作品>/<任务 run id> |
media.episode_structure_changed |
季集结构已切换(剧集组) | setEpisodeGroup 事务内 |
…/<作品>/<时间> |
site.unreachable / site.recovered |
站点不可达 / 恢复 | 列表抓取连续失败到阈值(与待处理同口径)、登录失效、站点检测结果跨越 | 列表抓取带 …/<站点>/<快照 id>,其余只在跨越时发 |
幂等键用斜杠分隔,与已有的 <type>:<id> 键分开(download.completed:<id> 已被 download.status_changed 占用,outbox 幂等键全表唯一)。
订阅:模块在 outboxRoutes 里写 type → 路由函数。生命周期事件可以有多个订阅方(collectOutboxRoutes 按模块顺序合成),其它事件(内部流程,如 release.observed → resolveObservation)仍只能一个模块认领。路由函数返回任务、任务数组或 null(不建任务);中继对第一个任务用事件的幂等键、其余用 <键>#<任务名>。没有订阅方的生命周期事件也会被中继认领、标记已投递,并镜像到 SSE(worker/index.ts 的镜像前缀)。
3. 链式钩子清单
挂点名称与语义在 HOOK_POINTS,入参 / 返回值类型在 apps/server/src/hooks/points.ts。
| 挂点 | 类型 | 位置 | 能做什么 |
|---|---|---|---|
name.recognize |
rewrite | resolveRelease(标题解析后、召回前)、identifyParsed(整理 / 接管按名称识别) |
改写解析结果(标题、年份、季集、外部 ID) |
organize.rename |
rewrite | planImport,模板命名生成目标之后(保留原名 / 原地入库不调) |
改写主视频目标文件名;同主干的附属文件跟着改;结果带路径分隔符或空名的钩子跳过;改名后撞名转 duplicate_target |
organize.intercept |
intercept | planImport,写整理记录之前(预演也调) |
返回原因即拒绝:记录为失败,reason = intercepted:<原因> |
resource.selection |
rewrite | 订阅 / 收录版本重评(reevaluate)择优排序之后 |
重排或过滤候选;不认识的 ID 忽略,不能加入新候选 |
subscription.completion.check |
intercept | completeSubscription,标记完成之前 |
追加完成条件:返回原因即这次不完成,订阅继续追踪 |
语义:
- 注册在模块启动时完成:
ServerModule.registerHooks(hooks, ctx),bootstrap按SERVER_MODULES顺序调用;执行顺序即注册顺序(不设优先级数字,需要先后就调整模块顺序)。 - rewrite:每个钩子拿到上一个钩子的结果,返回新值或
undefined(不改)。intercept:第一个返回非空原因的钩子拒绝,后面不再执行。 - 每个钩子单独限时(缺省 2 秒,可在注册时改),超时或抛错记 warn 日志、跳过这个钩子;拦截类出错按放行处理。
- 没有注册时调用方直接用原值(
hasHook为 false,不构造上下文)。
MP 的其它链式事件(transfer.overwrite.check、resource.download、discover.source、recommend.source 等)等有真实需求再加:在 HOOK_POINTS 登记、在 points.ts 写类型、在业务节点调用。
4. 功能模块约定
一个外围功能 = 服务端一个 ServerModule + contracts 里一份 manifest + (可选)前端插槽注册:
- manifest(
FEATURES):名称、说明、怎么用、订阅的事件、提供的能力、页面入口、插槽位置、钩子。服务端模块manifest: featureManifest("<id>");modules.test.ts核对模块与清单一一对应、订阅的事件与outboxRoutes一致。 - 功能清单只给智能体:系统提示「能力地图」一节末尾列出外围功能(
renderFeatures),能力只列本次可用的。不做开关:功能都直接启用。 - 前端插槽(
apps/web/app/lib/slots.ts):位置见SLOT_POSITIONS(侧栏入口、作品页操作区、作品页面板区、整理记录行操作、设置分区)。动作类位置注册{ label, icon, visible?, Dialog },页面渲染成按钮 / 菜单项、点了打开对话框;面板类位置注册组件,挂载点按顺序渲染。注册写在app/features/<功能>.tsx,app/features/index.ts汇总。默认直接显示,不做开关。已挂载:作品页操作区、作品页面板区(library.$id.tsx)、整理记录行菜单(organize.tsx);侧栏与设置分区先只定义位置。 - 样板:补字幕(
modules/subtitles)——manifest、订阅organization.finished(认出值得补字幕的整理,自动补字幕还没做,只记调试日志不建任务)、写入后发subtitle.written、前端在作品页操作区和整理记录行菜单挂「补字幕」。
不做
- 插件系统、热加载、插件市场、功能开关。
- 钩子优先级数字、钩子里做外部请求。
- 把广播事件放进内存队列(MP 做法):我们已有 outbox,事件要能跨进程、跨重启。