- 订阅已建 / 已改 / 已完成 / 已取消,下载完成 / 失败 / 移除,整理结束(统一 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 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
113 lines
5.5 KiB
TypeScript
113 lines
5.5 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { createTempTree, type TempTree } from "@umt/testkit";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { HookRegistry } from "../../src/hooks/index.ts";
|
|
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
|
|
|
|
// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
|
|
// 钩子出错不影响整理。整理结束发 organization.finished。
|
|
|
|
const hasPg = await pgAvailable();
|
|
const SIZE = 200_000;
|
|
const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
|
|
const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
|
|
const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
|
|
|
|
describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
|
|
let h: Harness;
|
|
let lib: TempTree;
|
|
let src: TempTree;
|
|
let entity = "";
|
|
const req = () => ({
|
|
sourcePaths: [src.path(DIR)],
|
|
mediaEntityId: entity,
|
|
action: "copy" as const,
|
|
trigger: "manual" as const,
|
|
actorType: "user" as const,
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
|
|
src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
|
|
h = await createHarness("organize_hooks");
|
|
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
|
|
await h.setSettings("organize", { scrapeAfterImport: false });
|
|
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
|
|
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
|
|
}, 60_000);
|
|
afterEach(() => {
|
|
h.ctx.hooks = undefined;
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
for (const t of [lib, src]) await t?.cleanup();
|
|
});
|
|
|
|
it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
|
|
const hooks = new HookRegistry();
|
|
const seen: string[][] = [];
|
|
hooks.forModule("test").intercept("organize.intercept", (c) => {
|
|
seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
|
|
return c.action === "copy" ? "演示:不许复制" : null;
|
|
});
|
|
h.ctx.hooks = hooks;
|
|
const plan = await planImport(h.d, req());
|
|
expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
|
|
expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
|
|
const rec = await h.db
|
|
.selectFrom("organization_record")
|
|
.select(["status", "error"])
|
|
.where("id", "=", plan.recordId!)
|
|
.executeTakeFirstOrThrow();
|
|
expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
|
|
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
|
|
expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
|
|
});
|
|
|
|
it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
|
|
const hooks = new HookRegistry();
|
|
const ctxs: unknown[] = [];
|
|
hooks.forModule("test").rewrite("organize.rename", (name, c) => {
|
|
ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
|
|
return name.replace(/\.mkv$/, " [自定义].mkv");
|
|
});
|
|
// 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
|
|
hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
|
|
h.ctx.hooks = hooks;
|
|
const plan = await planImport(h.d, req());
|
|
expect(plan.status).toBe("running");
|
|
const ex = await executeRecord(h.d, plan.recordId!);
|
|
expect(ex.status).toBe("succeeded");
|
|
expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
|
|
const files = await h.db
|
|
.selectFrom("organization_file_result")
|
|
.select(["target_path", "status"])
|
|
.where("organization_record_id", "=", plan.recordId!)
|
|
.execute();
|
|
const targets = files.map((f) => f.target_path ?? "").sort();
|
|
expect(targets).toHaveLength(2);
|
|
const video = targets.find((t) => t.endsWith(".mkv"))!;
|
|
const sub = targets.find((t) => t.endsWith(".srt"))!;
|
|
expect(video).toMatch(/ \[自定义\]\.mkv$/);
|
|
const stem = video.slice(0, -".mkv".length);
|
|
expect(sub.startsWith(stem)).toBe(true);
|
|
expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
|
|
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
|
|
expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
|
|
});
|
|
|
|
it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
|
|
const hooks = new HookRegistry();
|
|
hooks.forModule("broken").rewrite("organize.rename", () => {
|
|
throw new Error("坏了");
|
|
});
|
|
hooks.forModule("broken").intercept("organize.intercept", () => new Promise<string>(() => {}), { timeoutMs: 20 });
|
|
h.ctx.hooks = hooks;
|
|
const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
|
|
expect(plan.status).toBe("queued");
|
|
const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
|
|
expect(main.dst).not.toContain("自定义");
|
|
});
|
|
});
|