- 新表 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
379 lines
18 KiB
TypeScript
379 lines
18 KiB
TypeScript
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { reresolveWordsBatch } from "../../src/modules/sources-identity/service/identify-words.ts";
|
|
import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts";
|
|
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
|
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
|
import { upsertSiteRelease } from "../../src/modules/sources-identity/service/snapshot.ts";
|
|
import { createHarness, EXEC_KEY, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
|
|
|
|
// 识别词接口(docs/design/identify-words.md):作品识别词读写 / 试算 / 版本冲突、集数待确认事项的确认与忽略(写一行进识别词)、
|
|
// 规则照写的用(对不上季表只待确认,不自动停用)、全局识别词试算 / 保存 / 后台重算范围、单行示例。
|
|
|
|
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}` },
|
|
}));
|
|
|
|
// 已完结:S1 = 12(2023-10-22 起)、S2 = 13(2025-01-10 起,每周一集)
|
|
const KUSURIYA: TmdbFixture = {
|
|
type: "tv",
|
|
id: 990101,
|
|
original_title: "Kusuriya no Hitorigoto",
|
|
original_language: "ja",
|
|
origin_country: ["JP"],
|
|
date: "2023-10-22",
|
|
titles: { "zh-CN": "药屋少女的呢喃", "en-US": "Kusuriya no Hitorigoto" },
|
|
popularity: 30,
|
|
alternative_titles: [],
|
|
external_ids: {},
|
|
rich: { status: "Ended" },
|
|
seasons: [
|
|
{ season_number: 1, air_date: "2023-10-22", names: { "zh-CN": "第 1 季" }, episodes: weekly(12, "2023-10-22") },
|
|
{ season_number: 2, air_date: "2025-01-10", names: { "zh-CN": "第 2 季" }, episodes: weekly(13, "2025-01-10") },
|
|
],
|
|
} as TmdbFixture;
|
|
|
|
describe.skipIf(!pg)("识别词接口", () => {
|
|
let h: Harness;
|
|
let tmdb: MockTmdb;
|
|
let siteId: string;
|
|
let n = 0;
|
|
let kId: string;
|
|
let lilith40: string;
|
|
let lilith41: string;
|
|
|
|
beforeAll(async () => {
|
|
invalidateGlobalWords();
|
|
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
|
|
h = await createHarness("identify_words_api", { tmdbUrl: tmdb.url });
|
|
siteId = (await seedSite(h, { baseUrl: "https://acgrip.example/" })).site.id;
|
|
});
|
|
afterAll(async () => {
|
|
await h?.close();
|
|
await tmdb?.close();
|
|
});
|
|
|
|
const insert = (title: string, publishedAt: string | null = null, subtitle = "药屋少女的呢喃") =>
|
|
h.db
|
|
.insertInto("source_release")
|
|
.values({
|
|
site_id: siteId,
|
|
site_release_id: String(810000 + ++n),
|
|
title,
|
|
subtitle,
|
|
source_published_at: publishedAt ? new Date(publishedAt) : null,
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
const resolve = async (title: string, publishedAt: string | null = null) => {
|
|
const r = await insert(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, entityId: res.mediaEntityId! };
|
|
};
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言读 jsonb
|
|
const er = async (id: string): Promise<any> =>
|
|
(
|
|
await h.db
|
|
.selectFrom("source_release")
|
|
.select("episode_resolution")
|
|
.where("id", "=", id)
|
|
.executeTakeFirstOrThrow()
|
|
).episode_resolution;
|
|
const openDecisions = () =>
|
|
h.db
|
|
.selectFrom("decision")
|
|
.selectAll()
|
|
.where("kind", "=", "episode_mapping")
|
|
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
|
.execute();
|
|
const call = async (path: string, init: { method?: string; body?: unknown; key?: string } = {}) =>
|
|
json(await h.req(path, init));
|
|
const entityWords = async () => (await call(`/media-entities/${kId}/identify-words`)).body.data;
|
|
const saveEntity = (text: string, expectedVersion: number, key?: string) =>
|
|
call(`/media-entities/${kId}/identify-words`, {
|
|
method: "PUT",
|
|
body: { text, expectedVersion },
|
|
...(key ? { key } : {}),
|
|
});
|
|
|
|
it("准备:Lilith-Raws 连续编号 40 / 41 待确认,事项选项里带要写进识别词的那一行", async () => {
|
|
kId = (await resolve("[ANi] Kusuriya no Hitorigoto - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]")).entityId;
|
|
expect((await refreshMetadata(h.rt, kId, { full: true })).status).toBe("refreshed");
|
|
lilith40 = (
|
|
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 40 [Baha][WEB-DL][1080p]", "2025-03-14T15:00:00Z")
|
|
).id;
|
|
lilith41 = (
|
|
await resolve("[Lilith-Raws] Kusuriya no Hitorigoto - 41 [Baha][WEB-DL][1080p]", "2025-03-21T15:00:00Z")
|
|
).id;
|
|
expect((await er(lilith40)).method).toBe("unresolved");
|
|
const ds = await openDecisions();
|
|
expect(ds).toHaveLength(1);
|
|
expect(ds[0]!.evidence).toMatchObject({ suggestion: { expression: "EP-30", targetSeason: 2 } });
|
|
const options = ds[0]!.options as { key: string; input: { line: string } }[];
|
|
expect(options.map((o) => [o.key, o.input.line])).toEqual([
|
|
["confirm_suggestion", "S01 @Lilith-Raws >> S02 EP-30"],
|
|
["ignore_rule", "S01 @Lilith-Raws >> 忽略"],
|
|
]);
|
|
const w = await entityWords();
|
|
expect(w).toMatchObject({ applicable: true, block: { text: "", version: 0 } });
|
|
expect(w.knownGroups).toEqual(expect.arrayContaining(["ANi", "Lilith-Raws"]));
|
|
});
|
|
|
|
it("试算:逐行错误带行号,换算前后与仍对不上季表的条数;纯读不落库", async () => {
|
|
const r = await call(`/media-entities/${kId}/identify-words/dry-run`, {
|
|
body: { text: "# 连续编号\nS01 @Lilith-Raws >> S02 EP-30\nS0X >> EP\nS02 >> S01 2EP\n" },
|
|
});
|
|
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
|
expect(r.body.data.errors).toEqual([
|
|
{ line: 3, message: expect.stringContaining("不是季号") },
|
|
{ line: 4, message: expect.stringContaining("EP") },
|
|
]);
|
|
expect(r.body.data).toMatchObject({ rules: 1, changed: 2, mismatched: 0 });
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
|
const i40 = r.body.data.preview.items.find((x: any) => x.sourceReleaseId === lilith40);
|
|
expect(i40).toMatchObject({
|
|
current: { method: "unresolved" },
|
|
preview: { method: "rule", ruleLine: 2, canonical: { season: 2, episodes: { start: 10, end: 10 } } },
|
|
validation: "pass",
|
|
changed: true,
|
|
words: [],
|
|
});
|
|
// 算出越界:照样标对不上,算作仍对不上季表
|
|
const bad = await call(`/media-entities/${kId}/identify-words/dry-run`, {
|
|
body: { text: "S01 @Lilith-Raws >> S02 EP-20\n" },
|
|
});
|
|
expect(bad.body.data).toMatchObject({ errors: [], mismatched: 2 });
|
|
expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0);
|
|
expect((await er(lilith40)).method).toBe("unresolved");
|
|
});
|
|
|
|
it("保存校验:有错的行拒绝、版本不符 409、只读 Key 403", async () => {
|
|
const e = await saveEntity("S0X >> EP\n", 0);
|
|
expect(e.status).toBe(400);
|
|
expect(e.body.error.message).toContain("第 1 行");
|
|
expect((await saveEntity("S01 >> EP\n", 3)).status).toBe(409);
|
|
expect((await saveEntity("S01 >> EP\n", 0, READ_KEY)).status).toBe(403);
|
|
expect(await h.db.selectFrom("identify_words").select("id").execute()).toHaveLength(0);
|
|
});
|
|
|
|
it("集数待确认「确认建议规则」:这一行连同来历注释追加进作品识别词,重算后资源解决、事项关闭", async () => {
|
|
const d = (await openDecisions())[0]!;
|
|
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "confirm_suggestion" } });
|
|
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
|
expect(r.body.data).toMatchObject({
|
|
status: "resolved",
|
|
resolution: {
|
|
optionKey: "confirm_suggestion",
|
|
execution: { line: "S01 @Lilith-Raws >> S02 EP-30", lineNumber: 2, reresolve: { resolved: 2 } },
|
|
},
|
|
});
|
|
const w = await entityWords();
|
|
expect(w.block.version).toBe(1);
|
|
expect(w.block.text).toMatch(
|
|
/^# \d{4}-\d{2}-\d{2} 按发布时间对播出日期推出的建议\nS01 @Lilith-Raws >> S02 EP-30\n$/,
|
|
);
|
|
expect(await er(lilith40)).toMatchObject({
|
|
method: "rule",
|
|
ruleText: "S01 @Lilith-Raws >> S02 EP-30",
|
|
canonical: { season: 2, episodes: { start: 10, end: 10 } },
|
|
});
|
|
expect(await openDecisions()).toHaveLength(0);
|
|
const audit = await h.db
|
|
.selectFrom("audit_event")
|
|
.select(["action", "payload"])
|
|
.where("action", "=", "identify_words.updated")
|
|
.execute();
|
|
expect(audit[0]?.payload).toMatchObject({ mediaEntityId: kId, added: ["S01 @Lilith-Raws >> S02 EP-30"] });
|
|
});
|
|
|
|
it("改这一行:每次保存重算并返回受影响数量;算出越界只待确认,这一行原样保留", async () => {
|
|
const w = await entityWords();
|
|
const edited = w.block.text.replace("EP-30", "EP-31");
|
|
const r = await saveEntity(edited, w.block.version);
|
|
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
|
expect(r.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
|
|
expect(r.body.data.block.version).toBe(w.block.version + 1);
|
|
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
|
|
|
|
// 写成 EP(集号不变):S02E40 / E41 超出第 2 季,资源待确认,这一行不会被停用或改写
|
|
const over = edited.replace("EP-31", "EP");
|
|
const o = await saveEntity(over, w.block.version + 1);
|
|
expect(o.status).toBe(200);
|
|
expect(await er(lilith40)).toMatchObject({ method: "unresolved", ruleText: "S01 @Lilith-Raws >> S02 EP" });
|
|
expect((await entityWords()).block.text).toBe(over);
|
|
expect(await openDecisions()).toHaveLength(1);
|
|
});
|
|
|
|
it("注释掉这一行 = 停用:资源回到自动判定;选「忽略」把忽略行追加进去", async () => {
|
|
const w = await entityWords();
|
|
const off = w.block.text.replace("S01 @Lilith-Raws >> S02 EP", "# S01 @Lilith-Raws >> S02 EP");
|
|
expect((await saveEntity(off, w.block.version)).status).toBe(200);
|
|
expect((await er(lilith41)).method).toBe("unresolved");
|
|
expect((await er(lilith41)).ruleText).toBeUndefined();
|
|
|
|
const d = (await openDecisions())[0]!;
|
|
const r = await call(`/decisions/${d.id}/resolve`, { body: { optionKey: "ignore_rule" } });
|
|
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
|
expect(r.body.data.status).toBe("dismissed");
|
|
const text = (await entityWords()).block.text as string;
|
|
expect(text.startsWith(off.trimEnd())).toBe(true);
|
|
expect(text.trimEnd().split("\n").at(-1)).toBe("S01 @Lilith-Raws >> 忽略");
|
|
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
|
|
expect(await openDecisions()).toHaveLength(0);
|
|
});
|
|
|
|
it("有作品识别词的作品列表:规则行数、停用行数、可按名称搜", async () => {
|
|
const r = await call("/identify-words/entities");
|
|
expect(r.status).toBe(200);
|
|
expect(r.body.data.items).toEqual([
|
|
expect.objectContaining({ mediaEntityId: kId, title: "药屋少女的呢喃", rules: 1, disabled: 1, errors: 0 }),
|
|
]);
|
|
expect((await call("/identify-words/entities?q=不存在")).body.data.items).toEqual([]);
|
|
});
|
|
|
|
it("单行示例:作品识别词找一条命中的近期资源,给出换算后的季集与校验;也可以自己给标题", async () => {
|
|
const r = await call("/identify-words/try", {
|
|
body: { scope: "entity", mediaEntityId: kId, line: "S01 @Lilith-Raws >> S02 EP-30" },
|
|
});
|
|
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
|
expect(r.body.data).toMatchObject({
|
|
matched: true,
|
|
sample: { title: expect.stringContaining("Lilith-Raws") },
|
|
episode: { method: "rule", canonical: { season: 2 } },
|
|
validation: "pass",
|
|
});
|
|
const own = await call("/identify-words/try", {
|
|
body: {
|
|
scope: "entity",
|
|
mediaEntityId: kId,
|
|
line: "S01 @Lilith-Raws >> S02 EP-30",
|
|
title: "[Lilith-Raws] X - 99",
|
|
},
|
|
});
|
|
expect(own.body.data).toMatchObject({ matched: true, validation: "fail", episode: { method: "unresolved" } });
|
|
const bad = await call("/identify-words/try", { body: { scope: "entity", mediaEntityId: kId, line: "S0X >> EP" } });
|
|
expect(bad.body.data.error).toContain("不是季号");
|
|
});
|
|
|
|
it("全局识别词:试算列出识别结果会变的资源;保存要带版本;生效行有增减才排后台重算", async () => {
|
|
const other = await insert("Fake.Show.S01E05.1080p.WEB-DL-GRP", null, "");
|
|
const g0 = (await call("/identify-words/global")).body.data;
|
|
expect(g0).toMatchObject({ text: "", version: 0, reresolve: null });
|
|
const text = "# 测试\nFake.Show => 假剧 {[tmdbid=1399;type=tv]}\n([ => broken\n";
|
|
const dry = await call("/identify-words/global/dry-run", { body: { text } });
|
|
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
|
|
expect(dry.body.data.errors).toEqual([{ line: 3, message: expect.stringContaining("正则") }]);
|
|
expect(dry.body.data.added).toEqual(["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}", "([ => broken"]);
|
|
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
|
const item = dry.body.data.items.find((x: any) => x.sourceReleaseId === other.id);
|
|
expect(item).toMatchObject({
|
|
words: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"],
|
|
before: { title: "Fake Show", explicitIds: [] },
|
|
after: { title: "假剧", explicitIds: [{ provider: "tmdb", id: "1399" }] },
|
|
});
|
|
expect(dry.body.data.matched).toBe(1);
|
|
|
|
expect(
|
|
(await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 }, key: EXEC_KEY }))
|
|
.status,
|
|
).toBe(403);
|
|
const saved = await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } });
|
|
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
|
|
expect(saved.body.data.block).toMatchObject({ version: 1, reresolve: { status: "queued" } });
|
|
expect((await call("/identify-words/global", { method: "PUT", body: { text, expectedVersion: 0 } })).status).toBe(
|
|
409,
|
|
);
|
|
expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toEqual([
|
|
expect.objectContaining({ input: { version: 1 } }),
|
|
]);
|
|
// 只改注释:生效行没变,不排重算
|
|
const again = await call("/identify-words/global", {
|
|
method: "PUT",
|
|
body: { text: text.replace("# 测试", "# 改了注释"), expectedVersion: 1 },
|
|
});
|
|
expect(again.body.data.block.version).toBe(2);
|
|
expect(h.engine.enqueued.filter((e) => e.task === "reresolveIdentifyWords")).toHaveLength(1);
|
|
});
|
|
|
|
it("后台重算:只重新识别标题命中改动行的资源,进度写回;被新版本取代就停", async () => {
|
|
// 版本 2 的保存没有改动行,版本 1 的任务已被取代
|
|
expect(await reresolveWordsBatch(h.ctx, 1, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull();
|
|
const g = (await call("/identify-words/global")).body.data;
|
|
const saved = await call("/identify-words/global", {
|
|
method: "PUT",
|
|
body: { text: `${g.text}Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28\n`, expectedVersion: g.version },
|
|
});
|
|
const version = saved.body.data.block.version as number;
|
|
const before = h.engine.enqueued.length;
|
|
expect(await reresolveWordsBatch(h.ctx, version, { last: null, scanned: 0, matched: 0, queued: 0 })).toBeNull();
|
|
const queued = h.engine.enqueued.slice(before).filter((e) => e.task === "resolveObservation");
|
|
expect(queued.map((e) => (e.input as { sourceReleaseId: string }).sourceReleaseId).sort()).toEqual(
|
|
[lilith40, lilith41].sort(),
|
|
);
|
|
const p = (await call("/identify-words/global")).body.data.reresolve;
|
|
expect(p).toMatchObject({ status: "done", matched: 2, queued: 2 });
|
|
expect(p.scanned).toBeGreaterThanOrEqual(4);
|
|
|
|
// 重新识别后集数按全局识别词偏移(40 → 12,对上第 1 季),命中的行记在解析结果上
|
|
await resolveRelease(h.rt, { sourceReleaseId: lilith40, force: true, trigger: "review" });
|
|
const row = await h.db
|
|
.selectFrom("source_release")
|
|
.select(["parsed"])
|
|
.where("id", "=", lilith40)
|
|
.executeTakeFirstOrThrow();
|
|
expect((row.parsed as { words?: { applied: string[] } }).words?.applied).toEqual([
|
|
"Kusuriya no Hitorigoto - 4 <> \\[Baha >> EP-28",
|
|
]);
|
|
});
|
|
|
|
it("全局示例:没给标题时从近期资源里找命中的", async () => {
|
|
const r = await call("/identify-words/try", { body: { scope: "global", line: "Fake.Show => 假剧" } });
|
|
expect(r.body.data).toMatchObject({
|
|
matched: true,
|
|
sample: { title: "Fake.Show.S01E05.1080p.WEB-DL-GRP" },
|
|
titleAfter: "假剧.S01E05.1080p.WEB-DL-GRP",
|
|
parsed: { title: "假剧", season: 1, episodes: { start: 5, end: 5 } },
|
|
});
|
|
const none = await call("/identify-words/try", { body: { scope: "global", line: "完全不存在的词 => X" } });
|
|
expect(none.body.data).toMatchObject({ matched: false, sample: null });
|
|
});
|
|
|
|
it("站点列表抓取落库时就按全局识别词解析(parsed 记命中的行)", async () => {
|
|
const r = await h.db.transaction().execute((trx) =>
|
|
upsertSiteRelease(h.rt, trx, { id: siteId }, {
|
|
siteId,
|
|
siteReleaseId: "words-snap-1",
|
|
title: "Fake.Show.S01E06.1080p.WEB-DL-GRP",
|
|
category: { id: "1", name: "TV" },
|
|
sizeBytes: null,
|
|
seeders: null,
|
|
leechers: null,
|
|
snatched: null,
|
|
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
|
|
hr: false,
|
|
uploadedAt: null,
|
|
detailUrl: "https://acgrip.example/details.php?id=1",
|
|
tags: [],
|
|
raw: {},
|
|
observedAt: new Date().toISOString(),
|
|
} as Parameters<typeof upsertSiteRelease>[3]),
|
|
);
|
|
const row = await h.db
|
|
.selectFrom("source_release")
|
|
.select("parsed")
|
|
.where("id", "=", r.id)
|
|
.executeTakeFirstOrThrow();
|
|
expect(row.parsed).toMatchObject({
|
|
title: "假剧",
|
|
explicitIds: [{ provider: "tmdb", id: "1399" }],
|
|
words: { applied: ["Fake.Show => 假剧 {[tmdbid=1399;type=tv]}"], original: "Fake.Show.S01E06.1080p.WEB-DL-GRP" },
|
|
});
|
|
});
|
|
});
|