feat(identity): 识别接入集数解析——映射规则表、规范季集写回解析结果、待确认不建单元并开待处理、规则失效停用、按作品重算
迁移 0066_episode_mapping。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
7b9939e0a0
commit
691671a1d2
20 files changed
+1181
-17
No files matched your search
@@ -0,0 +1,303 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user