Files
UHD_Media_Tool/docs/design/naming-template.md
T

16 KiB
Raw Blame History

整理命名模板(对标 MoviePilot 重命名格式)

需求来源:docs/plan/00-first-version-plan.md §8 第 5 条。行为对照:docs/porting/moviepilot-transfer.md §2.4。上游 jxxghp/MoviePilot v3 @ e6820a8,只记录行为,不复制源码(GPLv3)。 现状:packages/domain/src/path/{template,binding,sanitize}.ts。模板语法是自研的 {var} + [[可选段]];模板集 DEFAULT_TEMPLATES 在 services/organize.ts:434 写死,没有设置项、没有预览,变量只有 16 个,名字和 MP 对不上。

1. MP 怎么做(结论)

项 MP 行为 位置
模板 电影、剧集、音乐各一条全路径 Jinja2 字符串,/ 就是目录层级;全局一份,存 system/env app/runtime/config.py:521-542
电影默认 {{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}} :522
剧集默认 {{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}} :528
渲染 jinja2.Template 默认环境:不开 sandbox,未定义变量渲染成空串;值为 None 的键先被删掉,所以 {% if x %} 为假 transhandler.py:1991
后处理 没有:不 strip、不合并空格、不限长度。模板本身先把 \ 换成 /、合并连续 /、去首尾 / config.py:1347-1363
非法字符 `/:*?"<> 转全角,**只作用于** title/en_title/original_title/episode_title;releaseGroup、name 等字段里的/` 会被当成目录
目录拼装 库目录/[类型]/[二级分类]/<渲染结果>;类型、分类两层由目录配置里的两个开关控制,分类来自 category.yaml transhandler.py:1757-1793
媒体根 从后往前找"含 title、不含 season"的最后一层作为作品根目录 directory.py:194-250
多集 E01-E02 保留区间;3 集及以上截成首集 transhandler.py:384-389
前端 设定 → 目录:三个 Ace 编辑器(jinja2 高亮),只有 Jinja 文档链接;没有变量清单、没有预览;不能按目录覆盖格式(目录只有 renaming 开关) AccountSettingDirectory.vue:64-118、DirectoryCard.vue:300-417

2. 语法:Jinja 风格的安全子集

采用与 MP 相同的写法,MP 用户的模板可以直接粘贴。自写解析器,不引入 Jinja 运行时,不执行任意代码。

