feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 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
This commit is contained in:
1 parent
12f8fc59c3
commit
63e94b308e
90 files changed
+6366
-2131
No files matched your search
@@ -8,6 +8,7 @@ import { submitDownload, syncDownloads } from "../../src/modules/acquisition-org
|
||||
import { type FrozenPlan, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { queueScrape, runScrapeRecord } from "../../src/modules/acquisition-organize/services/scrape.ts";
|
||||
import { canonicalParsed } from "../../src/modules/sources-identity/service/episode-resolution.ts";
|
||||
import { setWordsText } from "../support/identify-words.ts";
|
||||
import { createHarness, fakeDisk, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||
|
||||
// 文件侧集数映射(docs/plan/episode-resolution.md 批次 3):整理计划与季包选文件对文件名重新解析出的季集,
|
||||
@@ -264,27 +265,32 @@ describe.skipIf(!hasPg)("整理与季包选文件按作品映射集数(真实 PG
|
||||
expect(decision.evidence).toMatchObject({ sourceReleaseIds: [rel.id] });
|
||||
});
|
||||
|
||||
it("有 active 规则 EP-12(→ 第 2 季)时整理按规则", async () => {
|
||||
it("作品识别词 `S01 @ANi >> S02 EP-12` 时整理按这一行", async () => {
|
||||
const { id } = await series("规则作品");
|
||||
const rule = await h.db
|
||||
.insertInto("episode_mapping_rule")
|
||||
.values({
|
||||
media_entity_id: id,
|
||||
season: 1,
|
||||
release_group: "ANi",
|
||||
kind: "expression",
|
||||
expression: "EP-12",
|
||||
target_season: 2,
|
||||
created_by_type: "user",
|
||||
created_by_id: "admin",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await setWordsText(h.db, id, "S01 @ANi >> S02 EP-12\n");
|
||||
const plan = await dryRun(id, "rule");
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 2, episodes: { start: 2, end: 2 } });
|
||||
expect(m!.dst).toMatch(/S02E02/);
|
||||
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "rule", ruleId: rule.id });
|
||||
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({
|
||||
method: "rule",
|
||||
ruleLine: 1,
|
||||
ruleText: "S01 @ANi >> S02 EP-12",
|
||||
});
|
||||
});
|
||||
|
||||
it("全局识别词也套到整理时的文件名上:`Rule Show - <> \\[1080P >> EP-12` 把 - 14 认成第 2 集", async () => {
|
||||
const { id } = await series("全局识别词作品");
|
||||
await setWordsText(h.db, null, "Rule Show - <> \\[1080P >> EP-12\n");
|
||||
try {
|
||||
const plan = await dryRun(id, "rule");
|
||||
const [m] = mains(plan);
|
||||
expect(m).toMatchObject({ season: 1, episodes: { start: 2, end: 2 } });
|
||||
expect(m!.dst).toMatch(/S01E02/);
|
||||
expect(Object.values(plan.facts)[0]!.episodeResolution).toMatchObject({ method: "raw" });
|
||||
} finally {
|
||||
await setWordsText(h.db, null, "");
|
||||
}
|
||||
});
|
||||
|
||||
it("原号本来就对(S01E05):整理行为不变", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { setWordsText } from "../support/identify-words.ts";
|
||||
import { createFmHarness, type FmHarness, pgAvailable, READ_KEY, seedEntity } from "./harness.ts";
|
||||
|
||||
// 文件管理识别 / 推荐名按作品映射集数(docs/plan/episode-resolution.md 批次 3,只读):识别到本地剧集作品后,
|
||||
@@ -97,4 +98,34 @@ describe.skipIf(skip)("文件管理识别与推荐名按作品映射集数", ()
|
||||
);
|
||||
expect(bad.body.data).toMatchObject({ name: null, reason: "episode_unresolved", mediaEntityId: show });
|
||||
});
|
||||
|
||||
it("全局识别词与作品识别词都生效:`- 40` 经全局识别词减 15 后是 25,再按作品季表认作 S02E13", async () => {
|
||||
await setWordsText(h.db, null, "Kusuriya no Hitorigoto - <> \\[1080P >> EP-15\n");
|
||||
try {
|
||||
const r = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{ items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
const [it40] = r.body.data.items;
|
||||
expect(it40.parsed).toMatchObject({ season: 2, episodes: [13] });
|
||||
expect(it40.suggestion).toMatchObject({ mediaEntityId: show, contentUnit: "S02E13", contentUnitId: s2e13 });
|
||||
|
||||
// 再加作品识别词:全局识别词先改文件名(25),作品识别词再换算(25 - 24 → S02E01)
|
||||
await setWordsText(h.db, show, "S01 @ANi >> S02 EP-24\n");
|
||||
const mapped = await h.api(
|
||||
"POST",
|
||||
"/files/identify",
|
||||
{ items: [{ root: h.dlRoot, path: "[ANi] Kusuriya no Hitorigoto - 40 [1080P].mkv" }], minSizeBytes: 0 },
|
||||
READ_KEY,
|
||||
);
|
||||
expect(mapped.body.data.items[0].parsed).toMatchObject({ season: 2, episodes: [1] });
|
||||
expect(mapped.body.data.items[0].suggestion).toMatchObject({ contentUnit: "S02E01" });
|
||||
} finally {
|
||||
await setWordsText(h.db, null, "");
|
||||
await setWordsText(h.db, show, "");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { FrozenPlan } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { setWordsText } from "../support/identify-words.ts";
|
||||
import { createFmHarness, EXEC_KEY, type FmHarness, key, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
|
||||
|
||||
// 整理时指定季集结构(docs/runbook/organize.md「整理时指定剧集组」):作品是 TMDB 默认季(S1 = 25 集),
|
||||
@@ -271,18 +272,7 @@ describe.skipIf(skip)("整理时指定季集结构", () => {
|
||||
|
||||
it("作品的映射规则按作品结构写:临时结构下规则结果经 TMDB 原季集换到指定结构", async () => {
|
||||
// 作品现在是「制作季」:ABS 组按 EP-24 换到组的第 2 季(- 26 → 组 S02E02 = 默认 S01E14)
|
||||
await h.db
|
||||
.insertInto("episode_mapping_rule")
|
||||
.values({
|
||||
media_entity_id: show,
|
||||
season: 1,
|
||||
release_group: "ABS",
|
||||
kind: "expression",
|
||||
expression: "EP-24",
|
||||
target_season: 2,
|
||||
created_by_type: "user",
|
||||
})
|
||||
.execute();
|
||||
await setWordsText(h.db, show, "S01 @ABS >> S02 EP-24\n");
|
||||
await writeFile(join(h.dl, "[ABS] Split Cour - 26 [1080p].mkv"), "abs".padEnd(64, "x"));
|
||||
const dry = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: key(),
|
||||
|
||||
@@ -259,8 +259,9 @@ describe.skipIf(!pg)("剧集组", () => {
|
||||
});
|
||||
|
||||
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 },
|
||||
const rule = await call(`/media-entities/${entityId}/identify-words`, {
|
||||
method: "PUT",
|
||||
body: { text: "S01 @Lilith-Raws >> S02 EP-12\n", expectedVersion: 0 },
|
||||
});
|
||||
expect(rule.status, JSON.stringify(rule.body)).toBe(200);
|
||||
const lil = (await resolve("[Lilith-Raws] Split Cour - 20 [Baha][WEB-DL][1080p]")).id;
|
||||
@@ -269,7 +270,7 @@ describe.skipIf(!pg)("剧集组", () => {
|
||||
canonical: { season: 2, episodes: { start: 8, end: 8 } },
|
||||
});
|
||||
expect((await release(lil)).content_unit_id).toBe(await unitAt(2, 8));
|
||||
// 越过组内 S2 的 13 集:规则校验不过 → 待确认、规则停用
|
||||
// 越过组内 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" });
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { resolveRelease } from "../../src/modules/sources-identity/service/resol
|
||||
import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts";
|
||||
|
||||
// 集数映射的智能体兜底(docs/plan/episode-resolution.md 批次 5):episode_mapping.inspect 的上下文包与上限、
|
||||
// simulate、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则写规则 + 重算 + 事项关闭)、
|
||||
// simulate(试一行作品识别词)、apply_rule 强制模拟(不通过 / 会让已对上的资源变待确认都拒绝且不写库;通过则把这一行
|
||||
// 连同理由注释追加进作品识别词 + 重算 + 事项关闭)、
|
||||
// 后台调查接入(没有持续授权降级只读并留智能体建议 → 待处理一键确认;有授权时恢复动作按业务事实核实)。
|
||||
// 不调真实模型:按假模型会走的调用顺序直接经注册中心调用能力。
|
||||
|
||||
@@ -50,7 +51,8 @@ const KUSURIYA: TmdbFixture = {
|
||||
} as TmdbFixture;
|
||||
|
||||
const AIR = { 40: "2025-03-14T15:00:00Z", 41: "2025-03-21T15:00:00Z", 42: "2025-03-28T15:00:00Z" } as const;
|
||||
const EP30 = { kind: "expression", expression: "EP-30", targetSeason: 2 } as const;
|
||||
/** 作品识别词:第 1 季连续编号减 30 换到第 2 季 */
|
||||
const ep30 = (group: string, expr = "EP-30") => `S01 @${group} >> S02 ${expr}`;
|
||||
|
||||
describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
let h: Harness;
|
||||
@@ -110,8 +112,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
.where("dedupe_key", "=", `episode_mapping:${kId}:1:${group.toLowerCase()}`)
|
||||
.orderBy("created_at", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
const rulesOf = (group: string) =>
|
||||
h.db.selectFrom("episode_mapping_rule").selectAll().where("release_group", "=", group).execute();
|
||||
/** 作品识别词里提到这个发布组的行(含注释) */
|
||||
const wordsText = async () =>
|
||||
(await h.db.selectFrom("identify_words").select("text").where("media_entity_id", "=", kId).executeTakeFirst())
|
||||
?.text ?? "";
|
||||
const rulesOf = async (group: string) => (await wordsText()).split("\n").filter((l) => l.includes(`@${group}`));
|
||||
const methods = async (ids: string[]) =>
|
||||
(await h.db.selectFrom("source_release").select(["id", "episode_resolution"]).where("id", "in", ids).execute()).map(
|
||||
(r) => (r.episode_resolution as { method: string }).method,
|
||||
@@ -143,7 +148,8 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
const out = await invoke("episode_mapping.inspect", { decisionId: d.id });
|
||||
expect(out.entity).toMatchObject({ id: kId, kind: "series" });
|
||||
expect(out.scope).toEqual({ season: 1, releaseGroup: "Lilith-Raws" });
|
||||
expect(out.decision).toMatchObject({ id: d.id, status: "open", failedRules: [] });
|
||||
expect(out.decision).toMatchObject({ id: d.id, status: "open" });
|
||||
expect(out.decision).not.toHaveProperty("failedRules");
|
||||
expect(out.releases).toHaveLength(3);
|
||||
expect(out.releases[0]).toMatchObject({
|
||||
releaseGroup: "Lilith-Raws",
|
||||
@@ -160,7 +166,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
const s2 = out.seasons.find((s: Any) => s.season === 2);
|
||||
expect(s2.total).toBe(13);
|
||||
expect(s2.episodes[9]).toMatchObject({ episode: 10, airDate: "2025-03-14", title: "第 10 集" });
|
||||
expect(out).toMatchObject({ latestSeason: 2, ended: true, rules: [] });
|
||||
expect(out).toMatchObject({
|
||||
latestSeason: 2,
|
||||
ended: true,
|
||||
words: { text: "", lines: [{ line: 1, kind: "blank" }] },
|
||||
});
|
||||
expect(out.suggestion).toMatchObject({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
@@ -198,15 +208,16 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
|
||||
it("simulate:草稿越界 → 没通过并写明原因;EP-30 → 样本全部解决;纯读,控制台调用不留建议", async () => {
|
||||
const d = await decisionOf("Lilith-Raws");
|
||||
const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: { ...EP30, expression: "EP-20" } });
|
||||
const bad = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Lilith-Raws", "EP-20") });
|
||||
expect(bad.summary).toMatchObject({ samples: 3, resolved: 0, ok: false });
|
||||
expect(bad.failures.join("")).toContain("仍对不上季表");
|
||||
const ok = await invoke("episode_mapping.simulate", {
|
||||
decisionId: d.id,
|
||||
rule: EP30,
|
||||
line: "s01 @Lilith-Raws >> s02 ep - 30",
|
||||
reasons: ["发布时间对上播出日期"],
|
||||
});
|
||||
expect(ok.rule).toMatchObject({ season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 });
|
||||
// 回给规范写法
|
||||
expect(ok.line).toBe("S01 @Lilith-Raws >> S02 EP-30");
|
||||
expect(ok.summary).toMatchObject({ samples: 3, resolved: 3, passed: 3, regressions: 0, missing: 0, ok: true });
|
||||
expect(ok.failures).toEqual([]);
|
||||
const i40 = ok.items.find((i: Any) => i.sourceReleaseId === lilith[0]);
|
||||
@@ -226,7 +237,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
await expect(
|
||||
invoke("episode_mapping.apply_rule", {
|
||||
decisionId: d.id,
|
||||
rule: { ...EP30, expression: "EP-20" },
|
||||
line: ep30("Lilith-Raws", "EP-20"),
|
||||
reasons: ["试试"],
|
||||
}),
|
||||
).rejects.toThrow(/模拟没通过/);
|
||||
@@ -239,11 +250,11 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
await resolve("[Sakurato] Kusuriya no Hitorigoto - 05 [WEB-DL][1080p]", "2023-11-19T15:00:00Z");
|
||||
const ids = await continuous("Sakurato", [40, 41]);
|
||||
const d = await decisionOf("Sakurato");
|
||||
const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 });
|
||||
const sim = await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Sakurato") });
|
||||
expect(sim.summary).toMatchObject({ samples: 2, resolved: 2, regressions: 1, ok: false });
|
||||
expect(sim.failures.join("")).toContain("已对上的资源变成待确认");
|
||||
await expect(
|
||||
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["连续编号"] }),
|
||||
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Sakurato"), reasons: ["连续编号"] }),
|
||||
).rejects.toThrow(/已对上的资源变成待确认/);
|
||||
expect(await rulesOf("Sakurato")).toHaveLength(0);
|
||||
expect(await methods(ids)).toEqual(["unresolved", "unresolved"]);
|
||||
@@ -270,18 +281,26 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
|
||||
const sim = await invoke(
|
||||
"episode_mapping.simulate",
|
||||
{ decisionId: d.id, rule: EP30, reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"] },
|
||||
{
|
||||
decisionId: d.id,
|
||||
line: ep30("Lilith-Raws"),
|
||||
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
|
||||
},
|
||||
run.id,
|
||||
);
|
||||
expect(sim).toMatchObject({ suggestionRecorded: true, summary: { ok: true } });
|
||||
await expect(
|
||||
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["对上播出日期"] }, run.id),
|
||||
invoke(
|
||||
"episode_mapping.apply_rule",
|
||||
{ decisionId: d.id, line: ep30("Lilith-Raws"), reasons: ["对上播出日期"] },
|
||||
run.id,
|
||||
),
|
||||
).rejects.toThrow(/授权/);
|
||||
expect(await rulesOf("Lilith-Raws")).toHaveLength(0);
|
||||
|
||||
const withSug = await decisionOf("Lilith-Raws");
|
||||
expect((withSug.evidence as Any).agentSuggestion).toMatchObject({
|
||||
rule: { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 },
|
||||
line: "S01 @Lilith-Raws >> S02 EP-30",
|
||||
reasons: ["40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期"],
|
||||
simulation: { samples: 3, resolved: 3, ok: true },
|
||||
runId: run.id,
|
||||
@@ -311,14 +330,15 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
status: "resolved",
|
||||
resolution: { optionKey: "confirm_agent_suggestion", execution: { reresolve: { resolved: 4 } } },
|
||||
});
|
||||
const rules = await rulesOf("Lilith-Raws");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0]).toMatchObject({ expression: "EP-30", created_by_type: "user", evidence_level: "manual" });
|
||||
expect(rules[0]!.evidence).toMatchObject({
|
||||
decisionId: d.id,
|
||||
agentSuggestion: { runId: run.id },
|
||||
expect(body.body.data.resolution.execution).toMatchObject({
|
||||
line: "S01 @Lilith-Raws >> S02 EP-30",
|
||||
simulation: { samples: 4, ok: true },
|
||||
});
|
||||
// 写进作品识别词:上面一行注释写明来历与理由
|
||||
expect((await wordsText()).trimEnd().split("\n")).toEqual([
|
||||
expect.stringMatching(/^# \d{4}-\d{2}-\d{2} 智能体建议:40-42 的发布时间分别对上第 2 季第 10-12 集的播出日期$/),
|
||||
"S01 @Lilith-Raws >> S02 EP-30",
|
||||
]);
|
||||
expect(await methods([...lilith, more.id])).toEqual(["rule", "rule", "rule", "rule"]);
|
||||
});
|
||||
|
||||
@@ -332,7 +352,7 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
evidence: JSON.stringify({
|
||||
...(d.evidence as object),
|
||||
agentSuggestion: {
|
||||
rule: { season: 1, releaseGroup: "Kirara", kind: "expression", expression: "EP-20", targetSeason: 2 },
|
||||
line: "S01 @Kirara >> S02 EP-20",
|
||||
reasons: ["旧建议"],
|
||||
simulation: {
|
||||
samples: 2,
|
||||
@@ -379,37 +399,38 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
expect(run.goal).toContain("episode_mapping.apply_rule");
|
||||
|
||||
await invoke("episode_mapping.inspect", { decisionId: d.id }, run.id);
|
||||
expect((await invoke("episode_mapping.simulate", { decisionId: d.id, rule: EP30 }, run.id)).summary.ok).toBe(true);
|
||||
expect(
|
||||
(await invoke("episode_mapping.simulate", { decisionId: d.id, line: ep30("Nekomoe") }, run.id)).summary.ok,
|
||||
).toBe(true);
|
||||
const out = await invoke(
|
||||
"episode_mapping.apply_rule",
|
||||
{ decisionId: d.id, rule: EP30, reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"] },
|
||||
{
|
||||
decisionId: d.id,
|
||||
line: ep30("Nekomoe"),
|
||||
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
|
||||
},
|
||||
run.id,
|
||||
);
|
||||
expect(out).toMatchObject({
|
||||
evidenceLevel: "multi_evidence",
|
||||
summary: { samples: 3, resolved: 3, ok: true },
|
||||
reresolve: { resolved: 3 },
|
||||
decisionStatus: "resolved",
|
||||
});
|
||||
const rules = await rulesOf("Nekomoe");
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0]).toMatchObject({
|
||||
season: 1,
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
target_season: 2,
|
||||
status: "active",
|
||||
created_by_type: "agent",
|
||||
created_by_id: run.id,
|
||||
evidence_level: "multi_evidence",
|
||||
});
|
||||
expect(rules[0]!.evidence).toMatchObject({
|
||||
decisionId: d.id,
|
||||
reasons: ["发布时间对上第 2 季第 10-12 集播出日期", "同组 S02E09 已按原号对上"],
|
||||
samples: 3,
|
||||
simulation: { ok: true },
|
||||
runId: run.id,
|
||||
});
|
||||
// 追加在已有内容后面,注释写明是智能体、哪个会话、理由
|
||||
const lines = (await wordsText()).trimEnd().split("\n");
|
||||
expect(lines.slice(-2)).toEqual([
|
||||
`# ${new Date().toISOString().slice(0, 10)} 智能体(会话 ${run.id.slice(0, 8)}):发布时间对上第 2 季第 10-12 集播出日期;同组 S02E09 已按原号对上`,
|
||||
"S01 @Nekomoe >> S02 EP-30",
|
||||
]);
|
||||
expect(out.appended).toBe(lines.slice(-2).join("\n"));
|
||||
expect(out.lineNumber).toBe(lines.length);
|
||||
const audit = await h.db
|
||||
.selectFrom("audit_event")
|
||||
.select(["actor_type", "actor_id"])
|
||||
.where("action", "=", "identify_words.updated")
|
||||
.orderBy("at", "desc")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(audit).toMatchObject({ actor_type: "agent", actor_id: run.id });
|
||||
expect(await methods(ids)).toEqual(["rule", "rule", "rule"]);
|
||||
|
||||
// 系统核实:事项内资源都已不是待确认
|
||||
@@ -433,18 +454,25 @@ describe.skipIf(!pg)("集数映射的智能体兜底", () => {
|
||||
expect((closed.evidence as Any).autoRecover).toMatchObject({ outcome: "recovered", recoverAttempted: true });
|
||||
});
|
||||
|
||||
it("用户手动停用智能体写的规则后:资源回到待确认,同作用域不再自动写", async () => {
|
||||
const rule = (await rulesOf("Nekomoe"))[0]!;
|
||||
it("用户把智能体写的那一行注释掉后:资源回到待确认,同范围不再自动写", async () => {
|
||||
const cur = await json(await h.req(`/media-entities/${kId}/identify-words`));
|
||||
const text = (cur.body.data.block.text as string).replace(
|
||||
"S01 @Nekomoe >> S02 EP-30",
|
||||
"# S01 @Nekomoe >> S02 EP-30",
|
||||
);
|
||||
const off = await json(
|
||||
await h.req(`/episode-mapping-rules/${rule.id}`, { method: "PATCH", body: { status: "disabled" } }),
|
||||
await h.req(`/media-entities/${kId}/identify-words`, {
|
||||
method: "PUT",
|
||||
body: { text, expectedVersion: cur.body.data.block.version },
|
||||
}),
|
||||
);
|
||||
expect(off.status, JSON.stringify(off.body)).toBe(200);
|
||||
const d = await decisionOf("Nekomoe");
|
||||
expect(d.status).toBe("open");
|
||||
await expect(
|
||||
invoke("episode_mapping.apply_rule", { decisionId: d.id, rule: EP30, reasons: ["再写一次"] }),
|
||||
).rejects.toThrow(/手动停用/);
|
||||
expect((await rulesOf("Nekomoe")).map((r) => r.status)).toEqual(["disabled"]);
|
||||
invoke("episode_mapping.apply_rule", { decisionId: d.id, line: ep30("Nekomoe"), reasons: ["再写一次"] }),
|
||||
).rejects.toThrow(/注释掉/);
|
||||
expect(await rulesOf("Nekomoe")).toEqual(["# S01 @Nekomoe >> S02 EP-30"]);
|
||||
});
|
||||
|
||||
it("恢复核实:写规则没解决事项内资源时不算恢复", async () => {
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture, type TmdbFixtureEpisode } from "@umt/testkit";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { refreshMetadata } from "../../src/modules/sources-identity/service/metadata.ts";
|
||||
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
|
||||
import { createHarness, type Harness, json, pgAvailable, READ_KEY, seedSite } from "./harness.ts";
|
||||
|
||||
// 集数映射接口(docs/plan/episode-resolution.md 批次 4):规则增删改与唯一约束、写入校验、模拟预览(纯读)、
|
||||
// 写入后按作品重算、待处理 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}` },
|
||||
}));
|
||||
|
||||
// 已完结: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 () => {
|
||||
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, KUSURIYA] });
|
||||
h = await createHarness("episode_mapping_api", { 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, publishedAt: string | null = null) => {
|
||||
const r = await 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 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 draft = { season: 1, releaseGroup: "Lilith-Raws", kind: "expression", expression: "EP-30", targetSeason: 2 };
|
||||
|
||||
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");
|
||||
// 发布时间贴着 S02E10 / S02E11 的播出日期(2025-03-14 / 03-21)
|
||||
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 } });
|
||||
expect((ds[0]!.options as { key: string; input: unknown }[]).map((o) => o.key)).toEqual([
|
||||
"confirm_suggestion",
|
||||
"ignore_rule",
|
||||
]);
|
||||
expect((ds[0]!.options as { input: unknown }[])[0]!.input).toEqual({
|
||||
season: 1,
|
||||
releaseGroup: "Lilith-Raws",
|
||||
expression: "EP-30",
|
||||
targetSeason: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("写入校验:表达式解析不了、表格区间重叠、special 没选类型都拒绝;只读 Key 不能写", async () => {
|
||||
const path = `/media-entities/${kId}/episode-mapping-rules`;
|
||||
const r1 = await call(path, { body: { kind: "expression", expression: "EP+abc" } });
|
||||
expect(r1.status).toBe(400);
|
||||
expect(r1.body.error.message).toContain("偏移表达式无法解析");
|
||||
const r2 = await call(path, {
|
||||
body: {
|
||||
kind: "table",
|
||||
tableEntries: [
|
||||
{ from: { start: 26, end: 28 }, to: { season: 2, episode: 1 } },
|
||||
{ from: { start: 28, end: 28 }, to: { season: 2, episode: 2 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(r2.status).toBe(400);
|
||||
expect(r2.body.error.message).toContain("重叠");
|
||||
expect((await call(path, { body: { kind: "special" } })).status).toBe(400);
|
||||
expect((await call(path, { body: { kind: "expression", expression: "-12" }, key: READ_KEY })).status).toBe(403);
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("模拟预览:季表摘要、按发布组统计与建议;草稿规则把待确认变成按规则 S02E10,纯读不落库", async () => {
|
||||
const base = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: {} });
|
||||
expect(base.status).toBe(200);
|
||||
expect(base.body.data.applicable).toBe(true);
|
||||
expect(base.body.data.seasons).toMatchObject({
|
||||
ended: true,
|
||||
latestSeason: 2,
|
||||
seasons: expect.arrayContaining([
|
||||
{ season: 1, total: 12, aired: 12 },
|
||||
{ season: 2, total: 13, aired: 13 },
|
||||
]),
|
||||
});
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
||||
const lilith = base.body.data.groups.find((g: any) => g.releaseGroup === "Lilith-Raws");
|
||||
expect(lilith).toMatchObject({
|
||||
total: 2,
|
||||
pending: 2,
|
||||
methods: [{ method: "unresolved", count: 2 }],
|
||||
suggestion: { season: 1, releaseGroup: "Lilith-Raws", expression: "EP-30", targetSeason: 2 },
|
||||
});
|
||||
expect(base.body.data.changed).toBe(0);
|
||||
|
||||
const r = await call(`/media-entities/${kId}/episode-mapping/preview`, {
|
||||
body: { releaseGroup: "lilith-raws", draft },
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const items = r.body.data.items;
|
||||
expect(items).toHaveLength(2);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言
|
||||
const i40 = items.find((x: any) => x.sourceReleaseId === lilith40);
|
||||
expect(i40).toMatchObject({
|
||||
releaseGroup: "Lilith-Raws",
|
||||
current: { method: "unresolved", source: { season: 1, episodes: { start: 40, end: 40 } } },
|
||||
preview: { method: "rule", ruleId: "draft", canonical: { season: 2, episodes: { start: 10, end: 10 } } },
|
||||
validation: "pass",
|
||||
changed: true,
|
||||
});
|
||||
expect(r.body.data.changed).toBe(2);
|
||||
expect(r.body.data.groups).toEqual([
|
||||
expect.objectContaining({ releaseGroup: "Lilith-Raws", pending: 0, methods: [{ method: "rule", count: 2 }] }),
|
||||
]);
|
||||
|
||||
// 草稿算出越界 → 校验不通过
|
||||
const badDraft = await call(`/media-entities/${kId}/episode-mapping/preview`, {
|
||||
body: { releaseGroup: "Lilith-Raws", draft: { ...draft, expression: "EP-20" } },
|
||||
});
|
||||
expect(badDraft.body.data.items[0]).toMatchObject({ validation: "fail", preview: { ruleFailed: "draft" } });
|
||||
// 季过滤:规范季 2 也算命中(ANi 01 是第 1 季,不在其中)
|
||||
const s2 = await call(`/media-entities/${kId}/episode-mapping/preview`, { body: { season: 2, draft } });
|
||||
expect(s2.body.data.items.map((x: { sourceReleaseId: string }) => x.sourceReleaseId).sort()).toEqual(
|
||||
[lilith40, lilith41].sort(),
|
||||
);
|
||||
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").execute()).toHaveLength(0);
|
||||
expect((await er(lilith40)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
let ruleA: string;
|
||||
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: { reresolve: { resolved: 2 } } },
|
||||
});
|
||||
const rules = await h.db.selectFrom("episode_mapping_rule").selectAll().execute();
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0]).toMatchObject({
|
||||
season: 1,
|
||||
release_group: "Lilith-Raws",
|
||||
kind: "expression",
|
||||
expression: "EP-30",
|
||||
target_season: 2,
|
||||
status: "active",
|
||||
created_by_type: "user",
|
||||
});
|
||||
ruleA = rules[0]!.id;
|
||||
expect(await er(lilith40)).toMatchObject({
|
||||
method: "rule",
|
||||
ruleId: ruleA,
|
||||
canonical: { season: 2, episodes: { start: 10, end: 10 } },
|
||||
});
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
// 资源接口照常带出集数解析结果
|
||||
const rel = await call(`/source-releases/${lilith41}`);
|
||||
expect(rel.body.data.episodeResolution).toMatchObject({
|
||||
method: "rule",
|
||||
canonical: { season: 2, episodes: { start: 11, end: 11 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("规则增改停删:同作用域只能一条启用(发布组忽略大小写),每次写入都重算并返回受影响数量", async () => {
|
||||
const path = `/media-entities/${kId}/episode-mapping-rules`;
|
||||
const dup = await call(path, { body: { ...draft, releaseGroup: "lilith-raws", expression: "EP-29" } });
|
||||
expect(dup.status).toBe(409);
|
||||
expect(dup.body.error.message).toContain("已有一条启用的规则");
|
||||
|
||||
// 修改表达式:两条都变
|
||||
const upd = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { expression: "-31" } });
|
||||
expect(upd.status, JSON.stringify(upd.body)).toBe(200);
|
||||
expect(upd.body.data.rule).toMatchObject({ expression: "EP-31", status: "active" });
|
||||
expect(upd.body.data.reresolve).toMatchObject({ changed: 2, resolved: 0 });
|
||||
expect((await er(lilith40)).canonical).toEqual({ season: 2, episodes: { start: 9, end: 9 } });
|
||||
|
||||
// 停用:资源回到待确认、重新开事项;停用原因记为手动
|
||||
const off = await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "disabled" } });
|
||||
expect(off.body.data.rule.status).toBe("disabled");
|
||||
expect(JSON.parse(off.body.data.rule.disabledReason)).toMatchObject({ manual: true });
|
||||
expect((await er(lilith40)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
|
||||
// 停用后同作用域可以再建一条(表格规则);待处理随资源解决自动关闭
|
||||
const b = await call(path, {
|
||||
body: {
|
||||
season: 1,
|
||||
releaseGroup: "LILITH-RAWS",
|
||||
kind: "table",
|
||||
tableEntries: [
|
||||
{ from: { start: 40, end: 40 }, to: { season: 2, episode: 10 } },
|
||||
{ from: { start: 41, end: 41 }, to: { season: 2, episode: 11 } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(b.status, JSON.stringify(b.body)).toBe(200);
|
||||
expect(b.body.data.reresolve).toMatchObject({ resolved: 2 });
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
const ruleB = b.body.data.rule.id as string;
|
||||
|
||||
// 再启用 A 与 B 冲突
|
||||
expect(
|
||||
(await call(`/episode-mapping-rules/${ruleA}`, { method: "PATCH", body: { status: "active" } })).status,
|
||||
).toBe(409);
|
||||
|
||||
// 列表:含停用的与停用原因
|
||||
const list = await call(path);
|
||||
expect(list.body.data.items.map((x: { id: string; status: string }) => [x.id, x.status])).toEqual(
|
||||
expect.arrayContaining([
|
||||
[ruleA, "disabled"],
|
||||
[ruleB, "active"],
|
||||
]),
|
||||
);
|
||||
|
||||
// 删除 B:资源回到待确认
|
||||
const del = await call(`/episode-mapping-rules/${ruleB}`, { method: "DELETE" });
|
||||
expect(del.status).toBe(200);
|
||||
expect(del.body.data.reresolve).toMatchObject({ changed: 2 });
|
||||
expect(await h.db.selectFrom("episode_mapping_rule").select("id").where("id", "=", ruleB).execute()).toHaveLength(
|
||||
0,
|
||||
);
|
||||
expect((await er(lilith41)).method).toBe("unresolved");
|
||||
expect(await openDecisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("待处理「忽略并建忽略规则」:建 special=ignore 规则,事项记为已忽略,资源不再待确认", async () => {
|
||||
const d = (await openDecisions())[0]!;
|
||||
// A 仍停用,同作用域没有启用的规则
|
||||
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 rule = await h.db
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.selectAll()
|
||||
.where("status", "=", "active")
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(rule).toMatchObject({ kind: "special", special: "ignore", season: 1, release_group: "Lilith-Raws" });
|
||||
expect(await er(lilith40)).toMatchObject({ method: "rule", special: "ignore", canonical: null });
|
||||
expect(await openDecisions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ 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):识别绑定作品后按季表 / 规则换算季集再建单元;
|
||||
@@ -91,6 +92,7 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
|
||||
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
|
||||
@@ -201,71 +203,41 @@ describe.skipIf(!pg)("集数解析接入识别", () => {
|
||||
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();
|
||||
|
||||
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",
|
||||
ruleId: rule.id,
|
||||
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", ruleFailed: rule.id });
|
||||
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 after = await h.db
|
||||
.selectFrom("episode_mapping_rule")
|
||||
.selectAll()
|
||||
.where("id", "=", rule.id)
|
||||
// 识别词原文没被改动
|
||||
const w = await h.db
|
||||
.selectFrom("identify_words")
|
||||
.select(["text", "version"])
|
||||
.where("media_entity_id", "=", kId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(after.status).toBe("disabled");
|
||||
expect(after.disabled_reason).toContain("rule:validate_fail");
|
||||
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], failedRules: [rule.id] });
|
||||
expect(d?.summary).toContain("规则已停用");
|
||||
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 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();
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { compileEntityWords } from "@umt/contracts";
|
||||
import { createMigrator, type DB, migrateToLatest, sql } from "@umt/db";
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { up } from "../../../../packages/db/migrations/0094_identify_words.ts";
|
||||
import { PG_URL, pgAvailable, withSchemaLock } from "../auth-settings/harness.ts";
|
||||
|
||||
// 0094:episode_mapping_rule 结构化规则逐作品转成作品识别词文本行后删表。部署库只有一部作品 9 条规则
|
||||
// (药屋少女的呢喃:S2 任意组 EP+24 → S1;S3 任意组 EP+48 → S1;S3 七个发布组 EP → S1),按它造数据。
|
||||
|
||||
const hasPg = await pgAvailable();
|
||||
|
||||
describe.skipIf(!hasPg)("0094 映射规则迁成作品识别词", () => {
|
||||
const schema = `iw_mig_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
const admin = async (q: string) => {
|
||||
const c = new pg.Client({ connectionString: PG_URL });
|
||||
await c.connect();
|
||||
await c.query(q).finally(() => c.end());
|
||||
};
|
||||
let db: Kysely<DB>;
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
await withSchemaLock(() => admin(`drop schema if exists ${schema} cascade`));
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移前的旧表不在类型里
|
||||
const raw = () => db as unknown as Kysely<any>;
|
||||
const rule = (entity: string, v: Record<string, unknown>) =>
|
||||
raw()
|
||||
.insertInto("episode_mapping_rule")
|
||||
.values({ media_entity_id: entity, kind: "expression", created_by_type: "user", ...v })
|
||||
.execute();
|
||||
const groups = ["ADWeb", "MWeb", "CHDWEB", "黒ネズミたち", "Judas", "绿茶字幕组", "綠茶字幕組"];
|
||||
|
||||
it("药屋 9 条规则合成 3 行;停用的变注释;单集对照一行一条;智能体的带注释;重跑只补缺的行", async () => {
|
||||
await admin(`create schema ${schema}`);
|
||||
db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 2, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
});
|
||||
await withSchemaLock(async () => {
|
||||
const r = await createMigrator(db, { migrationTableSchema: schema }).migrateTo("0093_pi_azure_provider");
|
||||
if (r.error) throw r.error;
|
||||
});
|
||||
const entity = async (title: string) =>
|
||||
(
|
||||
await sql<{
|
||||
id: string;
|
||||
}>`insert into media_entity (kind, title) values ('series', ${title}) returning id`.execute(db)
|
||||
).rows[0]!.id;
|
||||
const kusuriya = await entity("药屋少女的呢喃");
|
||||
const other = await entity("别的剧");
|
||||
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
|
||||
await rule(kusuriya, { season: 3, expression: "EP+48", target_season: 1 });
|
||||
for (const g of groups) await rule(kusuriya, { season: 3, release_group: g, expression: "EP", target_season: 1 });
|
||||
// 别的剧:停用(自动停用留下的)、单集对照、特别篇、智能体写的
|
||||
await rule(other, {
|
||||
season: 1,
|
||||
release_group: "ANi",
|
||||
expression: "EP-12",
|
||||
target_season: 2,
|
||||
status: "disabled",
|
||||
disabled_reason: JSON.stringify({ source: { season: 1 } }),
|
||||
});
|
||||
await rule(other, {
|
||||
season: 1,
|
||||
release_group: "Baha",
|
||||
kind: "table",
|
||||
expression: null,
|
||||
table_entries: JSON.stringify([
|
||||
{ from: { start: 13, end: 13 }, to: { season: 0, episode: 1 } },
|
||||
{ from: { start: 27, end: 28 }, to: { season: 2, episode: 2 } },
|
||||
]),
|
||||
});
|
||||
await rule(other, { season: null, release_group: "SweetSub", kind: "special", expression: null, special: "extra" });
|
||||
await rule(other, {
|
||||
season: 2,
|
||||
release_group: "Nekomoe",
|
||||
expression: "EP-30",
|
||||
created_by_type: "agent",
|
||||
evidence: JSON.stringify({ reasons: ["发布时间对上播出日期"] }),
|
||||
});
|
||||
|
||||
await withSchemaLock(() => migrateToLatest(db, { migrationTableSchema: schema }));
|
||||
|
||||
const text = async (id: string) =>
|
||||
(await db.selectFrom("identify_words").select("text").where("media_entity_id", "=", id).executeTakeFirstOrThrow())
|
||||
.text;
|
||||
expect(await text(kusuriya)).toBe(
|
||||
["S02 >> S01 EP+24", "S03 >> S01 EP+48", `S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`, ""].join("\n"),
|
||||
);
|
||||
expect(await text(other)).toBe(
|
||||
[
|
||||
"S01E13 @Baha >> S00E01",
|
||||
"S01E27-E28 @Baha >> S02E02",
|
||||
"# S01 @ANi >> S02 EP-12",
|
||||
"# 智能体:发布时间对上播出日期",
|
||||
"S02 @Nekomoe >> EP-30",
|
||||
"* @SweetSub >> 花絮",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
// 迁出来的文本能原样编译,规则与原来等价(药屋 2 + 7 条)
|
||||
const c = compileEntityWords(await text(kusuriya));
|
||||
expect(c.errors).toEqual([]);
|
||||
expect(c.rules.map((r) => [r.season, r.releaseGroup, r.expression, r.targetSeason])).toEqual([
|
||||
[2, null, "EP+24", 1],
|
||||
[3, null, "EP+48", 1],
|
||||
...groups.map((g) => [3, g, "EP", 1]),
|
||||
]);
|
||||
expect(compileEntityWords(await text(other)).errors).toEqual([]);
|
||||
const tables = await sql<{
|
||||
t: string | null;
|
||||
}>`select to_regclass(${`${schema}.episode_mapping_rule`})::text as t`.execute(db);
|
||||
expect(tables.rows[0]!.t).toBeNull();
|
||||
const version = await db
|
||||
.selectFrom("identify_words")
|
||||
.select(["version", "updated_by_type"])
|
||||
.where("media_entity_id", "=", kusuriya)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(version).toEqual({ version: 1, updated_by_type: "system" });
|
||||
|
||||
// 重跑(表还在的情况):已有的行不重复,只追加新的
|
||||
await sql
|
||||
.raw(`create table episode_mapping_rule (
|
||||
id uuid primary key default gen_random_uuid(), media_entity_id uuid not null, season integer, release_group text,
|
||||
kind text not null, expression text, target_season integer, table_entries jsonb, special text,
|
||||
status text not null default 'active', evidence jsonb not null default '{}'::jsonb, created_by_type text not null,
|
||||
created_at timestamptz not null default now())`)
|
||||
.execute(db);
|
||||
await rule(kusuriya, { season: 2, expression: "EP+24", target_season: 1 });
|
||||
await rule(kusuriya, { season: 4, expression: "EP+72", target_season: 1 });
|
||||
await up(raw());
|
||||
expect(await text(kusuriya)).toBe(
|
||||
[
|
||||
"S02 >> S01 EP+24",
|
||||
"S03 >> S01 EP+48",
|
||||
`S03 ${groups.map((g) => `@${g}`).join(" ")} >> S01 EP`,
|
||||
"S04 >> S01 EP+72",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
|
||||
api.setFailMode(null);
|
||||
});
|
||||
|
||||
it("模块注册 8 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
it("模块注册 9 个任务与 3 条 outbox 路由;任务处理器经 step.run 调服务", async () => {
|
||||
const reg = new TaskRegistry();
|
||||
h.module.registerTasks(reg, h.ctx);
|
||||
expect(reg.names()).toEqual([
|
||||
@@ -78,6 +78,7 @@ describe.skipIf(!up)("M-Team 快照与任务注册", () => {
|
||||
"prewarmMetadata",
|
||||
"refreshMetadata",
|
||||
"refreshSiteAccount",
|
||||
"reresolveIdentifyWords",
|
||||
"resolveObservation",
|
||||
"searchSubscription",
|
||||
"snapshotSite",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DB, Kysely } from "@umt/db";
|
||||
import { invalidateGlobalWords } from "../../src/modules/sources-identity/service/identify-words-cache.ts";
|
||||
|
||||
// 测试里直接写识别词原文(绕过保存接口的版本检查与重算);全局识别词写完让进程内缓存失效。
|
||||
|
||||
export async function setWordsText(db: Kysely<DB>, entityId: string | null, text: string): Promise<void> {
|
||||
const cur = await (entityId
|
||||
? db.selectFrom("identify_words").select("id").where("media_entity_id", "=", entityId)
|
||||
: db.selectFrom("identify_words").select("id").where("media_entity_id", "is", null)
|
||||
).executeTakeFirst();
|
||||
if (cur)
|
||||
await db
|
||||
.updateTable("identify_words")
|
||||
.set((eb) => ({ text, version: eb("version", "+", 1) }))
|
||||
.where("id", "=", cur.id)
|
||||
.execute();
|
||||
else await db.insertInto("identify_words").values({ media_entity_id: entityId, text, version: 1 }).execute();
|
||||
if (!entityId) invalidateGlobalWords();
|
||||
}
|
||||
Reference in new issue
Block a user