feat(domain): naming template engine (Jinja safe subset, MP variables, path layout)

- naming/engine: lexer/parser/AST/render for {{ }} / if-elif-else / whitelisted filters
  (default pad pad2 upper lower trim replace truncate date join); rejects for/set/attribute/
  subscript/call/~/% with line:col errors and 'did you mean' hints
- naming/variables: variable table aligned with MoviePilot names plus ours (cut, airDate,
  issue, slotName, libraryRoot ...); buildNamingVars from parsed release + entity + units
- naming/layout: split by literal '/', segment roles (category/root/season/sub/file),
  value escaping, reserved names, 255-byte segments, validateNamingTemplate
- naming/defaults: MP default movie/tv + our variety template, v1 syntax upgrade,
  entity > root > global override resolution, builtin preview samples
- path/binding: resolveTargetDirectory keeps bound dirs, only unbound segments render;
  renderMediaFileName = last segment of the full template; DEFAULT_TEMPLATES = MP v2
- differential: 34 cases vs MP (21 identical, 13 documented deviations)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 15:24:32 +08:00
1 parent e8745115f4
commit f7c75ad781
22 files changed
+2674 -281

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
import { describe, expect, it } from "vitest";
import {
buildNamingContext,
buildNamingVars,
compileNaming,
DEFAULT_TEMPLATES,
LEGACY_V1_NAMING,
MP_DEFAULT_NAMING,
NAMING_SAMPLES,
NAMING_VARIABLE_NAMES,
NAMING_VARIABLES,
NamingTemplateError,
namingKindOf,
parseRelease,
renderMediaFileName,
renderNamingPath,
renderNamingString,
resolveNamingTemplates,
resolveTargetDirectory,
tryCompileNaming,
upgradeLegacyTemplate,
validateNamingTemplate,
} from "../src/index.ts";
const known = { knownVars: NAMING_VARIABLE_NAMES };
const r = (tpl: string, vars: Record<string, string | number> = {}) => renderNamingString(tpl, vars);
const issue = (tpl: string) => {
const c = tryCompileNaming(tpl, known);
return c.ok ? undefined : c.issues[0];
};
describe("语法", () => {
it("输出、or、默认值、注释、去空白", () => {
expect(r("{{ title }}", { title: "A" })).toBe("A");
expect(r("{{ en_title or title }}", { title: "A" })).toBe("A");
expect(r("{{ en_title or title or 'x' }}", {})).toBe("x");
expect(r("{{ year | default('未知') }}", {})).toBe("未知");
expect(r("a{# 注释 #}b")).toBe("ab");
expect(r("a {%- if 1 -%} b {%- endif %}")).toBe("ab");
expect(r("{{ missing }}")).toBe("");
});
it("if / elif / else 嵌套与比较", () => {
const t = "{% if n > 10 %}big{% elif n == 1 %}one{% elif not n %}none{% else %}mid{% endif %}";
expect(r(t, { n: 11 })).toBe("big");
expect(r(t, { n: "1" })).toBe("one");
expect(r(t, {})).toBe("none");
expect(r(t, { n: 5 })).toBe("mid");
expect(r("{% if a and (b or c) %}y{% endif %}", { a: 1, c: "x" })).toBe("y");
expect(r("{% if a != 'x' %}y{% endif %}", { a: "z" })).toBe("y");
expect(r("{% if a >= 2 and a <= 3 %}y{% endif %}", { a: "3" })).toBe("y");
expect(r("{% if a > 1 %}y{% endif %}", { a: "abc" })).toBe("");
expect(r("{% if total_episodes %}y{% else %}n{% endif %}", { total_episodes: 0 })).toBe("n");
});
it("过滤器", () => {
expect(r("{{ season | pad(2) }}", { season: "3" })).toBe("03");
expect(r("{{ episode | pad(3) }}", { episode: "1-3" })).toBe("001-003");
expect(r("{{ season | pad2 }}", { season: 7 })).toBe("07");
expect(r("{{ t | upper }}|{{ t | lower }}|{{ ' a ' | trim }}", { t: "Ab" })).toBe("AB|ab|a");
expect(r("{{ t | replace(' ', '.') }}", { t: "a b c" })).toBe("a.b.c");
expect(r("{{ t | truncate(3) }}", { t: "绝命毒师" })).toBe("绝命毒");
expect(r("{{ d | date('YYYY-MM-DD') }}/{{ d | date('YYYY.MM.DD') }}", { d: "20260927" })).toBe(
"2026-09-27/2026.09.27",
);
expect(r("{{ d | date('YYYYMMDD') }}", { d: "2026-09-27" })).toBe("20260927");
expect(renderNamingString("{{ l | join(',') }}", { l: ["a", "b"] })).toBe("a,b");
expect(r("{{ x | default('-') | upper }}", {})).toBe("-");
});
it("编译错误带行列号与原因", () => {
expect(issue("{% for x in y %}{% endfor %}")).toMatchObject({ code: "unsupported", line: 1, col: 1 });
expect(issue("a\n{{ title.__class__ }}")).toMatchObject({ code: "attribute_access", line: 2, col: 9 });
expect(issue("{{ title[0] }}")?.code).toBe("subscript");
expect(issue("{{ lipsum() }}")?.code).toBe("call");
expect(issue("{{ title ~ year }}")?.code).toBe("concat");
expect(issue("{{ '%02d' % season }}")?.message).toContain("pad(2)");
expect(issue("{{ title if year else 'x' }}")?.code).toBe("inline_if");
expect(issue("{% set a = 1 %}")?.code).toBe("unsupported");
expect(issue("{{ title")?.code).toBe("unclosed_tag");
expect(issue("{% if title %}x")?.code).toBe("unclosed_if");
expect(issue("{% endif %}")?.code).toBe("stray_endif");
expect(issue("{% else %}")?.code).toBe("stray_else");
expect(issue("{% if title %}{% else %}{% elif year %}{% endif %}")?.code).toBe("syntax");
expect(issue("{{ }}")?.code).toBe("empty_output");
expect(issue("x }}")?.code).toBe("stray_close");
expect(issue("{{ title | shout }}")?.code).toBe("unknown_filter");
expect(issue("{{ title | uper }}")?.message).toContain("是否指 upper");
expect(issue("{{ season | pad('x') }}")?.code).toBe("filter_arg");
expect(issue("{{ airDate | date('MM/DD') }}")?.code).toBe("filter_arg");
expect(issue("{{ title | replace('a') }}")?.code).toBe("filter_arity");
expect(issue("{{ videoformat }}")?.message).toBe("未知变量 videoformat,是否指 videoFormat");
expect(issue("{{ constructor }}")?.code).toBe("unknown_variable");
expect(issue("{{ title | constructor }}")?.code).toBe("unknown_filter");
expect(tryCompileNaming("{% constructor %}").ok).toBe(false);
expect(renderNamingString("{{ constructor }}{{ __proto__ }}", {})).toBe("");
expect(issue("{{ 1.5 }}")?.code).toBe("unsupported_number");
expect(() => compileNaming("{{ a.b }}")).toThrow(NamingTemplateError);
});
it("限制:长度、嵌套深度、节点数、输出字节", () => {
expect(issue("x".repeat(1025))?.code).toBe("too_long");
const deep = `${"{% if title %}".repeat(5)}x${"{% endif %}".repeat(5)}`;
expect(issue(deep)?.code).toBe("too_deep");
expect(tryCompileNaming("{{a}}".repeat(200)).ok).toBe(true);
expect(tryCompileNaming("{{a}}".repeat(205)).ok).toBe(false);
expect(() => renderNamingString("{{a}}{{a}}", { a: "x".repeat(3000) })).toThrow(/4096/);
});
it("收集引用变量", () => {
expect(compileNaming(MP_DEFAULT_NAMING.movie).vars).toEqual(["title", "year", "part", "videoFormat", "fileExt"]);
});
});
describe("变量表与上下文", () => {
it("变量名唯一,覆盖 MP 命名变量", () => {
expect(new Set(NAMING_VARIABLE_NAMES).size).toBe(NAMING_VARIABLES.length);
const mp =
"title en_title original_title name year title_year season season_fmt season_year episode season_episode total_episodes episode_title episode_date part videoFormat resourceType effect edition resource_term releaseGroup videoCodec videoBit audioCodec webSource fps customization fileExt type category tmdbid imdbid doubanid".split(
" ",
);
for (const v of mp) expect(NAMING_VARIABLE_NAMES, v).toContain(v);
expect(NAMING_VARIABLES.filter((v) => v.ours).map((v) => v.name)).toEqual([
"cut",
"contentType",
"segment",
"airDate",
"issue",
"slotName",
"libraryRoot",
]);
});
it("季集元数据、版本目标、库根、外部 ID", () => {
const v = buildNamingVars(
{
title: "绝命毒师",
year: 2008,
tmdbId: 1396,
imdbId: "tt0903747",
slotName: "4K HDR",
libraryRoot: "电视剧-A",
category: "欧美剧",
seasons: { 1: { year: 2008, episodeTotal: 7 } },
episodes: { "1:1": { title: "Pilot", airDate: "2008-01-20" } },
},
parseRelease("Breaking.Bad.S01E01.2160p.UHD.BluRay.REMUX.DV.HDR10.HEVC.TrueHD.7.1-FGT.mkv"),
{ fileExt: "MKV" },
);
expect(v).toMatchObject({
season: "1",
season_fmt: "S01",
season_year: "2008",
total_episodes: 7,
episode_title: "Pilot",
episode_date: "2008-01-20",
resourceType: "UHD BluRay REMUX",
tmdbid: "1396",
imdbid: "tt0903747",
slotName: "4K HDR",
libraryRoot: "电视剧-A",
category: "欧美剧",
fileExt: ".mkv",
type: "电视剧",
});
const daily = buildNamingVars({ title: "奔跑吧", kind: "variety" }, parseRelease("奔跑吧 20240426 第1期"));
expect(daily).toMatchObject({ type: "综艺", season: "1", airDate: "20240426", issue: "1" });
expect(daily.episode).toBeUndefined();
expect(buildNamingVars({ title: "T" }, parseRelease("T.S01-S02.1080p")).season_fmt).toBe("S01-S02");
});
it("namingKindOf", () => {
expect(["movie", "series", "variety", "collection"].map(namingKindOf)).toEqual(["movie", "tv", "variety", "movie"]);
});
});
describe("路径:段、角色、清理", () => {
it("段角色:分类前缀 / 作品根 / 季 / 子目录 / 文件", () => {
const res = renderNamingPath(
"{{type}}/{{category}}/{{title_year}}/Season {{season}}/{{edition}}/{{title}} - {{season_episode}}{{fileExt}}",
{
type: "电视剧",
category: "",
title: "X",
title_year: "X (2008)",
season: "1",
season_episode: "S01E01",
edition: "WEB-DL",
fileExt: ".mkv",
},
);
expect(res.ok).toBe(true);
expect(res.relativePath).toBe("电视剧/X (2008)/Season 1/WEB-DL/X - S01E01.mkv");
expect(res.segments.map((s) => s.role)).toEqual(["category", "root", "season", "sub", "file"]);
});
it("模板里的 \\ 视为目录分隔,多余的 / 合并", () => {
expect(renderNamingPath("/{{title}}\\\\{{title}}{{fileExt}}/", { title: "A", fileExt: ".mkv" }).relativePath).toBe(
"A/A.mkv",
);
});
it("值里的非法字符与 / 转全角,控制字符删除,合并空白", () => {
const res = renderNamingPath("{{title}}/{{title}} - {{releaseGroup}}{{fileExt}}", {
title: 'A/B:C*D?"E<F>G|H\\I\u0001',
releaseGroup: "x/y",
fileExt: ".mkv",
});
expect(res.relativePath).toBe("A/B:C*D?"E<F>G|H\I/A/B:C*D?"E<F>G|H\I - x/y.mkv");
});
it("保留名、首尾点、长度截断", () => {
expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "nul", fileExt: ".mkv" }).relativePath).toBe(
"nul_/nul_.mkv",
);
expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "..a..", fileExt: ".mkv" }).relativePath).toBe(
"a/a.mkv",
);
const long = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "é".repeat(300), fileExt: ".mkv" });
const [dir, file] = long.relativePath.split("/");
expect(new TextEncoder().encode(dir).length).toBeLessThanOrEqual(255);
expect(new TextEncoder().encode(file).length).toBeLessThanOrEqual(255);
expect(file!.endsWith(".mkv")).toBe(true);
const tooLong = renderNamingPath(`${"{{title}}/".repeat(20)}{{title}}{{fileExt}}`, {
title: "长".repeat(85),
fileExt: ".mkv",
});
expect(tooLong.ok).toBe(false);
expect(tooLong.errors[0]?.code).toMatch(/_too_long$/);
});
it("空段:分类/子目录丢弃,作品根/文件名为空报错,识别不出作品根报错", () => {
const empty = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { fileExt: ".mkv" });
expect(empty.ok).toBe(false);
expect(empty.errors[0]?.code).toBe("empty_segment");
expect(empty.missing).toEqual(["title"]);
expect(renderNamingPath("{{title}}{{fileExt}}", { title: "A", fileExt: ".mkv" }).errors[0]?.code).toBe(
"no_work_root",
);
expect(
renderNamingPath("{{type}}/{{title}}{{fileExt}}", { type: "电影", title: "A", fileExt: ".mkv" }).errors[0]?.code,
).toBe("no_work_root");
});
});
describe("校验(保存前)", () => {
it("默认模板全部通过且角色正确", () => {
for (const k of ["movie", "tv", "variety"] as const) {
const v = validateNamingTemplate(MP_DEFAULT_NAMING[k], k);
expect(v, k).toMatchObject({ ok: true, errors: [] });
expect(v.roles.map((x) => x.role)).toEqual(k === "movie" ? ["root", "file"] : ["root", "season", "file"]);
}
for (const k of ["movie", "tv", "variety"] as const)
expect(validateNamingTemplate(LEGACY_V1_NAMING[k], k).ok, k).toBe(true);
});
it("缺 fileExt、未知变量、作品根;可能为空的提示", () => {
expect(validateNamingTemplate("{{title}}/{{title}}", "movie").errors[0]).toMatchObject({
code: "missing_file_ext",
message: "文件名缺少 {{fileExt}}:模板必须以 {{fileExt}} 结尾",
});
expect(validateNamingTemplate("{{title}}/{{titel}}{{fileExt}}", "movie").errors[0]?.message).toContain(
"是否指 title",
);
expect(validateNamingTemplate("{{title}} ({{year}})/{{title}}{{fileExt}}", "movie").warnings).toEqual([
"year 可能为空,建议写成 {% if year %}…{{year}}…{% endif %}",
]);
expect(validateNamingTemplate(MP_DEFAULT_NAMING.tv, "tv").warnings).toEqual([]);
});
});
describe("v1 旧语法迁移", () => {
it("{var}、{var:02}、{airDate:dash}、[[ ]] → 新语法", () => {
expect(upgradeLegacyTemplate("{title}[[ ({year})]]")).toBe("{{title}}{% if year %} ({{year}}){% endif %}");
expect(upgradeLegacyTemplate("S{season:02}")).toBe("S{{season | pad(2)}}");
expect(upgradeLegacyTemplate("{airDate:dash}[[ - 第{issue}期]]")).toBe(
"{{airDate | date('YYYY-MM-DD')}}{% if issue %} - 第{{issue}}期{% endif %}",
);
expect(upgradeLegacyTemplate("{resolution} {seasonEpisode}")).toBe("{{videoFormat}} {{season_episode}}");
});
it("v1 模板迁移后输出与旧实现一致", () => {
const movie = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay"));
expect(renderMediaFileName("movie", LEGACY_V1_NAMING, movie, "mkv")).toEqual({
ok: true,
fileName: "指环王 (2001) - Extended - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(renderMediaFileName("tv", LEGACY_V1_NAMING, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03.mkv",
});
const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p"));
expect(renderMediaFileName("tv", LEGACY_V1_NAMING, daily, "mp4")).toMatchObject({
fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4",
});
});
});
describe("覆盖顺序:作品 > 库根 > 全局(按类型回落)", () => {
it("resolveNamingTemplates", () => {
const res = resolveNamingTemplates({
global: MP_DEFAULT_NAMING,
root: { tv: "R-tv", movie: "" },
entity: { tv: "E-tv", variety: "E-var" },
});
expect(res.templates).toEqual({ movie: MP_DEFAULT_NAMING.movie, tv: "E-tv", variety: "E-var" });
expect(res.sources).toEqual({ movie: "global", tv: "entity", variety: "entity" });
expect(resolveNamingTemplates({ global: MP_DEFAULT_NAMING, root: { tv: "R-tv" }, entity: null }).sources.tv).toBe(
"root",
);
});
});
describe("路径绑定不变", () => {
const root = { id: "L", hostPath: "/media/tv" };
it("已有作品绑定:目录用绑定路径,文件名用新模板(差分 #24)", () => {
const naming = buildNamingContext({ title: "新名", year: 2008 }, parseRelease("Show.S01E02.1080p"));
const dec = resolveTargetDirectory({
entity: { id: "e", kind: "tv" },
naming,
season: 1,
bindings: [
{
id: "b",
entityId: "e",
scope: "entity",
libraryRootId: "L",
relativePath: "旧名 (2008)",
templateVersion: 1,
},
],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(dec).toMatchObject({ kind: "new", relativePath: "旧名 (2008)/Season 1", reusedBindingIds: ["b"] });
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, naming, "mkv")).toMatchObject({
fileName: "新名 - S01E02 - 第 2 集.mkv",
});
});
it("首次绑定:作品根 + 分类前缀进绑定,子目录段在绑定之后照常渲染", () => {
const templates = {
...DEFAULT_TEMPLATES,
version: 7,
movie: "{{type}}/{{title_year}}/{{edition}}/{{title}}{{fileExt}}",
};
const naming = buildNamingContext({ title: "X", year: 2020 }, parseRelease("X.2020.1080p.BluRay.DV.x265-A"));
const first = resolveTargetDirectory({
entity: { id: "m", kind: "movie" },
naming,
bindings: [],
libraryRoot: root,
templates,
});
expect(first).toMatchObject({
kind: "new",
relativePath: "电影/X (2020)/BluRay DV",
newBindings: [{ scope: "entity", relativePath: "电影/X (2020)", templateVersion: 7 }],
});
const again = resolveTargetDirectory({
entity: { id: "m", kind: "movie" },
naming: { ...naming, title_year: "改名 (2020)" },
bindings: [
{
id: "b",
entityId: "m",
scope: "entity",
libraryRootId: "L",
relativePath: "电影/X (2020)",
templateVersion: 7,
},
],
libraryRoot: root,
templates,
});
expect(again).toMatchObject({ kind: "existing", relativePath: "电影/X (2020)/BluRay DV" });
});
it("没有季目录的模板:季绑定等于作品目录", () => {
const dec = resolveTargetDirectory({
entity: { id: "e", kind: "tv" },
naming: buildNamingContext({ title: "X" }, parseRelease("X.S02E01")),
season: 2,
bindings: [],
libraryRoot: root,
templates: { ...DEFAULT_TEMPLATES, tv: "{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}" },
});
expect(dec).toMatchObject({
kind: "new",
relativePath: "X",
newBindings: [{ relativePath: "X" }, { relativePath: "X" }],
});
});
});
describe("内置样本", () => {
it("六条样本都能用默认模板渲染", () => {
expect(NAMING_SAMPLES).toHaveLength(6);
for (const s of NAMING_SAMPLES) {
const ext = s.fileName.slice(s.fileName.lastIndexOf("."));
const vars = buildNamingVars({ ...s.entity, kind: s.kind }, parseRelease(s.fileName), { fileExt: ext });
const res = renderNamingPath(MP_DEFAULT_NAMING[s.kind], vars);
expect(res.ok, `${s.label} ${JSON.stringify(res.errors)}`).toBe(true);
}
});
});