Files
UHD_Media_Tool/docs/design/naming-template.md
T
rootandClaude Opus 5.5 b343dddbf6 docs: 清理过程文档,CHANGELOG 补齐 0.3.8–0.3.14
- 删除 docs/plan 下 00–18 的计划、评审、验收、发布记录与升级证据,docs/spikes 与 spikes 验证代码
  (保留 spikes/movieclaw-src 本地克隆),以及 4 份一次性审查 / 调研;全部可在 ceb66f8 查到
- MP 对照移到 docs/porting/moviepilot-v3-comparison.md;经验教训改为 docs/plan/lessons.md(补第 25 条)
- docs/plan/README.md:文档放哪、当前状态、待办
- 引用改写:代码注释指向在维护的文档,其余注明已删除与 git 位置;清掉原有 6 个失效链接
- README 开头改为当前状态与入口;CHANGELOG 补齐 0.3.8–0.3.14 与工程变更

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 16:08:09 +08:00

197 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 整理命名模板(对标 MoviePilot 重命名格式)
> 需求来源:`00-first-version-plan`(已删除,见 git `ceb66f8`) §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 等字段里的 `/` 会被当成目录 | `message.py:520-534` |
| 目录拼装 | `库目录/[类型]/[二级分类]/<渲染结果>`;类型、分类两层由目录配置里的两个开关控制,分类来自 `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 只转标题类,releaseGroup 带 `/` 会多一层目录;我们不让值生成目录 |
| 模板字面量里的 `/` | 目录分隔 | 同 |
| 控制字符 | 删除 | 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 并行。