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("自定义"); }); });