import { createMockTmdb, type MockTmdb, SPLIT_COUR_FIXTURE, TMDB_FIXTURES } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { loadSeasonTable } 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 { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts"; // 剧集组(季集结构):TMDB 默认把两个 cour 合成一季(S1 = 25 集),剧集组「制作季」拆成 S1(1–12)、S2(13–25)。 // 选剧集组后季表 / 单元按组生成、旧单元与资产覆盖按 TMDB 原季集原地迁移、集数映射按组校验与换算、切回默认、刷新按组。 const pg = await pgAvailable(); describe.skipIf(!pg)("剧集组", () => { let h: Harness; let tmdb: MockTmdb; let siteId: string; let n = 0; let entityId: string; let abs13: string; let s01e14: string; let unitE14: string; let unitE30: string; beforeAll(async () => { tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, SPLIT_COUR_FIXTURE] }); h = await createHarness("episode_groups", { tmdbUrl: tmdb.url }); siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id; }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const resolve = async (title: string) => { const r = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: String(820000 + ++n), title, subtitle: "分割番" }) .returningAll() .executeTakeFirstOrThrow(); 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 release = async (id: string): Promise => h.db .selectFrom("source_release") .select(["episode_resolution", "content_unit_id"]) .where("id", "=", id) .executeTakeFirstOrThrow(); const unit = (id: string) => h.db .selectFrom("content_unit") .select(["season_number", "episode_number", "status", "source", "metadata", "title", "parent_unit_id"]) .where("id", "=", id) .executeTakeFirstOrThrow(); const unitAt = async (season: number, episode: number) => ( await h.db .selectFrom("content_unit") .select("id") .where("media_entity_id", "=", entityId) .where("kind", "=", "episode") .where("season_number", "=", season) .where("episode_number", "=", episode) .where("status", "<>", "retired") .executeTakeFirstOrThrow() ).id; const seasonTotals = async () => ( await h.db .selectFrom("content_unit") .select(["season_number", "episode_total", "status"]) .where("media_entity_id", "=", entityId) .where("kind", "=", "season") .where("status", "<>", "retired") .orderBy("season_number") .execute() ).map((s) => [s.season_number, s.episode_total, s.status]); const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) => json(await h.req(path, init)); const setGroup = (groupId: string | null, key?: string) => call(`/media-entities/${entityId}/episode-group`, { method: "PUT", body: { groupId }, ...(key ? { key } : {}), }); it("准备:默认季 S1 = 25 集;`- 13` 与 S01E14 按原号;S01E14 有入库文件;另有一条 S01E30 临时单元", async () => { const r1 = await resolve("[ANi] Split Cour - 13 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]"); entityId = r1.entityId; abs13 = r1.id; expect((await refreshMetadata(h.rt, entityId, { full: true })).status).toBe("refreshed"); s01e14 = (await resolve("[Nekomoe] Split Cour S01E14 [1080p]")).id; expect((await release(abs13)).episode_resolution).toMatchObject({ method: "raw", canonical: { season: 1 } }); expect((await release(s01e14)).episode_resolution).toMatchObject({ method: "raw", canonical: { season: 1, episodes: { start: 14, end: 14 } }, }); expect(await seasonTotals()).toEqual([ [0, null, "confirmed"], [1, 25, "confirmed"], ]); unitE14 = await unitAt(1, 14); const asset = await h.db .insertInto("media_asset") .values({ media_entity_id: entityId, kind: "video", status: "active" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("asset_unit_coverage") .values({ media_asset_id: asset.id, content_unit_id: unitE14 }) .execute(); const s1 = await h.db .selectFrom("content_unit") .select("id") .where("media_entity_id", "=", entityId) .where("kind", "=", "season") .where("season_number", "=", 1) .executeTakeFirstOrThrow(); unitE30 = ( await h.db .insertInto("content_unit") .values({ media_entity_id: entityId, parent_unit_id: s1.id, kind: "episode", season_number: 1, episode_number: 30, status: "provisional", source: "release", }) .returning("id") .executeTakeFirstOrThrow() ).id; }); it("剧集组列表与季卡片预览:按组显示季名、集数、首播、本地已入库(换结构前按原季集对照)", async () => { const list = await call(`/media-entities/${entityId}/episode-groups`, { key: READ_KEY }); expect(list.status).toBe(200); expect(list.body.data).toMatchObject({ applicable: true, current: null, error: null }); expect(list.body.data.groups.map((g: { id: string; typeLabel: string }) => [g.id, g.typeLabel])).toEqual([ ["grp-production", "制作季"], ["grp-aired", "原始播出"], ]); const preview = await call(`/media-entities/${entityId}/episode-structure?groupId=grp-production`); expect(preview.status).toBe(200); expect(preview.body.data).toMatchObject({ groupId: "grp-production", name: "制作季", current: false }); expect( preview.body.data.seasons.map((s: Record) => [ s.seasonNumber, s.name, s.episodeCount, s.airDate, s.posterPath, s.inLibrary, s.seasonUnitId, ]), ).toEqual([ [1, "第一部", 12, "2024-01-07", "/split-s1.jpg", 0, null], [2, "第二部", 13, "2024-07-07", "/split-s1.jpg", 1, null], ]); expect(preview.body.data.seasons[1].episodes[1]).toMatchObject({ episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14, inLibrary: true, }); const def = await call(`/media-entities/${entityId}/episode-structure`); expect(def.body.data).toMatchObject({ groupId: null, current: true }); const s1 = def.body.data.seasons.find((s: { seasonNumber: number }) => s.seasonNumber === 1); expect(s1).toMatchObject({ episodeCount: 25, inLibrary: 1 }); expect(s1.seasonUnitId).toBeTruthy(); }); it("切到剧集组:只读 Key 不能改;集单元按原季集原地迁移(资产覆盖跟着走),季表按组,资源全量重算", async () => { expect((await setGroup("grp-production", READ_KEY)).status).toBe(403); const r = await setGroup("grp-production", EXEC_KEY); expect(r.status, JSON.stringify(r.body)).toBe(200); const d = r.body.data; expect(d).toMatchObject({ changed: true, current: { groupId: "grp-production", name: "制作季", setBy: "api_key:operator" }, moved: 13, kept: 12, seasons: [ { seasonNumber: 1, episodeCount: 12 }, { seasonNumber: 2, episodeCount: 13 }, ], orphanSeasons: [0], refresh: "refreshed", }); expect( d.unmatched.map((u: Record) => [u.seasonNumber, u.episodeNumber, u.reason, u.referenced]), ).toEqual( expect.arrayContaining([ [0, 1, "not_in_target", false], [1, 30, "not_in_target", false], ]), ); // 同一个单元(id 不变)挪到 S02E02,资产覆盖仍指向它;标题 / 原季集对照随单元 const u14 = await unit(unitE14); expect(u14).toMatchObject({ season_number: 2, episode_number: 2, status: "confirmed", title: "下篇第2话" }); expect(u14.metadata).toMatchObject({ tmdbSeason: { value: 1 }, tmdbEpisode: { value: 14 } }); const cov = await h.db .selectFrom("asset_unit_coverage") .select("content_unit_id") .where("content_unit_id", "=", unitE14) .execute(); expect(cov).toHaveLength(1); expect(await unit(unitE30)).toMatchObject({ season_number: 1, episode_number: 30, status: "provisional" }); expect(await seasonTotals()).toEqual([ [0, null, "provisional"], [1, 12, "confirmed"], [2, 13, "confirmed"], ]); const s2Unit = await h.db .selectFrom("content_unit") .select(["id", "title"]) .where("media_entity_id", "=", entityId) .where("kind", "=", "season") .where("season_number", "=", 2) .executeTakeFirstOrThrow(); expect(u14.parent_unit_id).toBe(s2Unit.id); expect(s2Unit.title).toBe("第二部"); // 季表按组;带 25 条原季集对照 const table = await loadSeasonTable(h.db, entityId); expect(table.seasons.filter((s) => s.season >= 1).map((s) => [s.season, s.total])).toEqual([ [1, 12], [2, 13], ]); expect(table.origins).toHaveLength(25); // `- 13`(季号推定)归第 2 季第 1 集;按默认季编号的 S01E14 换到 S02E02,资源指向迁移后的同一个单元 expect((await release(abs13)).episode_resolution).toMatchObject({ canonical: { season: 2, episodes: { start: 1, end: 1 } }, }); const r14 = await release(s01e14); expect(r14.episode_resolution).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: { start: 2, end: 2 } }, }); expect(r14.content_unit_id).toBe(unitE14); expect(d.reresolve).toMatchObject({ releases: 2, changed: 2 }); const ent = await call(`/media-entities/${entityId}`); expect(ent.body.data).toMatchObject({ episodeGroupId: "grp-production", episodeGroupName: "制作季" }); const audit = await h.db .selectFrom("audit_event") .select("payload") .where("action", "=", "media_entity.episode_group") .execute(); expect(audit).toHaveLength(1); }); it("集数映射按组:规则目标是组内季集,按组的季表校验;新资源按组解析", async () => { const rule = await call(`/media-entities/${entityId}/identify-words`, { method: "PUT", body: { text: "S01 @Lilith-Raws >> S02 EP-12\n", expectedVersion: 0 }, }); expect(rule.status, JSON.stringify(rule.body)).toBe(200); const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id; expect((await release(lil)).episode_resolution).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 8, end: 8 } }, }); expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8)); // 越过组内 S2 的 13 集:规则校验不过 → 待确认(规则照常生效,不停用) const bad = (await resolve("[Lilith-Raws] Split Cour - 27 [Baha][WEB-DL][1080p]")).id; expect((await release(bad)).episode_resolution).toMatchObject({ method: "unresolved" }); const preview = await call(`/media-entities/${entityId}/episode-mapping/preview`, { body: {} }); expect(preview.body.data.seasons.structure).toEqual({ groupId: "grp-production", name: "制作季" }); }); it("剧集组下刷新元数据按组拉取:不改季集结构,单元保持组内季集", async () => { const r = await refreshMetadata(h.rt, entityId, { full: true }); expect(r.status).toBe("refreshed"); expect(r.seasons?.map((s) => [s.season, s.total])).toEqual([ [1, 12], [2, 13], ]); expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2, title: "下篇第2话" }); expect(tmdb.callsTo(/\/3\/tv\/episode_group\/grp-production/).length).toBeGreaterThan(0); }); it("剧集组之间切换:按原季集对照到另一组(全部 25 集一季)", async () => { const r = await setGroup("grp-aired"); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data).toMatchObject({ moved: 13, kept: 12, orphanSeasons: [0, 2] }); expect(await unit(unitE14)).toMatchObject({ season_number: 1, episode_number: 14 }); expect((await release(s01e14)).episode_resolution).toMatchObject({ method: "raw" }); }); it("切回 TMDB 默认季:单元回到原号,原季集对照清掉,特别篇季转正", async () => { await setGroup("grp-production"); expect(await unit(unitE14)).toMatchObject({ season_number: 2, episode_number: 2 }); const r = await setGroup(null); expect(r.status, JSON.stringify(r.body)).toBe(200); expect(r.body.data).toMatchObject({ changed: true, current: null, orphanSeasons: [2] }); const u14 = await unit(unitE14); expect(u14).toMatchObject({ season_number: 1, episode_number: 14, status: "confirmed" }); expect(u14.metadata).not.toHaveProperty("tmdbSeason"); // 组里的第 2 季在默认结构里没有,切回后成了没有集、没被引用的空季:随刷新退役,不再挂着临时季 expect(await seasonTotals()).toEqual([ [0, null, "confirmed"], [1, 25, "confirmed"], ]); expect((await loadSeasonTable(h.db, entityId)).origins).toBeUndefined(); expect((await release(abs13)).episode_resolution).toMatchObject({ method: "raw", canonical: { season: 1, episodes: { start: 13, end: 13 } }, }); expect((await release(s01e14)).content_unit_id).toBe(unitE14); // 同一个结构再选一次:不变 expect((await setGroup(null)).body.data).toMatchObject({ changed: false }); }); it("剧集组不存在 404,不改作品设置", async () => { const r = await setGroup("nope"); expect(r.status).toBe(404); const e = await h.db .selectFrom("media_entity") .select("episode_group_id") .where("id", "=", entityId) .executeTakeFirstOrThrow(); expect(e.episode_group_id).toBeNull(); }); });