语法 含义 例
{{ var }} 输出变量;缺失/空串 → 空串(同 MP) {{title}}
{{ a or b or 'x' }} 取第一个非空值;字面量只允许单/双引号字符串与整数 {{en_title or title}}
{{ var | filter(args) }} 过滤器链,白名单见下 {{season | pad(2)}}
{% if cond %}…{% elif cond %}…{% else %}…{% endif %} 条件;可嵌套,深度 ≤ 4 {% if year %} ({{year}}){% endif %}
cond var、not var、var == 'x'、var != 'x'、var > 1(整数比较)、and/or/括号 {% if total_discs and total_discs > 1 %}
{# 注释 #} 丢弃

过滤器白名单:default('x')、pad(n)(数字左补零)、upper、lower、trim、replace('a','b')、truncate(n)(按字符)、date('YYYY-MM-DD'|'YYYY.MM.DD'|'YYYYMMDD')。 不支持:{% for %}、{% set %}、宏、属性/下标访问(a.b、a[0])、函数调用、~ 拼接、过滤器外的任何表达式。遇到直接报编译错误,错误带行列号。

兼容旧语法:{var}、{var:02}、[[ … ]] 在读取 v1 模板时自动改写成 {{var | pad(2)}}、{% if … %},保存时落成新语法(一次性迁移,见 §9)。

限制:模板 ≤ 1024 字符;编译后节点 ≤ 500;单次渲染输出 ≤ 4096 字节;超出即报错。

3. 变量全表

名称与 MP 保持一致(驼峰/下划线照抄 MP,不统一改名)。"值格式"是渲染出的字符串。

变量 含义 值格式(例) 来源(我们)
title 作品标题(本地实体名) 绝命毒师 media_entity.title
en_title 英文标题 Breaking Bad 外部身份 TMDB en-US
original_title 原语种标题 Breaking Bad media_entity.original_title
name 文件名识别出的标题 同发布 parsed.title
year 年份 2008 实体年份,缺失取 parsed.year
title_year 标题 (年份),无年份只有标题 绝命毒师 (2008) 派生
season 季号,不补零;剧集缺季号为 1 1、0 parsed.season / 季单元
season_fmt S01,多季 S01-S02 S01 派生
season_year 该季首播年份 2008 季单元 metadata
episode 集号,不补零;多集 1-3 1、1-3 parsed.episodes
season_episode S01E01,多集 S01E01-E03 S01E01-E03 派生
total_episodes 本季总集数,未知为 0 12 季单元 episode_total
episode_title 单集标题 Pilot 集单元 metadata
episode_date 单集播出日期 2008-01-20 集单元 metadata
part 分段 CD1、Part2 parsed.part
videoFormat 分辨率,小写 2160p、1080p parsed.resolution
resourceType 来源 BluRay、WEB-DL、REMUX parsed.source
effect 特效 HDR10 DV parsed.hdr 以空格连接
edition resourceType effect(去首尾空格) BluRay DV 派生(同 MP)
resource_term resourceType effect videoFormat BluRay DV 2160p 派生
releaseGroup 发布组 CMCT parsed.releaseGroup.name
videoCodec 视频编码 H265 parsed.videoCodec
videoBit 位深 10bit parsed.bitDepth
audioCodec 音频编码 DTS-HD MA parsed.audio 第一项
webSource 流媒体来源 NF parsed.webSource
fps 帧率 60 parsed.fps
customization 自定义占位符 识别词(未实现前恒空)
fileExt 扩展名,带点;我们统一小写(MP 保留原大小写) .mkv 源文件
type 电影 / 电视剧 / 综艺 media_kind
category 二级分类 欧美剧 实体 category(没有则空)
tmdbid / imdbid / doubanid 外部 ID 1396 外部身份
cut 剪辑版本(我们特有) Director's Cut parsed.cut
contentType 内容类型 main、extra parsed.contentType
segment 正片外片段 NCOP parsed.segment
airDate 综艺播出日期 YYYYMMDD,用 date() 格式化 20260927 parsed.airDate
issue 综艺期号 12 parsed.issue
slotName 版本目标名 4K HDR acquisition_slot.name
libraryRoot 库根名 电视剧-A library_root.name

MP 在命名场景剔除 media_source/media_id,我们同样不暴露内部 id。MP 的音乐变量(artist/album/track…)不做(无音乐需求)。

4. 默认模板

类型 默认(与 MP 一致,除标注)
电影 MP 电影默认原样
剧集 MP 剧集默认原样
综艺 {{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {% if season_episode and episode %}{{season_episode}}{% else %}{{airDate | date('YYYY-MM-DD')}}{% endif %}{% if issue %} - 第 {{issue}} 期{% endif %}{{fileExt}}(我们特有:无集号按播出日期)
附属文件 同主视频主干 + 语言后缀(字幕 .zh-cn/.zh-tw/.eng,规则见 transfer 账本 §2.4 ⑦),不单独配模板

目录层级:模板里的 / 切段。解析时给每段定角色:作品根 = 从后往前第一段含 title/title_year/en_title/original_title 且不含季变量的段(同 MP get_media_root_path);季目录 = 作品根之后、含 season/season_fmt 的段;最后一段 = 文件名,必须以 {{fileExt}} 结尾(否则编译报错"文件名缺少 {{fileExt}}")。作品根之前的段(如 {{type}}/{{category}})算分类前缀。

分类前缀:不做 MP 的"类型/二级分类"两个开关,直接写进模板(例 {{type}}/{{category}}/{{title}}…);category 为空的段整段丢弃,不产生空目录。

5. 非法字符与长度

规则 行为 与 MP
变量值里的 `/:*?"<> ` 转全角,所有变量都转
模板字面量里的 / 目录分隔 同
控制字符 删除 MP 不处理
段首尾空白与点、连续空白 去首尾、合并为一个空格 偏离:MP 保留;只在变量为空导致 A - B 时出现差异
空段 丢弃;作品根或文件名段为空 → 报错 empty_segment MP 交给 Path 规范化
保留名 CON/NUL… 加 _ MP 无
长度 每段 ≤ 255 字节(UTF-8),文件名段保留扩展名截主干;全路径 ≤ 4096 字节 MP 无限制

复用现有 sanitizeSegment/sanitizeFileName,在渲染每个变量值时调用,字面量不经过它。

6. 覆盖层级与路径绑定

  • 生效顺序:作品级覆盖 > 库根级 > 全局默认,按类型(movie/tv/variety)分别覆盖;未覆盖的类型往上回落。
  • 存储:全局在 settings.naming {version, movie, tv, variety};库根在 library_root.naming(JSONB,同结构不含 version);作品在 media_entity.naming_override。每次保存全局或库根模板 version += 1(库根用 root:<id>:<n>)。
  • 路径绑定不变(REQ §7.1,已由 resolveTargetDirectory 实现):作品根目录与季目录只在首次建立 path_binding 时按模板渲染;之后入库直接用绑定路径,模板、译名变化都不改已有目录。path_binding.template_version 记录生成时的模板版本。
  • 文件名每次入库按当前模板渲染;已入库文件不回溯改名。要统一改名走文件管理的"按模板重命名"(预览 → 确认 → 批量任务),不在本期。
  • 渲染入口改成:整条模板 → 段列表 → 若已有作品/季绑定,用绑定路径替换对应前缀,只渲染剩余段。NamingTemplateSet 由 6 条子模板改为 3 条全路径模板;resolveTargetDirectory 的"首次绑定"分支改为取作品根段/季段的渲染结果。

7. 设置页表单

位置:设置 → 媒体库与目录 → 新节 #naming「命名模板」;库根抽屉加"覆盖命名模板"折叠区;作品详情 → 维护菜单加"命名覆盖"。

元素 行为
类型切换 电影 / 剧集 / 综艺 三个 Tab,各一个等宽多行编辑框(CodeMirror 轻量模式或 textarea + 行号),{{ }} {% %} 着色
变量清单 右侧(窄屏下方)按组列出:标题、季集、技术、ID、我们特有;每项显示名称、说明、当前示例值;点击在光标处插入 {{var}};条件片段按钮插入 {% if var %}…{% endif %}
示例文件 下拉选样本:内置 6 条(电影/电影 part/剧集单集/多集/S00/综艺按日期)+ "从最近入库挑一条" + 手填文件名(调用解析器)
实时预览 输入停止 300 ms 调预览接口,显示完整相对路径,段角色着色(分类前缀/作品根/季/文件);同时列出"已有绑定的作品不受影响"一句
错误 编译错误显示行列号与原因("未知变量 videoformat,是否指 videoFormat");缺少 {{fileExt}}、作品根识别不出、空段逐条列出;有错不能保存
操作 保存(版本 +1)/ 还原为 MP 默认 / 从 MP 粘贴(原样接受)

8. 后端接口

方法 路径 入参 出参
GET /naming/variables — [{name, group, label, example, ours: boolean}]
POST /naming/validate {template, kind} {ok, errors:[{line,col,message}], roles:[{segment, role}]}
POST /naming/preview {template?, kind, sample: {fileName} | {sourceReleaseId} | {assetId} | {builtin: n}, libraryRootId?, entityId?} {ok, relativePath, segments:[{text, role}], missing:[], warnings:[], usesBinding: boolean}
GET/PUT settings.naming 走现有 settings 读写 保存前服务端再编译一次
PUT /library-roots/{id} naming 字段 同上
PUT /media-entities/{id}/naming-override {movie?, tv?, variety?} | null

contracts:settings.ts 加 NamingSettings、SETTINGS_SECTIONS.library 加 naming 节;instances/library root 加 naming;domain 新增 path/naming-engine.ts(lexer → parser → AST → render,纯函数)与 buildNamingContext 的 MP 变量映射,template.ts 保留为 v1 兼容读取。

9. 迁移

  1. 新迁移:library_root.naming jsonb null、media_entity.naming_override jsonb null;settings.naming 缺省 = MP 默认,version = 2(v1 = 现有 DEFAULT_TEMPLATES)。
  2. 已有 path_binding 不动(template_version=1 照旧),只影响之后新建的绑定与文件名。
  3. organize.ts 与 organize/plan.ts 改为按"作品 → 库根 → 全局"取模板集。

10. 差分测试样例(与 MP 输出比对)

放 packages/testkit/fixtures/naming-cases.json;MP 侧输出用上游 jinja2.Template + TemplateContextBuilder 在 dev 容器离线生成一次入库,之后只跑我们的实现。L = 库根。未注明模板的用 §4 默认。

# 输入 期望(相对 L) 与 MP
1 电影 Inception 2010,2160p,.mkv Inception (2010)/Inception (2010) - 2160p.mkv 同
2 同上无分辨率 Inception (2010)/Inception (2010).mkv 同
3 电影 part=CD1,1080p Inception (2010)/Inception (2010)-CD1 - 1080p.mkv 同
4 电影无年份,title=A/B:C A/B:C/A/B:C.mkv 同
5 剧集 绝命毒师 2008 S01E01 绝命毒师 (2008)/Season 1/绝命毒师 - S01E01 - 第 1 集.mkv 同
6 S01E01-E02 合集 …/Season 1/绝命毒师 - S01E01-E02 - 第 1-2 集.mkv 同
7 S01E01-E03 合集 …/Season 1/绝命毒师 - S01E01-E03 - 第 1-3 集.mkv 有意偏离:MP 截成 S01E01 - 第 1 集
8 S00E03 绝命毒师 (2008)/Season 0/绝命毒师 - S00E03 - 第 3 集.mkv 同
9 剧集无年份无季号 E05,.ass 某剧/Season 1/某剧 - S01E05 - 第 5 集.ass(再加语言后缀) 同
10 S12E100 …/Season 12/X - S12E100 - 第 100 集.mkv 同
11 扩展名 .MKV 结尾 .mkv 有意偏离:MP 保留原大小写 .MKV(transhandler.py:391-395);我们沿用 sanitizeFileName 小写
12 模板 {{title_year}}/{{title}} {{season_fmt}}{{fileExt}},S02E01 绝命毒师 (2008)/绝命毒师 S02.mkv 同
13 模板 {{title}}/{{edition}}/{{title}}{{fileExt}},BluRay + DV X/BluRay DV/X.mkv 同
14 模板含 {{resource_term}},WEB-DL 1080p 无特效 段内 WEB-DL 1080p 同(MP 前导空格被我们 trim,目录段中间无差异)
15 releaseGroup=A/B,模板 …- {{releaseGroup}}{{fileExt}} … - A/B.mkv 有意偏离:MP 多出目录 A
16 {{en_title or title}},无英文名 用 title 同
17 {{season | pad(2)}},S3 03 MP 无此过滤器;MP 写法 {{ '%02d' % season|int }} 我们拒绝并提示用 pad
18 year 缺失,模板 {{title}} ({{year}}) X () → 段 X () 同(MP 未定义渲染空);校验给 warning "year 可能为空,建议 {% if year %}"
19 title 过长 300 字节 截到 255 字节,文件名保留 .mkv 偏离:MP 不截,文件系统报错
20 综艺 快乐大本营 airDate=20260927 issue=12 无集号 快乐大本营/Season 1/快乐大本营 - 2026-09-27 - 第 12 期.mp4 我们特有
21 模板 {{type}}/{{category}}/{{title}}…,category 空 电视剧/X (2008)/…(空段丢弃) 同(MP Path 规范化)
22 模板 {% for x in y %} 编译错误 不支持 for 偏离(安全子集)
23 模板 {{title.__class__}} 编译错误 不支持属性访问 偏离(安全子集)
24 已有作品绑定 旧名 (2008),新模板渲染出 新名 (2008) 目录用 旧名 (2008)/Season 1/…,文件名用新模板 偏离(路径绑定)
25 模板末段不含 {{fileExt}} 编译错误 偏离(MP 会生成无扩展名文件)

11. 实施拆分

包 内容 估
N1 domain naming-engine.ts(词法/语法/渲染/段角色/错误定位)+ v1 语法迁移 + 变量映射 + §10 全部样例单测 M(1.5 天)
N2 server settings/库根/作品覆盖存储、三级回落、/naming/* 三接口、organize 与 plan 改用新引擎、迁移 M(1 天)
N3 web 设置 #naming 节(编辑器、变量清单插入、样本、实时预览、错误)、库根抽屉覆盖、作品"命名覆盖" M(1.5 天)
N4 testkit MP 侧离线生成对照输出,差分脚本进 CI S

依赖:N1 → N2 → N3;N4 与 N1 并行。