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);
});
}
});