import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { reresolveEntityEpisodes } from "../../src/modules/sources-identity/service/episode-resolution.ts"; import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts"; import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; import { setWordsText } from "../support/identify-words.ts"; import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; // 集数解析接入识别(docs/plan/episode-resolution.md 批次 2):识别绑定作品后按季表 / 规则换算季集再建单元; // 待确认不建单元、开 episode_mapping 待处理(按作品 + 来源季 + 发布组合并);规则越界即停用;按作品重算。 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}` }, })); const series = ( id: number, title: string, zh: string, status: string, seasons: { n: number; eps: number; start: string }[], ): TmdbFixture => ({ type: "tv", id, original_title: title, original_language: "ja", origin_country: ["JP"], date: seasons[0]!.start, titles: { "zh-CN": zh, "en-US": title }, popularity: 30, alternative_titles: [], external_ids: {}, rich: { status }, seasons: seasons.map((s) => ({ season_number: s.n, air_date: s.start, names: { "zh-CN": `第 ${s.n} 季`, "en-US": `Season ${s.n}` }, episodes: weekly(s.eps, s.start), })), }); // 已完结:S1 = 12、S2 = 13(ANi 连续编号 25 → S02E13) const KUSURIYA = series(990001, "Kusuriya no Hitorigoto", "药屋少女的呢喃", "Ended", [ { n: 1, eps: 12, start: "2023-10-22" }, { n: 2, eps: 13, start: "2025-01-10" }, ]); // 在播:最新季 S2 = 12 const DANDADAN = series(990002, "Dandadan", "胆大党", "Returning Series", [ { n: 1, eps: 12, start: "2024-10-04" }, { n: 2, eps: 12, start: "2025-07-04" }, ]); describe.skipIf(!pg)("集数解析接入识别", () => { let h: Harness; let tmdb: MockTmdb; let siteId: string; let n = 0; let kId: string; beforeAll(async () => { tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA, DANDADAN] }); h = await createHarness("episode_resolution", { tmdbUrl: tmdb.url }); siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); // 副标题带中文名:两种文字的名称都命中,身份识别可自动确认(标题没有年份) const zhName = (title: string) => (title.includes("Dandadan") ? "胆大党" : "药屋少女的呢喃"); const release = (title: string, publishedAt: string | null = null) => h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: String(800000 + ++n), title, subtitle: zhName(title), source_published_at: publishedAt ? new Date(publishedAt) : null, }) .returningAll() .executeTakeFirstOrThrow(); const resolve = async (title: string, publishedAt: string | null = null) => { const r = await release(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, res, row: await row(r.id) }; }; const setWords = (entityId: string, text: string) => setWordsText(h.db, entityId, text); const row = (id: string) => h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); // biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb const er = (r: { episode_resolution: unknown }) => r.episode_resolution as any; // biome-ignore lint/suspicious/noExplicitAny: 同上 const parsedOfRow = (r: { parsed: unknown }) => r.parsed as any; const unitOf = async (id: string | null) => id ? h.db .selectFrom("content_unit") .select(["kind", "season_number", "episode_number"]) .where("id", "=", id) .executeTakeFirstOrThrow() : null; const resolvedEvent = async (srId: string) => (await h.outbox("release.resolved")).filter((e) => e.payload.sourceReleaseId === srId).at(-1); const openEpisodeDecisions = () => h.db .selectFrom("decision") .selectAll() .where("kind", "=", "episode_mapping") .where("status", "in", ["open", "investigating", "awaiting_user"]) .execute(); const episodeUnits = (entityId: string, season: number, episode: number) => h.db .selectFrom("content_unit") .select("id") .where("media_entity_id", "=", entityId) .where("kind", "=", "episode") .where("season_number", "=", season) .where("episode_number", "=", episode) .execute(); it("新建档还没有季表:原号滞后放行;刷新元数据后重算为原号通过", async () => { const first = await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); kId = first.res.mediaEntityId!; expect(er(first.row)).toMatchObject({ method: "unverified", canonical: { season: 1, episodes: { start: 1, end: 1 } }, }); expect(await unitOf(first.row.content_unit_id)).toMatchObject({ season_number: 1, episode_number: 1 }); const r = await refreshMetadata(h.rt, kId, { full: true }); expect(r.status).toBe("refreshed"); const seasons = await h.db .selectFrom("content_unit") .select(["season_number", "episode_total"]) .where("media_entity_id", "=", kId) .where("kind", "=", "season") .orderBy("season_number") .execute(); expect(seasons.filter((s) => s.season_number! >= 1)).toEqual([ { season_number: 1, episode_total: 12 }, { season_number: 2, episode_total: 13 }, ]); expect(er(await row(first.id)).method).toBe("raw"); }); it("ANi 连续编号 `- 25`(S1=12、S2=13 已完结)→ 跨季换算为 S02E13,建 S02E13 单元", async () => { const r = await resolve("[ANi] Kusuriya no Hitorigoto - 25 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); expect(r.res.mediaEntityId).toBe(kId); expect(er(r.row)).toMatchObject({ method: "absolute_cross_season", source: { season: 1, episodes: { start: 25, end: 25 } }, canonical: { season: 2, episodes: { start: 13, end: 13 } }, }); expect(parsedOfRow(r.row)).toMatchObject({ season: 2, episodes: { start: 13, end: 13 }, seasonInferred: false }); expect(parsedOfRow(r.row).releaseGroup.name).toBe("ANi"); expect(await unitOf(r.row.content_unit_id)).toEqual({ kind: "episode", season_number: 2, episode_number: 13 }); expect((await resolvedEvent(r.id))?.payload.contentUnitIds).toEqual([r.row.content_unit_id]); expect(await episodeUnits(kId, 1, 25)).toHaveLength(0); }); it("原号在季表内 S02E05 → raw", async () => { const r = await resolve("Kusuriya.no.Hitorigoto.S02E05.1080p.WEB-DL.H264-NTb"); expect(er(r.row)).toMatchObject({ method: "raw", canonical: { season: 2, episodes: { start: 5, end: 5 } } }); expect(await unitOf(r.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 5 }); }); let lilith40: string; let lilith41: string; it("无法换算 → unresolved:绑定作品但不建单元、开一条待处理;同作品同季同组第二条并入同一条", async () => { const a = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); lilith40 = a.id; expect(a.row).toMatchObject({ media_entity_id: kId, content_unit_id: null, resolution_status: "resolved" }); expect(er(a.row)).toMatchObject({ method: "unresolved", canonical: null }); // 规范值为空:parsed 照用解析结果 expect(parsedOfRow(a.row)).toMatchObject({ season: 1, episodes: { start: 40, end: 40 } }); expect(a.res.contentUnitIds).toEqual([]); expect((await resolvedEvent(a.id))?.payload.contentUnitIds).toEqual([]); expect(await episodeUnits(kId, 1, 40)).toHaveLength(0); let ds = await openEpisodeDecisions(); expect(ds).toHaveLength(1); expect(ds[0]).toMatchObject({ dedupe_key: `episode_mapping:${kId}:1:lilith-raws`, subject_type: "media_entity", subject_id: kId, }); expect(ds[0]!.summary).toContain("S01E40"); expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([a.id]); expect(ds[0]!.evidence).toHaveProperty("suggestion"); const b = await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]"); lilith41 = b.id; ds = await openEpisodeDecisions(); expect(ds).toHaveLength(1); expect((ds[0]!.evidence as { sourceReleaseIds: string[] }).sourceReleaseIds).toEqual([a.id, b.id]); expect(ds[0]!.summary).toContain("共 2 条资源"); }); it("作品识别词 `S02 @ANi >> EP-12` → rule;算出越界 → 待确认,这一行照常生效(不自动停用)", async () => { await setWords(kId, "# 第二季接着第一季编号\nS02 @ANi >> EP-12\n"); const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]"); expect(er(ok.row)).toMatchObject({ method: "rule", ruleLine: 2, ruleText: "S02 @ANi >> EP-12", canonical: { season: 2, episodes: { start: 1, end: 1 } }, }); expect(await unitOf(ok.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 1 }); const bad = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 30 [1080P][Baha][WEB-DL]"); expect(er(bad.row)).toMatchObject({ method: "unresolved", ruleText: "S02 @ANi >> EP-12" }); expect(er(bad.row)).not.toHaveProperty("ruleFailed"); expect(bad.row.content_unit_id).toBeNull(); // 识别词原文没被改动 const w = await h.db .selectFrom("identify_words") .select(["text", "version"]) .where("media_entity_id", "=", kId) .executeTakeFirstOrThrow(); expect(w.text).toBe("# 第二季接着第一季编号\nS02 @ANi >> EP-12\n"); const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`); expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id] }); expect(d?.evidence).not.toHaveProperty("failedRules"); expect(d?.summary).toContain("按作品识别词「S02 @ANi >> EP-12」算出的集数对不上作品季表"); expect(d?.summary).not.toContain("停用"); // 越界之后同一行对新资源照样生效 const again = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 14 [1080P][Baha][WEB-DL]"); expect(er(again.row)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 2, end: 2 } } }); }); it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => { await setWords(kId, "S02 @ANi >> EP-12\nS01 @Lilith-Raws >> S02 EP-30\n"); const decisionId = (await openEpisodeDecisions()).find((x) => x.dedupe_key.endsWith(":1:lilith-raws"))!.id; const r = await h.db.transaction().execute((trx) => reresolveEntityEpisodes(h.ctx, trx, kId)); expect(r.resolved).toBe(2); for (const [id, ep] of [ [lilith40, 10], [lilith41, 11], ] as const) { const x = await row(id); expect(er(x)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: ep, end: ep } } }); expect(parsedOfRow(x)).toMatchObject({ season: 2, episodes: { start: ep, end: ep } }); expect(await unitOf(x.content_unit_id)).toMatchObject({ season_number: 2, episode_number: ep }); // 季集变了:重新发 release.resolved 触发订阅评估 expect((await resolvedEvent(id))?.payload.contentUnitIds).toEqual([x.content_unit_id]); } const d = await h.db.selectFrom("decision").selectAll().where("id", "=", decisionId).executeTakeFirstOrThrow(); expect(d.status).toBe("resolved"); // 规则失效的那条(季 2、ANi)仍待确认 expect((await openEpisodeDecisions()).map((x) => x.dedupe_key)).toEqual([`episode_mapping:${kId}:2:ani`]); }); it("在播最新季超出 2 集(S02E14,S2=12)→ unverified:照常建单元,不开待处理", async () => { const first = await resolve("Dandadan.S01E01.1080p.WEB-DL.H264-NTb"); const dId = first.res.mediaEntityId!; await refreshMetadata(h.rt, dId, { full: true }); // 发布时间远离换算目标 S02E02 的播出日期 → 按元数据滞后放行 const r = await resolve("Dandadan.S02E14.1080p.WEB-DL.H264-NTb", "2026-10-01T12:00:00Z"); expect(r.res.mediaEntityId).toBe(dId); expect(er(r.row)).toMatchObject({ method: "unverified", canonical: { season: 2, episodes: { start: 14, end: 14 } }, }); expect(await unitOf(r.row.content_unit_id)).toMatchObject({ season_number: 2, episode_number: 14 }); expect((await openEpisodeDecisions()).filter((x) => x.subject_id === dId)).toHaveLength(0); }); });