- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
276 lines
13 KiB
TypeScript
276 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 { setWordsText } from "../support/identify-words.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 setWords = (entityId: string, text: string) => setWordsText(h.db, entityId, text);
|
|
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("作品识别词 `S02 @ANi >> EP-12` → rule;算出越界 → 待确认,这一行照常生效(不自动停用)", async () => {
|
|
await setWords(kId, "# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
|
|
const ok = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 13 [1080P][Baha][WEB-DL]");
|
|
expect(er(ok.row)).toMatchObject({
|
|
method: "rule",
|
|
ruleLine: 2,
|
|
ruleText: "S02 @ANi >> EP-12",
|
|
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", ruleText: "S02 @ANi >> EP-12" });
|
|
expect(er(bad.row)).not.toHaveProperty("ruleFailed");
|
|
expect(bad.row.content_unit_id).toBeNull();
|
|
// 识别词原文没被改动
|
|
const w = await h.db
|
|
.selectFrom("identify_words")
|
|
.select(["text", "version"])
|
|
.where("media_entity_id", "=", kId)
|
|
.executeTakeFirstOrThrow();
|
|
expect(w.text).toBe("# 第二季接着第一季编号\nS02 @ANi >> EP-12\n");
|
|
const d = (await openEpisodeDecisions()).find((x) => x.dedupe_key === `episode_mapping:${kId}:2:ani`);
|
|
expect(d?.evidence).toMatchObject({ sourceReleaseIds: [bad.id] });
|
|
expect(d?.evidence).not.toHaveProperty("failedRules");
|
|
expect(d?.summary).toContain("按作品识别词「S02 @ANi >> EP-12」算出的集数对不上作品季表");
|
|
expect(d?.summary).not.toContain("停用");
|
|
|
|
// 越界之后同一行对新资源照样生效
|
|
const again = await resolve("[ANi] Kusuriya no Hitorigoto S02 - 14 [1080P][Baha][WEB-DL]");
|
|
expect(er(again.row)).toMatchObject({ method: "rule", canonical: { season: 2, episodes: { start: 2, end: 2 } } });
|
|
});
|
|
|
|
it("新增规则后按作品重算:之前待确认的资源解决、建出单元、待处理关闭", async () => {
|
|
await setWords(kId, "S02 @ANi >> EP-12\nS01 @Lilith-Raws >> S02 EP-30\n");
|
|
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);
|
|
});
|
|
});
|