- 新表 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
176 lines
7.8 KiB
TypeScript
176 lines
7.8 KiB
TypeScript
import { calculateEpisodeOffset } from "@umt/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyGlobalWords,
|
|
compileGlobalWords,
|
|
parseRelease,
|
|
parseReleasePath,
|
|
smartNumber,
|
|
toChineseNumber,
|
|
} from "../src/index.ts";
|
|
|
|
// 全局识别词套用(移植 MP WordsMatcher)。MP 用例出处:tests/test_metainfo.py、tests/test_rust_accel.py。
|
|
|
|
const words = (...lines: string[]) => compileGlobalWords(lines.join("\n"));
|
|
const parse = (title: string, w: string[], subtitle?: string) =>
|
|
parseRelease(title, { words: words(...w), ...(subtitle ? { subtitle } : {}) });
|
|
|
|
describe("MP 识别词用例", () => {
|
|
it("复杂识别词先替换后偏移(test_custom_words_replace_then_episode_offset)", () => {
|
|
const w = "旧名 => 新名 && 第 <> 集 >> EP+1";
|
|
const r = parse("旧名 第03集", [w]);
|
|
expect(r.title).toBe("新名");
|
|
expect(r.episodes).toEqual({ start: 4, end: 4 });
|
|
expect(r.words?.applied).toEqual([w]);
|
|
expect(r.words?.original).toBe("旧名 第03集");
|
|
});
|
|
|
|
it("标题没有集数时只偏移副标题里的集数,季数不动(test_custom_words_episode_offset_applies_to_subtitle)", () => {
|
|
const w = "BLEACH Thousand-Year Blood War S04 => BLEACH 2004 S02 && S02 <> 1080p >> EP+40";
|
|
const sub = "死神 千年血战篇 -祸进谭- 第四季 第8集 ...";
|
|
const out = applyGlobalWords(
|
|
"BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB",
|
|
sub,
|
|
words(w),
|
|
);
|
|
expect(out.subtitle).toBe("死神 千年血战篇 -祸进谭- 第四季 第48集 ...");
|
|
expect(out.applied).toEqual([w]);
|
|
const r = parse("BLEACH Thousand-Year Blood War S04 1080p Disney+ WEB-DL AAC 2.0 H.264-CHDWEB", [w], sub);
|
|
expect(r.season).toBe(2);
|
|
expect(r.episodes).toEqual({ start: 48, end: 48 });
|
|
});
|
|
|
|
it("替换里带 {[tmdbid]} 并跨季偏移(test_get_torrent_episodes_applies_custom_words)", () => {
|
|
const w = "A.Will.Eternal.S04 => 一念永恒{[tmdbid=107371;type=tv]}S01 && S01 <> 2160p >> EP+165";
|
|
const r = parse("A.Will.Eternal.S04E05.2026.2160p.WEB-DL.mkv", [w]);
|
|
expect(r.episodes).toEqual({ start: 170, end: 170 });
|
|
expect(r.season).toBe(1);
|
|
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "107371" });
|
|
});
|
|
|
|
it("偏移表达式支持乘法与分组引用 \\1(test_custom_words_episode_offset_supports_multiplication_expression)", () => {
|
|
const w =
|
|
"Ha.Ha.Ha.Ha.Ha.2026.S06E([0-1][0-9]).Part1 => 哈哈哈哈哈 (2020){[tmdbid=112732;type=tv]} S06E\\1.Part1 && S06 <> .Part1 >> 2*EP-1";
|
|
const r = parse("Ha.Ha.Ha.Ha.Ha.2026.S06E03.Part1", [w]);
|
|
expect(r.title).toBe("哈哈哈哈哈");
|
|
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "112732" });
|
|
expect(r.season).toBe(6);
|
|
expect(r.episodes).toEqual({ start: 5, end: 5 });
|
|
expect(r.words?.applied).toEqual([w]);
|
|
});
|
|
|
|
it("EP 可以出现多次(test_custom_words_episode_offset_supports_repeated_ep_expression)", () => {
|
|
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> EP+EP-1"]);
|
|
expect(r.title).toBe("新名");
|
|
expect(r.episodes).toEqual({ start: 5, end: 5 });
|
|
});
|
|
|
|
it("2EP 不当成乘法:替换照做、偏移不做、这一行不算命中(test_custom_words_episode_offset_rejects_implicit_ep_expression)", () => {
|
|
const r = parse("旧名 第03集", ["旧名 => 新名 && 第 <> 集 >> 2EP"]);
|
|
expect(r.title).toBe("新名");
|
|
expect(r.episodes).toEqual({ start: 3, end: 3 });
|
|
expect(r.words).toBeUndefined();
|
|
});
|
|
|
|
it("{[…;g=…;s=1]} 指定作品与季(test_custom_words_support_episode_group_parameter)", () => {
|
|
const w = "Bakemonogatari => 物语系列 {[tmdbid=46195;type=tv;g=5ad0ec240e0a26303f00d84d;s=1]}";
|
|
const r = parse("Bakemonogatari 01", [w]);
|
|
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "46195" });
|
|
expect(r.season).toBe(1);
|
|
expect(r.mediaTypeHint).toBe("tv");
|
|
expect(r.words?.applied).toEqual([w]);
|
|
});
|
|
|
|
it("s=0 指定为特别季(test_custom_words_support_special_season_zero_parameter)", () => {
|
|
const r = parse("Test Show 01", ["Test Show => 测试剧 {[tmdbid=12345;type=tv;s=0]}"]);
|
|
expect(r.explicitIds).toContainEqual({ provider: "tmdb", id: "12345" });
|
|
expect(r.season).toBe(0);
|
|
});
|
|
|
|
it("替换为空串删掉词(test_metainfo_preserves_original_name_when_custom_words_applied)", () => {
|
|
const r = parse("电影测试替换名称 (2024)", ["测试替换 => "]);
|
|
expect(r.title).toBe("电影名称");
|
|
expect(r.year).toBe(2024);
|
|
});
|
|
|
|
it("记录命中的识别词(test_custom_words_apply_words_recording)", () => {
|
|
const r = parse("电影替换词.2024.mkv", ["替换词 => 新词"]);
|
|
expect(r.words?.applied).toEqual(["替换词 => 新词"]);
|
|
});
|
|
|
|
it("路径识别同样套用识别词(test_metainfopath_with_custom_words)", () => {
|
|
const r = parseReleasePath("/movies/电影测试替换名称 (2024)/电影测试替换名称.mkv", {
|
|
words: words("测试替换 => "),
|
|
});
|
|
expect(r.title).toBe("电影名称");
|
|
expect(r.year).toBe(2024);
|
|
expect(r.words?.applied).toEqual(["测试替换 => "]);
|
|
});
|
|
});
|
|
|
|
describe("识别词行为细节", () => {
|
|
it("屏蔽词:整行当正则删掉", () => {
|
|
expect(applyGlobalWords("Show.S01E01.国语中字.1080p", null, words("国语中字")).title).toBe("Show.S01E01..1080p");
|
|
});
|
|
|
|
it("注释、空行跳过;正则写错的行跳过,不影响后面的行", () => {
|
|
const out = applyGlobalWords("A B C", null, words("# A => X", "", "([ => Y", "B => Z"));
|
|
expect(out.title).toBe("A Z C");
|
|
expect(out.applied).toEqual(["B => Z"]);
|
|
});
|
|
|
|
it("按行序处理:前一行的结果交给后一行", () => {
|
|
expect(applyGlobalWords("abc", null, words("a => b", "bb => X")).title).toBe("Xc");
|
|
});
|
|
|
|
it("Python 写法:命名分组 (?P<n>…)、\\g<n>、(?i)", () => {
|
|
const out = applyGlobalWords("Foo.S01E02", null, words("(?i)foo\\.S(?P<s>\\d+) => Bar.S\\g<s>"));
|
|
expect(out.title).toBe("Bar.S01E02");
|
|
});
|
|
|
|
it("只偏移前后定位词之间的数字;前导零保留宽度;向前偏移", () => {
|
|
expect(applyGlobalWords("[Grp] Show - 25 [1080p]", null, words("Show - <> \\[1080p >> EP-12")).title).toBe(
|
|
"[Grp] Show - 13 [1080p]",
|
|
);
|
|
expect(applyGlobalWords("Show E05 end", null, words("Show E <> end >> EP+10")).title).toBe("Show E15 end");
|
|
});
|
|
|
|
it("中文集数偏移后仍写中文", () => {
|
|
expect(applyGlobalWords("第十二集", null, words("第 <> 集 >> EP+1")).title).toBe("第十三集");
|
|
});
|
|
|
|
it("定位词不在标题里就不动", () => {
|
|
const out = applyGlobalWords("Other 05", null, words("Show <> end >> EP+1"));
|
|
expect(out).toEqual({ title: "Other 05", subtitle: null, applied: [] });
|
|
});
|
|
|
|
it("中文数字互转", () => {
|
|
expect(smartNumber("十二")).toBe(12);
|
|
expect(smartNumber("一二")).toBe(12);
|
|
expect(smartNumber("08")).toBe(8);
|
|
expect(toChineseNumber(13)).toBe("十三");
|
|
expect(toChineseNumber(105)).toBe("一百零五");
|
|
expect(toChineseNumber(-3)).toBe("负三");
|
|
});
|
|
});
|
|
|
|
describe("集号算术(MP calculate_episode_offset)", () => {
|
|
it("白名单运算", () => {
|
|
expect(calculateEpisodeOffset("EP+1", 3)).toBe(4);
|
|
expect(calculateEpisodeOffset("2*EP-1", 3)).toBe(5);
|
|
expect(calculateEpisodeOffset("EP+EP-1", 3)).toBe(5);
|
|
expect(calculateEpisodeOffset("(EP+1)//2", 5)).toBe(3);
|
|
expect(calculateEpisodeOffset("EP/2", 5)).toBe(2);
|
|
expect(calculateEpisodeOffset("-EP+20", 5)).toBe(15);
|
|
expect(calculateEpisodeOffset("EP%4", 10)).toBe(2);
|
|
expect(calculateEpisodeOffset("-7%3", 0)).toBe(2);
|
|
expect(calculateEpisodeOffset("5", 3)).toBe(5);
|
|
});
|
|
|
|
it("拒绝省略运算符、乘方、名字与非法字符", () => {
|
|
for (const bad of ["2EP", "EP(1)", "EP**2", "EPX+1", "ep+1", "EP+1;", "EP/0", "EP+"]) {
|
|
expect(() => calculateEpisodeOffset(bad, 1), bad).toThrow();
|
|
}
|
|
});
|
|
});
|