import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { reresolveWordsBatch } from "../../src/modules/sources-identity/service/identify-words.ts"; import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts"; import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts"; // 识别词接口(docs/design/identify-words.md):作品识别词读写 / 试算 / 版本冲突、集数待确认事项的确认与忽略(写一行进识别词)、 // 规则照写的用(对不上季表只待确认,不自动停用)、全局识别词试算 / 保存 / 后台重算范围、单行示例。 const pg = await pgAvailable(); const weekly = (n: number, start: string): TmdbFixtureEpisode[] => Array.from({ length: n }, (_, k) => ({ episode_number: k + 1, air_date: new Date(Date.parse(start) + k * 7 * 86_400_000).toISOString().slice(0, 10), names: { "zh-CN": `第 ${k + 1} 集`, "en-US": `Episode ${k + 1}` }, })); // 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集) const KUSURIYA: TmdbFixture = { type: "tv", id: 990101, original_title: "Kusuriya no Hitorigoto", original_language: "ja", origin_country: ["JP"], date: "2023-10-22", titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" }, popularity: 30, alternative_titles: [], external_ids: {}, rich: { status: "Ended" }, seasons: [ { season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") }, { season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") }, ], } as TmdbFixture; describe.skipIf(!pg)("识别词接口", () => { let h: Harness; let tmdb: MockTmdb; let siteId: string; let n = 0; let kId: string; let lilith40: string; let lilith41: string; beforeAll(async () => { invalidateGlobalWords(); tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] }); h = await createHarness("identify_words_api", { tmdbUrl: tmdb.url }); siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const insert = (title: string, publishedAt: string | null = null, subtitle = "药屋少女的呢喃") => h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: String(810000 + ++n), title, subtitle, source_published_at: publishedAt ? new Date(publishedAt) : null, }) .returningAll() .executeTakeFirstOrThrow(); const resolve = async (title: string, publishedAt: string | null = null) => { const r = await insert(title, publishedAt); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); expect(res.outcome, JSON.stringify(res)).toBe("auto"); return { id: r.id, entityId: res.mediaEntityId! }; }; // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb const er = async (id: string): Promise => ( await h.db .selectFrom("source_release") .select("episode_resolution") .where("id", "=", id) .executeTakeFirstOrThrow() ).episode_resolution; const openDecisions = () => h.db .selectFrom("decision") .selectAll() .where("kind", "=", "episode_mapping") .where("status", "in", ["open", "investigating", "awaiting_user"]) .execute(); const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => json(await h.req(path, init)); const entityWords = async () => (await call(`/media-entities/${kId}/identify-words`)).body.data; const saveEntity = (text: string, expectedVersion: number, key?: string) => call(`/media-entities/${kId}/identify-words`, { method: "PUT", body: { text, expectedVersion }, ...(key ? { key } : {}), }); it("准备:Lilith-Raws 连续编号 40 / 41 待确认,事项选项里带要写进识别词的那一行", async () => { kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId; expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed"); lilith40 = ( await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z") ).id; lilith41 = ( await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z") ).id; expect((await er(lilith40)).method).toBe("unresolved"); const ds = await openDecisions(); expect(ds).toHaveLength(1); expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } }); const options = ds[0]!.options as { key: string; input: { line: string } }[]; expect(options.map((o) => [o.key, o.input.line])).toEqual([ ["confirm_suggestion", "S01 @Lilith-Raws >> S02 EP-30"], ["ignore_rule", "S01 @Lilith-Raws >> 忽略"], ]); const w = await entityWords(); expect(w).toMatchObject({ applicable: true, block: { text: "", version: 0 } }); expect(w.knownGroups).toEqual(expect.arrayContaining(["ANi", "Lilith-Raws"])); }); it("试算:逐行错误带行号,换算前后与仍对不上季表的条数;纯读不落库", async () => { const r = await call(`/media-entities/${kId}/identify-words/dry-run`, { body: { text: "# 连续编号\nS01 @Lilith-Raws >> S02 EP-30\nS0X >> EP\nS02 >> S01 2EP\n" }, }); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data.errors).toEqual([ { line: 3, message: expect.stringContaining("不是季号") }, { line: 4, message: expect.stringContaining("EP") }, ]); expect(r.body.data).toMatchObject({ rules: 1, changed: 2, mismatched: 0 }); // biome-ignore lint/suspicious/noExplicitAny: 测试断言 const i40 = r.body.data.preview.items.find((x: any) => x.sourceReleaseId === lilith40); expect(i40).toMatchObject({ current: { method: "unresolved" }, preview: { method: "rule", ruleLine: 2, canonical: { season: 2, episodes: { start: 10, end: 10 } } }, validation: "pass", changed: true, words: [], }); // 算出越界:照样标对不上,算作仍对不上季表 const bad = await call(`/media-entities/${kId}/identify-words/dry-run`, { body: { text: "S01 @Lilith-Raws >> S02 EP-20\n" }, }); expect(bad.body.data).toMatchObject({ errors: [], mismatched: 2 }); expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0); expect((await er(lilith40)).method).toBe("unresolved"); }); it("保存校验:有错的行拒绝、版本不符 409、只读 Key 403", async () => { const e = await saveEntity("S0X >> EP\n", 0); expect(e.status).toBe(400); expect(e.body.error.message).toContain("第 1 行"); expect((await saveEntity("S01 >> EP\n", 3)).status).toBe(409); expect((await saveEntity("S01 >> EP\n", 0, READ_KEY)).status).toBe(403); expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0); }); it("集数待确认「确认建议规则」:这一行连同来历注释追加进作品识别词,重算后资源解决、事项关闭", async () => { const d = (await openDecisions())[0]!; const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } }); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data).toMatchObject({ status: "resolved", resolution: { optionKey: "confirm_suggestion", execution: { line: "S01 @Lilith-Raws >> S02 EP-30", lineNumber: 2, reresolve: { resolved: 2 } }, }, }); const w = await entityWords(); expect(w.block.version).toBe(1); expect(w.block.text).toMatch( /^# \d{4}-\d{2}-\d{2} 按发布时间对播出日期推出的建议\nS01 @Lilith-Raws >> S02 EP-30\n$/, ); expect(await er(lilith40)).toMatchObject({ method: "rule", ruleText: "S01 @Lilith-Raws >> S02 EP-30", canonical: { season: 2, episodes: { start: 10, end: 10 } }, }); expect(await openDecisions()).toHaveLength(0); const audit = await h.db .selectFrom("audit_event") .select(["action", "payload"]) .where("action", "=", "identify_words.updated") .execute(); expect(audit[0]?.payload).toMatchObject({ mediaEntityId: kId, added: ["S01 @Lilith-Raws >> S02 EP-30"] }); }); it("改这一行:每次保存重算并返回受影响数量;算出越界只待确认,这一行原样保留", async () => { const w = await entityWords(); const edited = w.block.text.replace("EP-30", "EP-31"); const r = await saveEntity(edited, w.block.version); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 }); expect(r.body.data.block.version).toBe(w.block.version + 1); expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } }); // 写成 EP(集号不变):S02E40 / E41 超出第 2 季,资源待确认,这一行不会被停用或改写 const over = edited.replace("EP-31", "EP"); const o = await saveEntity(over, w.block.version + 1); expect(o.status).toBe(200); expect(await er(lilith40)).toMatchObject({ method: "unresolved", ruleText: "S01 @Lilith-Raws >> S02 EP" }); expect((await entityWords()).block.text).toBe(over); expect(await openDecisions()).toHaveLength(1); }); it("注释掉这一行 = 停用:资源回到自动判定;选「忽略」把忽略行追加进去", async () => { const w = await entityWords(); const off = w.block.text.replace("S01 @Lilith-Raws >> S02 EP", "# S01 @Lilith-Raws >> S02 EP"); expect((await saveEntity(off, w.block.version)).status).toBe(200); expect((await er(lilith41)).method).toBe("unresolved"); expect((await er(lilith41)).ruleText).toBeUndefined(); const d = (await openDecisions())[0]!; const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } }); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data.status).toBe("dismissed"); const text = (await entityWords()).block.text as string; expect(text.startsWith(off.trimEnd())).toBe(true); expect(text.trimEnd().split("\n").at(-1)).toBe("S01 @Lilith-Raws >> 忽略"); expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null }); expect(await openDecisions()).toHaveLength(0); }); it("有作品识别词的作品列表:规则行数、停用行数、可按名称搜", async () => { const r = await call("/identify-words/entities"); expect(r.status).toBe(200); expect(r.body.data.items).toEqual([ expect.objectContaining({ mediaEntityId: kId, title: "药屋少女的呢喃", rules: 1, disabled: 1, errors: 0 }), ]); expect((await call("/identify-words/entities?q=不存在")).body.data.items).toEqual([]); }); it("单行示例:作品识别词找一条命中的近期资源,给出换算后的季集与校验;也可以自己给标题", async () => { const r = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S01 @Lilith-Raws >> S02 EP-30" }, }); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data).toMatchObject({ matched: true, sample: { title: expect.stringContaining("Lilith-Raws") }, episode: { method: "rule", canonical: { season: 2 } }, validation: "pass", }); const own = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S01 @Lilith-Raws >> S02 EP-30", title: "[Lilith-Raws] X - 99", }, }); expect(own.body.data).toMatchObject({ matched: true, validation: "fail", episode: { method: "unresolved" } }); const bad = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S0X >> EP" } }); expect(bad.body.data.error).toContain("不是季号"); }); it("全局识别词:试算列出识别结果会变的资源;保存要带版本;生效行有增减才排后台重算", async () => { const other = await insert("Fake.Show.S01E05.1080p.WEB-DL-GRP", null, ""); const g0 = (await call("/identify-words/global")).body.data; expect(g0).toMatchObject({ text: "", version: 0, reresolve: null }); const text = "# 测试\nFake.Show => 假剧 {[tmdbid=1399;type=tv]}\n([ => broken\n"; const dry = await call("/identify-words/global/dry-run", { body: { text } }); expect(dry.status, JSON.stringify(dry.body)).toBe(200); expect(dry.body.data.errors).toEqual([{ line: 3, message: expect.stringContaining("正则") }]); expect(dry.body.data.added).toEqual(["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}", "([ => broken"]); // biome-ignore lint/suspicious/noExplicitAny: 测试断言 const item = dry.body.data.items.find((x: any) => x.sourceReleaseId === other.id); expect(item).toMatchObject({ words: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], before: { title: "Fake Show", explicitIds: [] }, after: { title: "假剧", explicitIds: [{ provider: "tmdb", id: "1399" }] }, }); expect(dry.body.data.matched).toBe(1); expect( (await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 }, key: EXEC_KEY })) .status, ).toBe(403); const saved = await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } }); expect(saved.status, JSON.stringify(saved.body)).toBe(200); expect(saved.body.data.block).toMatchObject({ version: 1, reresolve: { status: "queued" } }); expect((await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } })).status).toBe( 409, ); expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toEqual([ expect.objectContaining({ input: { version: 1 } }), ]); // 只改注释:生效行没变,不排重算 const again = await call("/identify-words/global", { method: "PUT", body: { text: text.replace("# 测试", "# 改了注释"), expectedVersion: 1 }, }); expect(again.body.data.block.version).toBe(2); expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toHaveLength(1); }); it("后台重算:只重新识别标题命中改动行的资源,进度写回;被新版本取代就停", async () => { // 版本 2 的保存没有改动行,版本 1 的任务已被取代 expect(await reresolveWordsBatch(h.ctx, 1, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull(); const g = (await call("/identify-words/global")).body.data; const saved = await call("/identify-words/global", { method: "PUT", body: { text: `${g.text}Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28\n`, expectedVersion: g.version }, }); const version = saved.body.data.block.version as number; const before = h.engine.enqueued.length; expect(await reresolveWordsBatch(h.ctx, version, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull(); const queued = h.engine.enqueued.slice(before).filter((e) => e.task === "resolveObservation"); expect(queued.map((e) => (e.input as { sourceReleaseId: string }).sourceReleaseId).sort()).toEqual( [lilith40, lilith41].sort(), ); const p = (await call("/identify-words/global")).body.data.reresolve; expect(p).toMatchObject({ status: "done", matched: 2, queued: 2 }); expect(p.scanned).toBeGreaterThanOrEqual(4); // 重新识别后集数按全局识别词偏移(40 → 12,对上第 1 季),命中的行记在解析结果上 await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" }); const row = await h.db .selectFrom("source_release") .select(["parsed"]) .where("id", "=", lilith40) .executeTakeFirstOrThrow(); expect((row.parsed as { words?: { applied: string[] } }).words?.applied).toEqual([ "Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28", ]); }); it("全局示例:没给标题时从近期资源里找命中的", async () => { const r = await call("/identify-words/try", { body: { scope: "global", line: "Fake.Show => 假剧" } }); expect(r.body.data).toMatchObject({ matched: true, sample: { title: "Fake.Show.S01E05.1080p.WEB-DL-GRP" }, titleAfter: "假剧.S01E05.1080p.WEB-DL-GRP", parsed: { title: "假剧", season: 1, episodes: { start: 5, end: 5 } }, }); const none = await call("/identify-words/try", { body: { scope: "global", line: "完全不存在的词 => X" } }); expect(none.body.data).toMatchObject({ matched: false, sample: null }); }); it("站点列表抓取落库时就按全局识别词解析(parsed 记命中的行)", async () => { const r = await h.db.transaction().execute((trx) => upsertSiteRelease(h.rt, trx, { id: siteId }, { siteId, siteReleaseId: "words-snap-1", title: "Fake.Show.S01E06.1080p.WEB-DL-GRP", category: { id: "1", name: "TV" }, sizeBytes: null, seeders: null, leechers: null, snatched: null, promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null }, hr: false, uploadedAt: null, detailUrl: "https://acgrip.example/details.php?id=1", tags: [], raw: {}, observedAt: new Date().toISOString(), } as Parameters[3]), ); const row = await h.db .selectFrom("source_release") .select("parsed") .where("id", "=", r.id) .executeTakeFirstOrThrow(); expect(row.parsed).toMatchObject({ title: "假剧", explicitIds: [{ provider: "tmdb", id: "1399" }], words: { applied: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], original: "Fake.Show.S01E06.1080p.WEB-DL-GRP" }, }); }); });