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:
1 parent
e8745115f4
commit
f7c75ad781
22 files changed
+2674
-281
No files matched your search
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user