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

@@ -0,0 +1,80 @@
"""命名模板差分:用 MP 上游(MetaInfo + TemplateContextBuilder + jinja2.Template)生成 naming-mp.json。
用法(MP 仓库根目录,用其 .venv):
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \
.venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/gen-naming-mp.py
只记录行为输出,不复制 MP 源码。默认模板取 settings.RENAME_FORMAT(与 MP 规范化一致);
MP 没有综艺类型,综艺样例用剧集类型渲染我们的模板(预期 MP 报错,属我们特有)。
"""
import json
import pathlib
import subprocess
from pathlib import PurePosixPath
from jinja2 import Template
from app.application.messaging.message import TemplateHelper
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
HERE = pathlib.Path(__file__).parent
cases = json.loads((HERE / "naming-cases.json").read_text(encoding="utf-8"))["cases"]
def normalize_template(s: str) -> str:
# 同 settings.RENAME_FORMAT:\ → /,合并 /,去首尾 /
import re
s = s.replace("\\", "/")
s = re.sub(r"/+", "/", s)
return s.strip("/")
def run(case: dict) -> dict:
kind = case["kind"]
mtype = MediaType.MOVIE if kind == "movie" else MediaType.TV
meta = MetaInfo(case["fileName"])
if mtype == MediaType.TV and meta.begin_episode is not None:
# transhandler.plan_transfer:单文件 3 集及以上截成首集
meta.end_season = None
if meta.total_season:
meta.total_season = 1
if meta.total_episode > 2:
meta.total_episode = 1
meta.end_episode = None
e = case["entity"]
mi = MediaInfo()
mi.type = mtype
mi.title = e["title"]
mi.year = str(e["year"]) if e.get("year") else None
mi.en_title = e.get("enTitle")
mi.original_title = e.get("originalTitle")
mi.category = e.get("category", "")
if e.get("tmdbId") is not None:
mi.tmdb_id = e["tmdbId"]
ext = "." + case["fileName"].rsplit(".", 1)[1]
ctx = TemplateHelper().builder.build(meta=meta, mediainfo=mi, file_extension=ext, include_raw_objects=False)
ctx.pop("media_source", None)
ctx.pop("media_id", None)
ctx.update(case.get("vars") or {})
tpl = case.get("template") or settings.RENAME_FORMAT(mtype)
try:
out = Template(normalize_template(tpl)).render(ctx)
except Exception as ex: # noqa: BLE001 - 记录 MP 的报错
return {"error": f"{type(ex).__name__}: {ex}"}
return {"path": str(PurePosixPath(out))}
def main() -> None:
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip()
result = {"generatedFrom": {"repo": "MoviePilot", "commit": commit}, "mp": {c["id"]: run(c) for c in cases}}
(HERE / "naming-mp.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {len(cases)} cases")
if __name__ == "__main__":
main()
@@ -0,0 +1,420 @@
{
"note": "命名模板差分样例:ours 期望 = expect/expectError;MP 输出见 naming-mp.json(gen-naming-mp.py 生成)。deviation 标注故意偏离/我们特有/表示差异,其余要求与 MP 逐字一致。",
"cases": [
{
"id": "N01",
"kind": "movie",
"fileName": "Inception.2010.2160p.BluRay.x265-CMCT.mkv",
"entity": {
"title": "Inception",
"year": 2010
},
"expect": "Inception (2010)/Inception (2010) - 2160p.mkv"
},
{
"id": "N02",
"kind": "movie",
"fileName": "Inception.2010.mkv",
"entity": {
"title": "Inception",
"year": 2010
},
"expect": "Inception (2010)/Inception (2010).mkv"
},
{
"id": "N03",
"kind": "movie",
"fileName": "Inception.2010.CD1.1080p.BluRay.x264-CMCT.mkv",
"entity": {
"title": "Inception",
"year": 2010
},
"expect": "Inception (2010)/Inception (2010)-CD1 - 1080p.mkv"
},
{
"id": "N04",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "A/B:C"
},
"expect": "A/B:C/A/B:C.mkv"
},
{
"id": "N05",
"kind": "tv",
"fileName": "Breaking.Bad.S01E01.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv"
},
{
"id": "N06",
"kind": "tv",
"fileName": "Breaking.Bad.S01E01-E02.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv"
},
{
"id": "N07",
"kind": "tv",
"fileName": "Breaking.Bad.S01E01-E03.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"expect": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E03 - 第 1-3 集.mkv",
"deviation": {
"kind": "intentional",
"note": "MP 3 集及以上截成首集(S01E01 - 第 1 集);我们保留区间,集账本按 3 集登记"
}
},
{
"id": "N08",
"kind": "tv",
"fileName": "Breaking.Bad.S00E03.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"expect": "绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv"
},
{
"id": "N09",
"kind": "tv",
"fileName": "Some.Show.E05.1080p.ass",
"entity": {
"title": "某剧"
},
"expect": "某剧/Season 1/某剧 - S01E05 - 第 5 集.ass"
},
{
"id": "N10",
"kind": "tv",
"fileName": "X.S12E100.1080p.mkv",
"entity": {
"title": "X"
},
"expect": "X/Season 12/X - S12E100 - 第 100 集.mkv"
},
{
"id": "N11",
"kind": "movie",
"fileName": "Inception.2010.1080p.BluRay.x264-CMCT.MKV",
"entity": {
"title": "Inception",
"year": 2010
},
"expect": "Inception (2010)/Inception (2010) - 1080p.mkv",
"deviation": {
"kind": "intentional",
"note": "MP 保留原扩展名大小写 .MKV;我们统一小写"
}
},
{
"id": "N12",
"kind": "tv",
"fileName": "Breaking.Bad.S02E01.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"template": "{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}",
"expect": "绝命毒师 (2008)/绝命毒师 S02.mkv"
},
{
"id": "N13",
"kind": "movie",
"fileName": "X.2020.1080p.BluRay.DV.x265-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{edition}}/{{title}}{{fileExt}}",
"expect": "X/BluRay DV/X.mkv"
},
{
"id": "N14",
"kind": "movie",
"fileName": "X.2020.1080p.WEB-DL.H264-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}} - {{resource_term}}{{fileExt}}",
"expect": "X/X - WEB-DL 1080p.mkv",
"deviation": {
"kind": "intentional",
"note": "MP resource_term 恒带前导空格,渲染出两个空格;我们合并连续空白"
}
},
{
"id": "N15",
"kind": "movie",
"fileName": "X.2020.1080p.WEB-DL.H264-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}} - {{releaseGroup}}{{fileExt}}",
"vars": {
"releaseGroup": "A/B"
},
"expect": "X/X - A/B.mkv",
"deviation": {
"kind": "intentional",
"note": "MP 只转义标题类变量,releaseGroup 里的 / 会多出一层目录 A;我们所有变量值都转全角,值不产生目录"
}
},
{
"id": "N16",
"kind": "movie",
"fileName": "Inception.2010.mkv",
"entity": {
"title": "盗梦空间",
"year": 2010
},
"template": "{{en_title or title}}/{{en_title or title}}{{fileExt}}",
"expect": "盗梦空间/盗梦空间.mkv"
},
{
"id": "N17",
"kind": "tv",
"fileName": "Breaking.Bad.S03E01.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"template": "{{title}}/S{{season | pad(2)}}/{{title}}{{fileExt}}",
"expect": "绝命毒师/S03/绝命毒师.mkv",
"deviation": {
"kind": "intentional",
"note": "pad 是我们的过滤器,MP(Jinja2)没有,报 No filter named 'pad';MP 写法 {{ '%02d' % season|int }} 我们拒绝并提示用 pad"
}
},
{
"id": "N18",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "X"
},
"template": "{{title}} ({{year}})/{{title}}{{fileExt}}",
"expect": "X ()/X.mkv"
},
{
"id": "N19",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长"
},
"expect": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长/长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长.mkv",
"deviation": {
"kind": "intentional",
"note": "每段按 UTF-8 255 字节截断,文件名保留扩展名;MP 不截,超长由文件系统报错"
}
},
{
"id": "N20",
"kind": "variety",
"fileName": "快乐大本营.20260927.第12期.1080p.WEB-DL.H264.mp4",
"entity": {
"title": "快乐大本营",
"kind": "variety"
},
"expect": "快乐大本营/Season 1/快乐大本营 - 2026-09-27 - 第 12 期.mp4",
"deviation": {
"kind": "ours",
"note": "综艺默认模板是我们特有(date 过滤器、issue 变量),MP 无对应"
}
},
{
"id": "N21",
"kind": "tv",
"fileName": "Breaking.Bad.S01E02.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"template": "{{type}}/{{category}}/{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{{fileExt}}",
"expect": "电视剧/绝命毒师 (2008)/Season 1/绝命毒师 - S01E02.mkv"
},
{
"id": "N22",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "X"
},
"template": "{% for x in y %}{{x}}{% endfor %}{{title}}/{{title}}{{fileExt}}",
"expectError": "unsupported",
"deviation": {
"kind": "intentional",
"note": "安全子集不支持 for;MP 照常渲染"
}
},
{
"id": "N23",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "X"
},
"template": "{{title.__class__}}/{{title}}{{fileExt}}",
"expectError": "attribute_access",
"deviation": {
"kind": "intentional",
"note": "安全子集不支持属性访问;MP 渲染出 <class 'str'>"
}
},
{
"id": "N24",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "X"
},
"template": "{{title}}/{{title}}",
"expectError": "missing_file_ext",
"deviation": {
"kind": "intentional",
"note": "文件名必须以 {{fileExt}} 结尾;MP 会生成无扩展名文件"
}
},
{
"id": "N25",
"kind": "movie",
"fileName": "X.2020.1080p.WEB-DL.H265-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}} - {{videoCodec}}{{fileExt}}",
"expect": "X/X - H265.mkv"
},
{
"id": "N26",
"kind": "movie",
"fileName": "X.2020.1080p.WEB-DL.HEVC-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}} - {{videoCodec}}{{fileExt}}",
"expect": "X/X - H265.mkv",
"deviation": {
"kind": "representation",
"note": "解析器把 HEVC/H.265/H265 归一成 H.265 再输出 H265;MP 保留原词 HEVC"
}
},
{
"id": "N27",
"kind": "movie",
"fileName": "X.2020.2160p.WEB-DL.10bit.H265-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}}{% if videoBit %} {{videoBit}}{% endif %}{{fileExt}}",
"expect": "X/X 10bit.mkv"
},
{
"id": "N28",
"kind": "movie",
"fileName": "Inception.2010.mkv",
"entity": {
"title": "Inception",
"year": 2010,
"enTitle": "Inception Movie"
},
"template": "{{ en_title | replace(' ', '.') }}/{{ title | upper }}{{fileExt}}",
"expect": "Inception.Movie/INCEPTION.mkv"
},
{
"id": "N29",
"kind": "tv",
"fileName": "Breaking.Bad.S00E03.1080p.WEB-DL.H264-NTb.mkv",
"entity": {
"title": "绝命毒师",
"year": 2008,
"enTitle": "Breaking Bad"
},
"template": "{{title}}/{% if season == '0' %}Specials{% else %}Season {{season}}{% endif %}/{{title}} - {{season_episode}}{{fileExt}}",
"expect": "绝命毒师/Specials/绝命毒师 - S00E03.mkv"
},
{
"id": "N30",
"kind": "movie",
"fileName": "X.2020.2160p.WEB-DL.H265-A.mkv",
"entity": {
"title": "X",
"year": 2020
},
"template": "{{title}}/{{title}} [{% if videoFormat == '2160p' %}4K{% elif videoFormat %}{{videoFormat}}{% else %}SD{% endif %}]{{fileExt}}",
"expect": "X/X [4K].mkv"
},
{
"id": "N31",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "X"
},
"template": "{{title}}/{{title}} - {{ releaseGroup | default('NOGRP') }}{{fileExt}}",
"expect": "X/X - NOGRP.mkv"
},
{
"id": "N32",
"kind": "movie",
"fileName": "Inception.2010.mkv",
"entity": {
"title": "Inception",
"year": 2010,
"tmdbId": 27205
},
"template": "{{title_year}} [tmdbid={{tmdbid}}]/{{title_year}}{{fileExt}}",
"expect": "Inception (2010) [tmdbid=27205]/Inception (2010).mkv"
},
{
"id": "N33",
"kind": "movie",
"fileName": "Some.Movie.mkv",
"entity": {
"title": "CON"
},
"expect": "CON_/CON_.mkv",
"deviation": {
"kind": "intentional",
"note": "Windows 保留名加 _;MP 不处理"
}
},
{
"id": "N34",
"kind": "movie",
"fileName": "Inception.2010.mkv",
"entity": {
"title": "Inception",
"year": 2010
},
"template": "{{title}}: {{year}}/{{title}}{{fileExt}}",
"expect": "Inception: 2010/Inception.mkv",
"deviation": {
"kind": "intentional",
"note": "模板字面量里的非法字符也转全角;MP 原样保留 :"
}
}
]
}
@@ -0,0 +1,110 @@
{
"generatedFrom": {
"repo": "MoviePilot",
"commit": "e6820a8"
},
"mp": {
"N01": {
"path": "Inception (2010)/Inception (2010) - 2160p.mkv"
},
"N02": {
"path": "Inception (2010)/Inception (2010).mkv"
},
"N03": {
"path": "Inception (2010)/Inception (2010)-CD1 - 1080p.mkv"
},
"N04": {
"path": "A/B:C/A/B:C.mkv"
},
"N05": {
"path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv"
},
"N06": {
"path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv"
},
"N07": {
"path": "绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv"
},
"N08": {
"path": "绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv"
},
"N09": {
"path": "某剧/Season 1/某剧 - S01E05 - 第 5 集.ass"
},
"N10": {
"path": "X/Season 12/X - S12E100 - 第 100 集.mkv"
},
"N11": {
"path": "Inception (2010)/Inception (2010) - 1080p.MKV"
},
"N12": {
"path": "绝命毒师 (2008)/绝命毒师 S02.mkv"
},
"N13": {
"path": "X/BluRay DV/X.mkv"
},
"N14": {
"path": "X/X - WEB-DL 1080p.mkv"
},
"N15": {
"path": "X/X - A/B.mkv"
},
"N16": {
"path": "盗梦空间/盗梦空间.mkv"
},
"N17": {
"error": "TemplateAssertionError: No filter named 'pad'."
},
"N18": {
"path": "X ()/X.mkv"
},
"N19": {
"path": "长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长/长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长长.mkv"
},
"N20": {
"path": "快乐大本营/Season 1/快乐大本营 - S01E12 - 第 12 集.mp4"
},
"N21": {
"path": "电视剧/绝命毒师 (2008)/Season 1/绝命毒师 - S01E02.mkv"
},
"N22": {
"path": "X/X.mkv"
},
"N23": {
"path": "<class 'str'>/X.mkv"
},
"N24": {
"path": "X/X"
},
"N25": {
"path": "X/X - H265.mkv"
},
"N26": {
"path": "X/X - HEVC.mkv"
},
"N27": {
"path": "X/X 10bit.mkv"
},
"N28": {
"path": "Inception.Movie/INCEPTION.mkv"
},
"N29": {
"path": "绝命毒师/Specials/绝命毒师 - S00E03.mkv"
},
"N30": {
"path": "X/X [4K].mkv"
},
"N31": {
"path": "X/X - NOGRP.mkv"
},
"N32": {
"path": "Inception (2010) [tmdbid=27205]/Inception (2010).mkv"
},
"N33": {
"path": "CON/CON.mkv"
},
"N34": {
"path": "Inception: 2010/Inception.mkv"
}
}
}
@@ -0,0 +1,62 @@
/**
* 命名模板差分:MP(MetaInfo + TemplateContextBuilder + jinja2.Template)vs 我们的 parseRelease + buildNamingVars + renderNamingPath。
* MP 输出由 gen-naming-mp.py 生成(naming-mp.json)。没有登记 deviation 的样例必须与 MP 逐字一致;
* 登记了的必须确实不同(差异消除后删掉登记)。说明见 docs/runbook/naming.md。
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
buildNamingVars,
MP_DEFAULT_NAMING,
type NamingEntityInput,
type NamingKind,
parseRelease,
renderNamingPath,
validateNamingTemplate,
} from "../../src/index.ts";
interface Case {
id: string;
kind: NamingKind;
fileName: string;
entity: NamingEntityInput;
template?: string;
vars?: Record<string, string>;
expect?: string;
expectError?: string;
deviation?: { kind: "intentional" | "ours" | "representation"; note: string };
}
const dir = new URL(".", import.meta.url);
const { cases } = JSON.parse(readFileSync(new URL("naming-cases.json", dir), "utf8")) as { cases: Case[] };
const { mp } = JSON.parse(readFileSync(new URL("naming-mp.json", dir), "utf8")) as {
mp: Record<string, { path?: string; error?: string }>;
};
function ours(c: Case): { path?: string; error?: string } {
const template = c.template ?? MP_DEFAULT_NAMING[c.kind];
const v = validateNamingTemplate(template, c.kind);
if (!v.ok) return { error: v.errors[0]!.code };
const ext = c.fileName.slice(c.fileName.lastIndexOf("."));
const vars = { ...buildNamingVars(c.entity, parseRelease(c.fileName), { fileExt: ext }), ...c.vars };
const r = renderNamingPath(template, vars);
return r.ok ? { path: r.relativePath } : { error: r.errors[0]!.code };
}
describe("命名模板差分(MP ↔ naming 引擎)", () => {
it("样例 ≥ 25 且每条都有 MP 输出", () => {
expect(cases.length).toBeGreaterThanOrEqual(25);
for (const c of cases) expect(mp[c.id], c.id).toBeDefined();
});
for (const c of cases) {
it(`${c.id} ${c.template ?? `默认 ${c.kind}`} ← ${c.fileName}`, () => {
const got = ours(c);
if (c.expectError) expect(got).toEqual({ error: c.expectError });
else expect(got).toEqual({ path: c.expect });
const m = mp[c.id]!;
if (c.deviation) expect(m.path, `${c.id} 登记为偏离但与 MP 一致,删掉 deviation`).not.toBe(got.path);
else expect(m.path, `${c.id} 与 MP 不一致`).toBe(got.path);
});
}
});
+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);
}
});
});
+8 -8
View File
@@ -32,10 +32,10 @@ describe("planOrganize 整季包", () => {
it("主视频 + 随行字幕,样例/nfo/图片排除,跨卷标记", () => {
const plan = planOrganize(tvInput());
expect(plan.files.map((f) => [f.role, f.dst])).toEqual([
["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.mkv"],
["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02.mkv"],
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.chi.zh-cn.srt"],
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01.eng.default.srt"],
["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.mkv"],
["main", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E02 - 第 2 集.mkv"],
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.chi.zh-cn.srt"],
["sidecar", "/media/tv/最后生还者 (2023)/Season 1/最后生还者 - S01E01 - 第 1 集.eng.default.srt"],
]);
expect(plan.files[0]).toMatchObject({
kind: "video",
@@ -291,10 +291,10 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", ()
},
});
expect(plan.files.map((f) => f.dst)).toEqual([
"/lib/剧/Season 2/剧 - S02E03-E04.mkv",
"/lib/剧/Season 1/剧 - S01E06.mkv",
"/lib/剧/Season 2/剧 - S02E07.mkv",
"/lib/剧/Season 2/剧 - S02E03-E04.chi.zh-cn.srt",
"/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv",
"/lib/剧/Season 1/剧 - S01E06 - 第 6 集.mkv",
"/lib/剧/Season 2/剧 - S02E07 - 第 7 集.mkv",
"/lib/剧/Season 2/剧 - S02E03-E04 - 第 3-4 集.chi.zh-cn.srt",
]);
expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } });
const movie = planOrganize({
+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,
);
});
});