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

304 lines
13 KiB
TypeScript

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 { 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 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("有 active 规则 `EP-12`(季 2、组 ANi)→ rule;规则算出越界 → 规则停用并开待处理", async () => {
const rule = await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ANi",
kind: "expression",
expression: "EP-12",
created_by_type: "user",
created_by_id: "admin",
})
.returning("id")
.executeTakeFirstOrThrow();
// 同作用域(发布组忽略大小写)只能有一条 active
await expect(
h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 2,
release_group: "ani",
kind: "expression",
expression: "EP",
created_by_type: "user",
})
.execute(),
).rejects.toThrow();
const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]");
expect(er(ok.row)).toMatchObject({
method: "rule",
ruleId: rule.id,
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", ruleFailed: rule.id });
expect(bad.row.content_unit_id).toBeNull();
const after = await h.db
.selectFrom("episode_mapping_rule")
.selectAll()
.where("id", "=", rule.id)
.executeTakeFirstOrThrow();
expect(after.status).toBe("disabled");
expect(after.disabled_reason).toContain("rule:validate_fail");
const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`);
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id], failedRules: [rule.id] });
expect(d?.summary).toContain("规则已停用");
});
it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => {
await h.db
.insertInto("episode_mapping_rule")
.values({
media_entity_id: kId,
season: 1,
release_group: "Lilith-Raws",
kind: "expression",
expression: "EP-30",
target_season: 2,
created_by_type: "user",
})
.execute();
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);
});
});