refactor(core): migrate to Bun and converge acquisition policies and Agent boundaries
This commit is contained in:
1 parent
3f1c6d02f6
commit
27c0f27434
268 files changed
+7468
-7852
No files matched your search
@@ -2,7 +2,6 @@ import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from
|
||||
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,
|
||||
@@ -78,7 +77,7 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
|
||||
it("订阅完成:发已完成(按历史行去重),重复完成不重复发事件", async () => {
|
||||
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
|
||||
const sub = await h.db
|
||||
.insertInto("subscription")
|
||||
@@ -86,26 +85,6 @@ describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
|
||||
.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(
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
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("自定义");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user