197 lines
16 KiB
Markdown
197 lines
16 KiB
Markdown
# 整理命名模板(对标 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 等字段里的 `/` 会被当成目录 | `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 并行。
|