Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
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<any> =>
|
||
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<string, unknown>) => [
|
||
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<string, unknown>) => [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}/episode-mapping-rules`, {
|
||
body: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-12", targetSeason: 2 },
|
||
});
|
||
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");
|
||
expect(await seasonTotals()).toEqual([
|
||
[0, null, "confirmed"],
|
||
[1, 25, "confirmed"],
|
||
[2, null, "provisional"],
|
||
]);
|
||
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();
|
||
});
|
||
});
|