feat: 生命周期广播事件与链式钩子设施(ADR-006)
- 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
This commit is contained in:
1 parent
0f656f0f58
commit
1db689d9a7
16 files changed
+1121
-22
No files matched your search
@@ -0,0 +1,81 @@
|
||||
import type { HOOK_POINTS, HookPoint } from "@umt/contracts";
|
||||
import type { ParsedRelease } from "@umt/domain";
|
||||
|
||||
// 链式钩子挂点的入参 / 返回值类型(挂点清单与语义在 contracts/hooks.ts,ADR-006)。
|
||||
// rewrite 挂点:value 是要改写的值,context 只读;intercept 挂点只有 context。
|
||||
|
||||
/** 资源选择的候选:只能重排或过滤,不能加入新候选(按 sourceReleaseId 映射回原资源) */
|
||||
export interface SelectionCandidate {
|
||||
sourceReleaseId: string;
|
||||
title: string;
|
||||
parsed: ParsedRelease;
|
||||
}
|
||||
|
||||
export interface HookPointTypes {
|
||||
"name.recognize": {
|
||||
value: ParsedRelease;
|
||||
context: {
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
/** 来源资源;按名称识别整理文件时为 null */
|
||||
sourceReleaseId: string | null;
|
||||
trigger: string;
|
||||
};
|
||||
};
|
||||
"organize.rename": {
|
||||
/** 目标文件名(不含目录);返回值不能含路径分隔符 */
|
||||
value: string;
|
||||
context: {
|
||||
targetPath: string;
|
||||
sourcePath: string;
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
season: number | null;
|
||||
episodes: { start: number; end: number } | null;
|
||||
};
|
||||
};
|
||||
"organize.intercept": {
|
||||
value: never;
|
||||
context: {
|
||||
mediaEntityId: string;
|
||||
mediaType: "movie" | "tv";
|
||||
action: string;
|
||||
trigger: string;
|
||||
downloadTaskId: string | null;
|
||||
files: { src: string; dst: string; kind: string; role: "main" | "sidecar" }[];
|
||||
};
|
||||
};
|
||||
"resource.selection": {
|
||||
value: SelectionCandidate[];
|
||||
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null };
|
||||
};
|
||||
"subscription.completion.check": {
|
||||
value: never;
|
||||
context: {
|
||||
subscriptionId: string;
|
||||
mediaEntityId: string | null;
|
||||
mediaKind: string;
|
||||
/** 本次判定完成的原因(slot_satisfied / download_accepted) */
|
||||
reason: string;
|
||||
progress: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
type KindOf<P extends HookPoint> = (typeof HOOK_POINTS)[P]["kind"];
|
||||
export type RewritePoint = { [P in HookPoint]: KindOf<P> extends "rewrite" ? P : never }[HookPoint];
|
||||
export type InterceptPoint = { [P in HookPoint]: KindOf<P> extends "intercept" ? P : never }[HookPoint];
|
||||
|
||||
export type HookValue<P extends HookPoint> = HookPointTypes[P]["value"];
|
||||
export type HookContext<P extends HookPoint> = Readonly<HookPointTypes[P]["context"]>;
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
/** 返回新值;返回 undefined 表示不改 */
|
||||
export type RewriteHook<P extends RewritePoint> = (
|
||||
value: HookValue<P>,
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<HookValue<P> | undefined>;
|
||||
/** 返回拒绝原因;返回 null / undefined 表示放行 */
|
||||
export type InterceptHook<P extends InterceptPoint> = (
|
||||
context: HookContext<P>,
|
||||
) => MaybePromise<string | null | undefined>;
|
||||
Reference in new issue
Block a user