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

+30 -34
View File
@@ -1,17 +1,15 @@
import { describe, expect, it } from "vitest";
import {
buildNamingContext,
compileTemplate,
DEFAULT_TEMPLATES,
detectPathConflicts,
NamingTemplateError,
type PathBinding,
parseRelease,
renderMediaFileName,
renderTemplate,
resolveTargetDirectory,
sanitizeFileName,
sanitizeSegment,
TemplateError,
toDownloaderPath,
toHostPath,
utf8Length,
@@ -61,7 +59,7 @@ describe("路径绑定", () => {
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)/Season 2",
reusedBindingIds: ["b1"],
newBindings: [{ scope: "season", season: 2, templateVersion: 1 }],
newBindings: [{ scope: "season", season: 2, templateVersion: 2 }],
});
});
it("首次:作品 + 季两级绑定,非法字符转全角", () => {
@@ -150,13 +148,13 @@ describe("路径绑定", () => {
...tv,
naming: { title: "x" },
season: 1,
templates: { ...DEFAULT_TEMPLATES, seasonDir: "S{episode}" },
templates: { ...DEFAULT_TEMPLATES, tv: "{{title}}/{{evil}}/{{title}}{{fileExt}}" },
}),
).toMatchObject({ kind: "error", missing: ["episode"] });
).toMatchObject({ kind: "error", reason: "template_error" });
});
});
describe("命名模板", () => {
describe("命名模板(旧入口 buildNamingContext / renderMediaFileName)", () => {
it("电影 / 剧集 / 多集区间 / 综艺日期", () => {
const movie = buildNamingContext(
{ title: "沙丘2", year: 2024 },
@@ -168,56 +166,54 @@ describe("命名模板", () => {
});
const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay"));
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({
fileName: "指环王 (2001) - Extended - 1080p.mkv",
fileName: "指环王 (2001) - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(ep.seasonEpisode).toBe("S02E01-E03");
expect(ep.season_episode).toBe("S02E01-E03");
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03.mkv",
fileName: "庆余年 - S02E01-E03 - 第 1-3 集.mkv",
});
const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p"));
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, daily, "mp4")).toMatchObject({
fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4",
fileName: "奔跑吧 - 2024-04-26 - 第 1 期.mp4",
});
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({
ok: false,
missing: ["episode_or_air_date"],
});
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toEqual({ ok: false, missing: ["title"] });
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toMatchObject({ ok: false, missing: ["title"] });
});
it("上下文完整字段", () => {
it("上下文完整字段(MP 变量名)", () => {
const ctx = buildNamingContext(
{ title: "T", originalTitle: "O", year: 2020 },
parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"),
);
expect(ctx).toMatchObject({
originalTitle: "O",
episode: 5,
original_title: "O",
season: "1",
episode: "5",
season_episode: "S01E05",
part: "CD2",
source: "WEB-DL",
videoCodec: "H.265",
hdr: "DV",
resourceType: "WEB-DL",
videoCodec: "H265",
effect: "DV",
edition: "WEB-DL DV",
resource_term: "WEB-DL DV 2160p",
releaseGroup: "GRP",
type: "电视剧",
});
expect(buildNamingContext({ title: "T" })).toEqual({ title: "T" });
});
it("格式化与可选片段", () => {
expect(renderTemplate("S{season:02}E{episode:03}", { season: 1, episode: 5 })).toEqual({
ok: true,
value: "S01E005",
expect(buildNamingContext({ title: "T" })).toEqual({
title: "T",
title_year: "T",
type: "电影",
total_episodes: 0,
});
expect(renderTemplate("{airDate:dot}", { airDate: "20240101" })).toEqual({ ok: true, value: "2024.01.01" });
expect(renderTemplate("{title:zz}", { title: "a" })).toEqual({ ok: true, value: "a" });
expect(renderTemplate("A[[ - {part}]] B", { part: "" })).toEqual({ ok: true, value: "A B" });
expect(renderTemplate("{title}/{year}", { title: "a/b" })).toEqual({ ok: false, missing: ["year"] });
expect(renderTemplate(compileTemplate("{title}"), { title: "a/b" })).toEqual({ ok: true, value: "a/b" });
expect(compileTemplate("{title}[[ ({year})]]").vars).toEqual(["title", "year"]);
});
it("模板校验:白名单、括号", () => {
for (const bad of ["{evil}", "{title", "a]]", "[[a", "[[ [[x]] ]]", "x".repeat(600)])
expect(() => compileTemplate(bad), bad).toThrow(TemplateError);
it("模板集校验", () => {
expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow();
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError);
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, variety: "{{constructor}}" })).toThrow(
NamingTemplateError,
);
});
});