Files
UHD_Media_Tool/apps/server/test/sources-identity/episode-groups.test.ts
T

334 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});