- 删除 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]>
16 KiB
整理命名模板(对标 MoviePilot 重命名格式)
需求来源:
00-first-version-plan(已删除,见 gitceb66f8) §8 第 5 条。行为对照:docs/porting/moviepilot-transfer.md§2.4。上游jxxghp/MoviePilotv3 @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. 迁移
- 新迁移:
library_root.naming jsonb null、media_entity.naming_override jsonb null;settings.naming缺省 = MP 默认,version = 2(v1 = 现有DEFAULT_TEMPLATES)。 - 已有
path_binding不动(template_version=1 照旧),只影响之后新建的绑定与文件名。 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 并行。