MoviePilot 标题识别与刮削链路:迁移对照
格式遵循 REQ §14.1.2。本文只记录上游行为,以及本项目 TS 重写时要保留和要改动的点,不复制上游源码。许可边界见 license-and-provenance.md。
0. 来源与核验方式
| 项 |
值 |
| 上游仓库 |
https://github.com/jxxghp/MoviePilot,分支 v3 |
| 本地快照 |
/opt/moviepilot-plus/upstream/MoviePilot |
| 精确提交 |
e6820a83ab05e8a00eb0e14adf0f357faa965c7e(2026-08-30 13:32 +0800,"docs(architecture): close residual audit goal",version.py = v3.0.0) |
| 与 REQ 附录的差异 |
REQ 附录 [S16] 引用的快照是 33da9ce65d5020a8e087584a3321c1ff353c1921。本地仓库是单提交浅克隆,不包含该对象,因此本文所有行号都以 e6820a8 为准。v3 的目录与 v2 不同:app/core/meta/* 已迁到 app/domain/meta/*,app/core/metainfo.py 已迁到 app/domain/metainfo.py,app/chain/media.py 已拆成 app/chain/media/* 和 app/chain/_recognition.py |
| 许可 |
仓库根目录 LICENSE 为 GPLv3 全文 |
| 运行核验 |
用本地 .venv 的 Python 解释器直接调用 app.domain.metainfo.MetaInfo / MetaInfoPath。运行时只注入默认扩展名(RMT_MEDIAEXT/SUBEXT/AUDIOEXT),accelerator=None,所以走的是纯 Python 参考实现。§9 的"实测输出"列全部来自这次运行,不是推测。探针脚本放在会话临时目录,没有写入任何仓库 |
0.1 一个必须知道的结构事实:Rust 快路径
app/domain/metainfo.py:495 MetaInfo() 先检查 get_metainfo_accelerator()(由 app/startup/composition/domain.py:34 注入 app/adapters/system/rust.py,底层是二进制扩展 moviepilot_rust,本机 uv 缓存里的版本是 0.3.3)。满足以下条件时,MetaInfo 走 Rust 解析:扩展存在、RUST_ACCEL 已开启(或运行在 free-threaded 运行时)、标题里没有 media_source= 或 bangumi/anilist 显式标签(_requires_python_metainfo,:472)。Rust 源码不在本仓库。 Python 路径的注释自称"参考实现"(_prepare_meta_input,:285),上游测试 tests/test_recognition_parity.py 负责校验两条路径等价。
结论:本项目的对照基准只取 Python 路径。如果以后发现生产环境 MP 的输出和本文不一致,先确认它有没有开 Rust 加速。
1. 调用链总览
2. 标题解析:MetaInfo / MetaInfoPath 入口
2.1 入口函数
| 上游 |
输入 |
输出 |
关键分支 |
app/domain/metainfo.py:495 MetaInfo(title, subtitle, custom_words, force_video) |
种子名、文件名或标题;站点副标题;临时识别词 |
MetaVideo、MetaAnime 或 MetaMusic |
① 后缀是音频且没有 force_video 时走 MetaMusic;② 可用时走 Rust;③ 否则走 _build_python_meta_info |
app/domain/metainfo.py:354 _build_python_meta_info |
同上 |
MetaBase(带 original_name) |
有识别词生效时,再用不带识别词的原标题解析一次,把结果存为 original_name |
app/domain/metainfo.py:279 _prepare_meta_input |
title |
_PreparedMetaInput |
依次做三件事:WordsMatcher.prepare(应用识别词)→ _find_metainfo_python(剥离显式 ID 标签)→ 后缀在媒体扩展名表里时 isfile=True 并取 stem |
app/domain/metainfo.py:332 _build_meta_info |
prepared |
MetaBase |
is_anime(parsed_title) 为真时用 MetaAnime,否则用 MetaVideo;显式标签最后覆盖(_apply_explicit_metainfo,:308) |
app/domain/metainfo.py:579 is_anime(name) |
name |
bool |
按顺序判断:【xx】【、- 12 形式为 True;接着命中 S\d{2} 或 EP?\d{2,4} 就返回 False;再匹配 [xx][ 为 True;其他情况为 False |
app/domain/metainfo.py:533 MetaInfoPath(path, custom_words, force_video) |
完整路径 |
合并后的 MetaBase |
见 §2.3 |
2.2 显式身份标签(_find_metainfo_python,:178)
| 语法 |
例子 |
结果字段 |
优先级 |
{[k=v;...]} 块 |
{[tmdbid=94997;type=tv;s=1;e=3-4;g=5eb730...]} |
media_source/media_id、type、begin/end_season、begin/end_episode、episode_group |
块内任意字段命中就把整块从标题里删掉 |
| Emby 风格 |
[tmdbid=94997]、[tmdb-94997]、{tmdbid=..}、{tmdb=..} |
TMDB 身份 |
第一个 [tmdbid] 总是优先,会覆盖 {[...]} 里的旧 tmdbid;其余写法按 [tmdbid]>[tmdb]>{tmdbid}>{tmdb} 取第一个 |
| 扩展来源 |
[bangumiid=..]、[anilist=..] |
Bangumi/AniList 身份 |
只在还没有同源身份时取 |
| 多来源并存 |
— |
media_source/media_id 只保留一对 |
按 _LEGACY_ID_KEYS 的顺序 TMDB > Douban > Bangumi > AniList 取第一个 |
id 为 0 |
[tmdbid=0] |
忽略 |
— |
_apply_range_total(:144):有 begin 和 end 时自动算出 total。
TS 对应:packages/domain/src/title/explicit-tags.ts
| 保留 |
调整 |
原因 |
| 四种标签写法、TMDB 优先、id=0 忽略、整块剥离 |
输出改为 external_identity 的候选,不直接视为已确认身份 |
REQ §7.2:本地 UUID 是主身份,外部 ID 只是映射。标签来自文件名,可信度低于人工锁定 |
2.3 多文件、整季包:目录名和文件名如何合并(MetaInfoPath,:533-576)
| 步骤 |
行为 |
说明 |
| 1 |
音频后缀且没有 force_video 时直接返回 MetaMusic |
整理链路一律传 force_video=True(request.py:102),所以影视目录里的音轨按视频解析 |
| 2 |
file_meta = parse(path.name) |
文件名带后缀,后缀会被识别出来并设 isfile=True |
| 3 |
should_use_parent_title_for_file_stem(infopath.py:15)为真时清空 cn_name/en_name/original_name |
条件:文件 stem 全是中文、长度 ≤16、完全由 双语/简体/繁中/国配/… 这类辅助词组成、不含"第X季/集",父目录含两个以上连续拉丁字母,并且文件本身没有显式身份 |
| 4 |
dir_meta = parse(parent.name);只有当 file_meta.type==TV 或 dir_meta.type!=TV 时才 merge |
防止一部电影文件被 TV 目录的季集信息污染。副作用是:文件自身识别不出 TV 而父目录是 TV 时,父目录的名称、季号都不会被合并(见样本 M-10、M-11) |
| 5 |
root_meta = parse(parent.parent.name),merge 条件同上 |
最多只看两级父目录 |
merge 规则(metabase.py:719 merge) |
文件优先,父目录只补空字段:type 在 UNKNOWN 时补;name 为空时补 cn/en;year 为空时补;季只在 type==TV 且 begin_season 为空时整组补(begin/end/total),集同理;可选字段 resource_type/pix/team/customization/effect/web_source/video_encode/video_bit/audio_encode/part/episode_group 为空时补;身份按成对原子合并,不会把不同层级的来源和 ID 拼成一对 |
— |
整季包的判定:MetaBase 里没有 is_pack 字段。约定是 type==TV、begin_season 有值、begin_episode is None 时表示"整季或多季包"(season_list,:473;episode_list 为空,:503)。begin_episode/end_episode 同时有值时表示单文件多集或集区间包。
文件级集号的规则(MetaVideo.__init_episode,metavideo.py:578):
| 条件 |
行为 |
isfile 且集区间长度 >2 |
丢弃 end,只保留 begin(一个文件不可能是 E01-E10)。S02E05E06 这种区间长度为 2 的会保留 |
isfile 且季区间长度 >1 |
丢弃 end_season |
文件名是纯数字、长度 <5(例如 01.mp4,metavideo.py:176) |
直接视为集号,type=TV,然后依靠父目录补名称和季号 |
TS 对应:packages/domain/src/title/parse-path.ts
| 保留 |
调整 |
原因 |
| 文件优先、父目录只补空、身份原子合并、最多两级父目录、纯数字文件名视为集号、文件多集区间 ≤2 |
① 步骤 4 的 merge 条件在"文件无类型、父目录是 TV"时改为合并名称和季号,但要给结果打 evidence: 'parent_dir' 标记,并把置信度降为"待确认";② 辅助词文件(字幕、音轨)不单独识别身份,交给附属文件配对逻辑(见 moviepilot-transfer.md),按主视频 stem 归属 |
上游的这条规则会让 奔跑吧.2024.05.03.mp4、简体.srt 这类文件识别失败或跑偏(M-10、M-11 实测)。本项目综艺是核心场景(REQ §5) |
| — |
输出里增加 `packHint: 'single' |
'multi_episode' |
3. MetaVideo 词元解析(app/domain/meta/metavideo.py)
3.1 预处理(__init__,:163-252)
| 顺序 |
行为 |
正则或常量 |
| 1 |
isfile 且标题是 <5 位的纯数字时,直接当作集号并返回 |
:176 |
| 2 |
标题整体是 Season N 或 SN 时,只设季号并返回 |
SEASON_FULL_RE :18 |
| 3 |
去掉开头第一个 [..] 或 【..】;如果括号里是"点分隔英文名、年份、资源类型"组成的发布名,就保留内容、只去括号 |
FIRST_BRACKET_RE、BRACKET_DOT_TITLE_RE、BRACKET_RESOURCE_RE :19-24 |
| 4 |
2019-2020 这种年份范围只保留前一个年份 |
YEAR_RANGE_RE :25 |
| 5 |
删除文件大小 12.3GB |
FILE_SIZE_RE :26 |
| 6 |
删除日期 2024.05.03、2024-5-3、2024 05 03 |
DATE_RE = \d{4}[\s._-]\d{1,2}[\s._-]\d{1,2} :27 |
| 7 |
分词:以 `. 空白 ( ) [ ] - 【 】 / ~ ; & |
# _ 「 」 ~` 为分隔 |
| 8 |
逐个词元执行 __parse_token(:254),命中一个阶段就停止后续阶段 |
见 §3.2 |
| 9 |
汇总:resource_effect = 效果词按出现顺序的逆序用空格拼接;resource_type = 来源词按顺序拼接并去重 |
:213-216 |
| 10 |
BluRay 且副标题或原标题含 DIY 或 -DIY@ 时,追加 DIY |
:218-221 |
| 11 |
init_subtitle(org_string);没解析出东西再解析 subtitle |
metabase.py:244 |
| 12 |
__fix_name:用 _name_nostring_re 清除干扰词。结果是 <1800 的纯数字、且没有年份、季、分辨率、来源、编码时,把它当作集号 |
:311 |
| 13 |
英文名是拼音且没有中文名时,从副标题第一个词里取中文名(要求词数相等) |
:240-246 |
| 14 |
resource_team = ReleaseGroupsMatcher().match(原标题),customization = CustomizationMatcher().match(原标题) |
:248-250 |
3.2 词元阶段的优先级(__parse_token,:254-285)
每个词元按下表顺序尝试,第一个返回 True 的阶段结束本词元的处理。有些阶段返回 False 但会改状态(例如命中季号后设置 stop_name)。
| 序 |
阶段(函数:行) |
触发条件与规则 |
前置门槛 |
| 1 |
__init_part :435 |
`^PART[0-9ABI]{0,2}$ |
^CD\d{0,2}$ |
| 2 |
__init_name :334 |
含中文的词元作为 cn_name,后续中文只追加一次(剧场版 等可以追加,xx版/xx字幕 不追加);AKA 之后停止取名;<4 位数字或罗马数字拼进名称;4 位数字暂存为 pending_name,下一个词元不是年份时再回收进英文名;命中季、集、来源、分辨率模式时 stop_name;媒体后缀不计入名称 |
— |
| 3 |
__init_year :461 |
4 位数字且在 1900 到 2050 之间(开区间);已经有年份时,把旧年份并入名称,用新的覆盖(例如 新精武门1991 (1991)、Blade Runner 2049 2017) |
已有名称 |
| 4 |
__init_resource_pix :486 |
^[SBUHD]*(\d{3,4}[PI]+)、\d{3,4}X(\d{3,4})、^[248]+K;纯数字时补 p |
已有名称 |
| 5 |
__init_season :525 |
`S(\d{3}) |
^S(\d{1,3})$ |
| 6 |
__init_episode :578 |
`EP?(\d{2,4})$ |
^EP?(\d{1,4})$ |
| 7 |
__init_resource_type :652 |
来源:BLURAY HDTV UHDTV HDDVD WEBRIP DVDRIP BDRIP BLU WEB BD HDRip REMUX UHD,WEB+DL 合成 WEB-DL,BLU+RAY 合成 BluRay;效果:SDR HDR\d* HDRVIVID DOLBY DOVI DV 3D REPACK HLG HDR10+/Plus/P VIVID EDR HQ |
已有名称 |
| 8 |
__init_web_source :688 |
流媒体平台码(streamingplatform.py 里约 270 条,如 NF→Netflix、MAX→Max),单个词元或与相邻词元合并匹配;前后各 1 到 2 个词元内必须有 WEB/DL/WEBDL/WEBRIP,否则不算 |
已有名称 |
| 9 |
__init_video_encode :746 |
H26[45] x26[45] AVC HEVC VC\d MPEG\d Xvid DivX AV1 AVS+/2/3;H/X 加下一个 264/265 拼接;10BIT 追加为 x265 10bit |
已有名称,并有 year、分辨率、来源、季、集中至少一项 |
| 10 |
__init_video_bit :796 |
`(8 |
10 |
| 11 |
__init_audio_encode :817 |
DTS DTSHD DTSHDMA Atmos TrueHD AC3 EAC3 DDP DD+ DD LPCM AAC FLAC MA HR Opus Vorbis AV3A AVSA \dAudios;后跟数字时拼成 DDP 5.1、TrueHD 7.1;DTS 用 - 连接(DTS-HD MA 6.1) |
同上 |
| 12 |
__init_fps :853 |
(\d{2,3})(?=FPS),结果只存数值 |
已有名称 |
3.3 edition 的含义(必读)
metabase.py:580 edition = resource_type + resource_effect,不含分辨率(带分辨率的是 resource_term,:566)。例如 UHD BluRay REMUX HDR DV。
上游没有"剪辑版本"字段:Extended、Extended Version、UNCUT、Director's Cut、IMAX 等都在 _name_nostring_re 里被当作干扰词删掉,或者直接不识别(M-08 实测:Extended.Edition 完全消失)。
| 保留 |
调整 |
原因 |
来源和效果词表、WEB-DL/BluRay 合成、DIY 追加、效果按出现逆序拼接(为了输出结果逐字一致) |
新增 cut 字段(extended/directors/uncut/imax/theatrical/remastered/criterion),从原标题独立提取;edition 只作为展示字段 |
REQ §6.1 双版本和收录目标需要区分剪辑版本;不能因为干扰词清理把这个信息丢掉 |
3.4 MetaAnime(app/domain/meta/metaanime.py:53)
| 行为 |
说明 |
用第三方库 anitopy 解析,没取到标题就尝试加 [ANIME] 前缀,再不行取第一个 [..] 的内容 |
需要 anitopy 等价实现 |
名称按 / 拆中英文,中文在哪一侧就取哪一侧 |
实测 葬送的芙莉莲 / Sousou no Frieren 两边都取到了 |
| 发布组由 ReleaseGroupsMatcher 统一匹配(内置 anime 组表) |
实测 喵萌奶茶屋@LoliHouse |
| 季号没写时保持 None(集号有值) |
实测 Frieren - 13 得到 begin_season=None。后续 TMDB 按 season_seq 取值,默认按 1 处理 |
TS 对应:packages/domain/src/title/anime.ts。首期可以只实现 is_anime 的判定、方括号分段、- NN 集号和 [01-28] 区间,完整的 anitopy 放到 M2。npm 上不引入 GPL 依赖;anitopy 本身是 MPL-2.0,如果采用需要在 ledger 里登记。
3.5 副标题季集(metabase.py:244 init_subtitle)
| 模式 |
正则(行) |
结果 |
陷阱 |
Episode 12 |
TITLE_EPISODE_RE :17 |
begin_episode |
≥10000 时忽略 |
全3季、共3季 |
SUBTITLE_SEASON_ALL_RE :20 |
begin=1,end=total=3 |
只在季号和集号都为空时生效,并且生效后直接 return |
第二季、第1-3季 |
SUBTITLE_SEASON_RE :19 |
begin/end_season,中文数字经 cn2an 转换 |
有负向前瞻 (?!\s*[全共]),所以**第八季 全12期 这类写法季号会丢**(M-13 实测) |
第1-10集 |
SUBTITLE_EPISODE_BETWEEN_RE :22 |
begin/end_episode |
— |
第5集、第5期、第5话 |
SUBTITLE_EPISODE_RE :21 |
begin_episode |
期 和 集 同等对待,都会成为 episode 号 |
全39集、39集全 |
SUBTITLE_EPISODE_ALL_RE :27 |
只设 total_episode,begin 仍为空 |
这是整季包的信号,但没有区间 |
01-26 Fin、[01-38 END]、完结 |
SUBTITLE_EPISODE_RANGE_FIN_RE :33 |
begin/end,total=end |
两个数都在 1900 到 2155 之间时当作年份,不当作集号 |
4. 识别词、集号偏移与 FormatParser
4.1 自定义识别词(app/domain/meta/words.py:100 WordsMatcher.prepare)
| 语法 |
解析(:141 __parse_word) |
行为 |
屏蔽词 |
block |
正则替换为空 |
被替换 => 替换 |
replace |
正则替换(regex 库,允许变长后顾) |
前定位 <> 后定位 >> EP-12 |
offset |
前定位词和后定位词都必须命中;找出二者之间所有 [0-9一二…十]+,按算术白名单计算(_calculate_episode_offset :54,仅允许 + - * / // % ** 和括号,EP 是占位符);保留前导零宽度,中文数字用 cn2an 写回;向前偏移时按升序替换,向后偏移时按降序替换,避免连锁覆盖 |
A => B && 前 <> 后 >> EP-12 |
replace_and_offset |
替换成功后才做偏移 |
# 开头 |
注释 |
跳过 |
生效的规则写入 meta.apply_words;有生效规则时,再用原标题解析一次,得到 original_name(metainfo.py:360)。
实测陷阱:鬼灭之刃 => 鬼灭之刃 第四季 与 第 <> 集 >> EP-44 叠加时,偏移正则会把"第四季"里的"四"也算进去,季号最终丢失(W-02)。偏移作用于前后定位词之间的所有数字。
4.2 整理时的集数定位(app/application/formatting.py:84 FormatParser)
| 参数 |
含义 |
eformat |
集数定位模板,例如 [LoliHouse] Frieren - {ep} [1080p].mkv,编译成带命名组 ep 的正则 |
details |
指定集数:5、1,12、3-4 |
offset |
-12、+1、EP*2,自动补成 EP±n |
part |
强制 part |
split_episode(:170):有 details 时直接返回;没有模板时只对 meta 的 begin/end 做偏移;有模板时从文件名提取后再偏移。上游用 eval() 计算偏移表达式(:185,:193,:197,:203)。
调用处 app/chain/transfer/request.py:108 _apply_meta_overrides:先覆盖手动季号,再覆盖集号和 part。注释特别说明,_build_path_meta 已经应用过一次覆盖,不要重复偏移(:69-72)。
TS 对应:packages/domain/src/title/custom-words.ts、packages/domain/src/title/episode-format.ts
| 保留 |
调整 |
原因 |
| 四种语法、偏移的方向排序、零宽度保留、偏移只执行一次 |
① 表达式求值禁止 eval,一律走白名单解析器;② 识别词是版本化的规则(REQ §5.3),需要 policy_revision 和回放样本,不能用全局单例配置;③ 偏移只作用于前后定位词之间、离后定位词最近的一个数字(可配置),不是所有数字 |
安全问题;REQ §5.3 要求规则可回放、可回滚;W-02 实测的误伤 |
| 正则引擎差异 |
Python regex 支持变长后顾 (?<=%s.*?),JS 的 RegExp 支持后顾,但性能和语义需要逐条测试;用户规则进库前先编译校验,并设超时保护(例如 re2/node-re2 或长度限制) |
防止 ReDoS |
5. 发布组与自定义占位符
| 上游 |
行为 |
本项目 |
app/domain/meta/releasegroup.py:26 RELEASE_GROUPS |
按站点分组的内置正则表(mteam: `MTeam(?:TV |
)、MPAD、MWeb;audiences: Audies、AD(?:Audio |
:117 __get_groups_re |
`(?<=[-@[£【&])(?:groups)(?=$ |
[@.\s][】&])`,不区分大小写 |
:127 match |
所有匹配去重后用 @ 连接 |
保留,输出改为数组 |
| 限制 |
组名不在表里时 resource_team=None(M-01 FraMeSToR、M-15 VARYG 实测都识别不到) |
增加兜底:取标题末尾 -GROUP(后缀剥离后),输出 `releaseGroup: { name, source: 'known' |
app/domain/meta/customization.py:50 match |
用户占位符正则,按配置顺序去重后连接 |
M2 再做;首期不实现 |
6. 识别编排(chain 层)
6.1 MediaChain 入口
| 上游 |
输入 → 输出 |
关键分支 |
app/chain/media/recognition.py:201 recognize_by_meta |
MetaBase → MediaInfo 或 None |
经 select_recognize_source 选"原生优先"或"插件优先"(配置项);成功后按需 obtain_images |
app/chain/media/path.py:466 recognize_by_path |
路径 → Context(meta, mediainfo) |
音乐另走一条路;影视走 MetaInfoPath 加 _recognize_with_fallback_by_meta |
app/chain/_recognition.py:254 _build_recognition_plan |
meta、来源、id → plan |
请求级 media_id 优先;没有时,meta 自带同源 id 就用它;mtype 缺省且没有身份时用 meta.type;只给 id 不给 source 属于无效请求 |
app/chain/_recognition.py:395 _recognition_steps |
plan → 生成器状态机 |
NATIVE(TMDB 模块)→ SUPPLEMENT(插件补充,_supplement_media_recognize :745)→ 有身份时 REPORT(上报共享);没有身份且允许共享时 QUERY_SHARE → 再 NATIVE → UPDATE_CACHE(回填本地正缓存) |
app/chain/media/plugin.py:343 recognize_help |
标题 → 辅助识别 |
发送 NameRecognize 事件,插件(如 LLM)返回 {name, year, season, episode};和原识别一致时不重识别(_prepare_video_help_meta :323);否则改写 meta 后再走一次 recognize_media |
6.2 整理时的识别优先级(app/chain/transfer/execution.py:320-373)
| 条件 |
行为 |
| 任务已经带 mediainfo(手动指定) |
直接使用 |
有下载历史(按 hash、文件全路径、父目录或 savepath 回查,records.py:131 _resolve_download_history),且历史里有 media_source/media_id,且电影年份不冲突(_is_movie_year_conflict) |
用历史身份做显式识别;沿用历史的 media_category |
| 下载历史的年份和文件年份冲突 |
按文件名重新识别,并记录日志 |
| 没有历史 |
recognize_by_meta(file_meta) |
TS 对应:packages/tasks/src/resolve-observation.ts 和 packages/tasks/src/verify-and-import.ts 负责编排;纯判定逻辑放在 packages/domain/src/identity/resolve-plan.ts
| 保留 |
调整 |
原因 |
| "下载时的身份优先于下载后重新识别",以及年份冲突时回退 |
下载历史换成 download_task → acquisition_plan → media_entity(UUID) 的强绑定;年份冲突不再静默回退,而是生成 decision(待处理),因为下载时的绑定可能是用户确认过的 |
REQ §7.2、§11.3 |
| 辅助识别事件 |
由 Pi Agent 的有界任务 identity.resolve 代替(REQ §10),输出结构和 {name, year, season, episode} 兼容,需要在授权范围内,并且写入 agent_run |
Agent 优先 |
| 共享识别(MP 社区服务上报和查询) |
不迁移 |
外部服务,隐私和可控性都有问题 |
7. TMDB 匹配策略
7.1 模块级计划(app/modules/themoviedb/__init__.py)
| 函数:行 |
行为 |
_build_recognize_plan :217 |
MUSIC 或非 TMDB 来源时返回 None;media_id 必须是数字 |
_validate_recognize_params :190 |
只有 meta、没有 id,且全局 RECOGNIZE_SOURCE 不是 TMDB 时不识别;meta 没有 name 时不识别 |
_prepare_search_names :280 |
查询名的顺序:cn_name → 简体化的 cn_name(zhconv)→ en_name,去重去空,找到第一个结果就停 |
_build_match_plan :537 |
① 类型未知且没有年份时只做 match_multi;② TV 时先按 (name, year, TV, season_year=year, season_number=begin_season, 剧集组) 匹配,再按 (name, TV)(不带年份);③ 其余情况(电影或未知但有年份)依次试电影带年份、TV 带年份、match_multi |
_get_info_by_tmdbid :446 |
只有 id 没有类型时,电影和 TV 各查一次;两边都有结果时用 _disambiguate_by_meta(:483,标题包含关系 +2 分,年份相等 +1 分,平分时看 meta.type),仍无法判断就返回 None;每一路的连接错误分开记录,两路都无结果且至少一路是连接错误时抛出 TMDbConnectionError,不把它当成"不存在" |
_recognize_steps :744 |
先读缓存;命中且有 title 时按缓存里的 id 重新拉详情(缓存只存身份,不存详情);结果没有 genres 时补一次详情 |
_build_media_info_base :704 |
按 category.py 的规则计算二级分类,构造 MediaInfo |
7.2 客户端匹配(app/modules/themoviedb/tmdbapi.py)
| 函数:行 |
规则 |
_generate_year_range :200 |
年份容差:依次试 [year, year+1, year-1];没有年份时只试 [None] |
_build_match_plan :211 |
电影:对每个年份做 MOVIE_NAME 查询;TV:有 season_year 和 season_number 时先做 TV_SEASON,再对每个年份做 TV_NAME |
_search_kwargs :149 |
电影传 year,TV 传 release_year(对应 TMDB 的 first_air_date_year) |
_build_name_candidate_plan :395 |
结果按上映或首播日期降序排列;有年份时只保留日期前 4 位等于该年份的候选;direct = 第一个 title 或 original_title(TV 为 name 或 original_name)规范化后完全相等的候选 |
__compare_names :130 |
规范化方法:remove_punctuation 后转大写,然后精确相等。没有模糊匹配 |
_candidate_resolution :437 |
没有 direct 时,依次对每个候选拉详情(get_info,含 alternative_titles 和 translations),用 names(__get_names :513:别名加各语言译名)精确比较,第一个命中的胜出;都不命中时返回 {} |
__search_tv_by_season :713 |
搜索时不带年份;direct 要求标题相等且首播年等于 season_year;别名回退时要求标题或别名相等,并且首播年相等,或者该季的 air_date 年份等于 season_year(_season_year_matches :628,有剧集组时看组内对应 order 的第一集 air_date) |
match_multi :746 |
multi 搜索只保留 movie 和 tv,排序键是 ("1" if movie else "0") + date 降序(电影排前面),然后走同样的标题优先、别名回退 |
__get_movie_detail :1046 |
append_to_response=images,credits,alternative_titles,translations,release_dates,external_ids |
__update_tmdbinfo_cn_title :961 |
标题不含中文时,取 alternative_titles 里 iso_3166_1=CN、含中文且已是简体的别名;没有就用新加坡译名 sg_title |
__update_tmdbinfo_extra_title :1008 |
原语言是 en 时 en_title=original,否则取 US 译名;另外生成 hk_title/tw_title/sg_title |
TS 对应:packages/adapters/src/tmdb/client.ts(HTTP、限流、重试、语言参数),packages/domain/src/identity/tmdb-match.ts(纯函数:候选排序、年份过滤、标题规范化、消歧)
| 保留 |
调整 |
原因 |
| 查询名顺序、按类型分步计划、年份容差 ±1、标题精确匹配优先、别名回退、季年份匹配、无类型 id 的双查加消歧、连接失败和不存在分开 |
① 所有 direct 或 alias 结果只作为候选,按证据打分输出 candidates[];分数够高并且只有一个候选时才自动绑定,否则进入待识别池(REQ §7.5);② 别名回退要限制拉详情的次数(例如前 5 个),避免上游那样对每个候选都请求一次;③ 已经存在本地实体时,先查 title_alias(pg_trgm)召回本地实体,再查 TMDB;④ TMDB 缺失或失败不阻断有本地证据的入库(REQ §14.1.3) |
本地 UUID 优先;控制成本;REQ 要求不因外部服务阻断 |
| 中文标题补全 |
放进元数据协调层(REQ §7.3 字段级来源),不改变路径绑定 |
REQ §7.1 |
8. 识别缓存(app/modules/themoviedb/cache.py)
| 项 |
上游行为(行) |
| 键 |
__get_key :143:[类型][TMDB_LOCALE]{tmdb_id 或 meta.name}-{year}-{begin_season} |
| 值 |
{id, type, year, title, poster_path, backdrop_path},只有身份没有详情 |
| 正缓存 TTL |
META_CACHE_EXPIRE,默认 24h(大内存模式 72h),容量 256 或 1024(app/runtime/config.py:1157-1184) |
| 负缓存 |
update(meta, {}) 写入 {id: 0},TTL 取 EMPTY_RESULT_CACHE_TTL,默认 30 分钟(config.py:290);info is None(网络错误)不写缓存 |
| 冲突保护 |
__is_type_conflicted :151:键类型是 TV 而结果是电影时拒绝写入(反过来允许,因为电影分支查不到时会回退到 TV) |
| 持久化 |
非 Redis 时 pickle 到文件,带版本号和每条的过期时间;save() 只保存正缓存 |
| 共享回填 |
update_recognize_cache (__init__.py:1044):共享识别成功后覆盖负缓存 |
| 旁路 |
recognize_media(cache=False) 通过 fresh() 上下文让下游 HTTP 缓存失效 |
TS 对应:packages/db 的 recognition_cache 表(或者直接复用 observation 的解析结果),packages/adapters/src/tmdb/cache.ts
| 保留 |
调整 |
原因 |
| 键的组成(类型、语言、名称或 id、年份、季)、正负缓存 TTL 分开、网络错误不缓存、TV 键拒存电影 |
① 缓存存 PG,带 parser_version;解析器升级后,旧键自动失效;② 人工纠错写 identity_revision 并锁定,不依赖缓存 TTL(REQ §5.3 "人工纠错的持续效力");③ TMDB 详情另外做 HTTP 层缓存(ETag 和 TTL),和身份缓存分开 |
人工锁定不能被缓存过期冲掉;PG 是唯一状态源(REQ §9.3) |
9. 验证样本(实测,Python 参考路径,e6820a8)
字段缩写:T=type,cn/en=名称,y=year,S=begin_season(-end),E=begin_episode(-end),tE=total_episode,rt=resource_type,fx=resource_effect,px=pix,grp=resource_team,web=web_source,v=video_encode,a=audio_encode。
9.1 发布标题(MetaInfo)
| # |
输入 |
实测输出 |
覆盖分支 |
TS 期望 |
| M-01 |
Oppenheimer.2023.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR |
T=未知 en=Oppenheimer y=2023 rt=UHD BluRay REMUX fx=HDR DV px=2160p v=HEVC a=TrueHD 7.1 Atmos grp=None |
效果逆序、未知发布组 |
同上,但 grp=FraMeSToR(suffix) |
| M-02 |
奥本海默 Oppenheimer 2023 2160p UHD Blu-ray HEVC DV TrueHD 7.1-MTeam |
cn=奥本海默 en=Oppenheimer rt=UHD BluRay fx=DV grp=MTeam |
中英双名、Blu-ray 合成 |
一致 |
| M-03 |
The.Last.of.Us.S01.2023.1080p.WEB-DL.H264.DDP5.1.Atmos-ADWeb |
T=TV S=1 E=空 y=2023 a=DDP 5.1 Atmos grp=ADWeb |
整季包 |
一致;packHint=season_pack |
| M-04 |
The.Last.of.Us.S01E03.Long.Long.Time.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX |
S=1 E=3 web=Max v=H265 fx=HDR DV en=The Last Of Us(集名被丢弃) |
流媒体平台需要相邻的 WEB、H.265 拼接 |
一致 |
| M-05 |
Breaking.Bad.S01-S05.Complete.1080p.BluRay.x264-ROVERS |
S=1-5 total_season=5 |
多季包 |
packHint=multi_season_pack |
| M-06 |
庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 60fps DDP5.1-HHWEB |
cn=庆余年 en=Joy Of Life S=2 fps=60 grp=HHWEB |
中文季词、fps |
一致 |
| M-07 |
庆余年.Joy.of.Life.S02E01-E06.2024.2160p.WEB-DL.H265.AAC-ADWeb |
S=2 E=1-6 tE=6 |
非文件的集区间 |
packHint=multi_episode |
| M-08 |
The.Lord.of.the.Rings.The.Fellowship.of.the.Ring.2001.Extended.Edition.2160p.UHD.BluRay.x265.10bit.HDR.DTS-HD.MA.6.1-CHD |
Extended.Edition 消失;v=x265 10bit a=DTS-HD MA 6.1 grp=CHD |
剪辑版本丢失、DTS 连接 |
cut=extended(新增) |
| M-09 |
Kill.Bill.Vol.1.2003.Part.1.1080p.BluRay.x264-CHD |
en=Kill Bill Vol 1 part=Part1 |
part 拼接 |
一致 |
| M-10 |
Avatar.The.Way.of.Water.2022.2160p.WEB-DL.DDP5.1.Atmos.HDR10Plus.HEVC-CMCT |
fx=HDR10Plus grp=CMCT |
HDR10+ |
一致 |
| M-11 |
奔跑吧 第十二季 20240426 第1期 1080p WEB-DL H264 AAC-HHWEB |
S=12 E=1(第1期 当作集号),20240426 被忽略 |
综艺期号 |
期号记为 issue=1,airDate=2024-04-26,episode 待映射 |
| M-12 |
奔跑吧.Keep.Running.S12.2024.04.26.1080p.WEB-DL.H264.AAC-HHWEB |
S=12 E=空,日期被 DATE_RE 删除,y=空 |
综艺单期会被当成整季包 |
airDate=2024-04-26,packHint=single,绝不能判为整季 |
| M-13 |
向往的生活 第八季 全12期 2024 1080p WEB-DL |
S=空,tE=12 |
第八季 全 的负向前瞻导致季号丢失 |
S=8,tE=12 |
| M-14 |
[喵萌奶茶屋&LoliHouse] 葬送的芙莉莲 / Sousou no Frieren - 12 [WebRip 1080p HEVC-10bit AAC][简繁日内封字幕] |
MetaAnime:cn=葬送的芙莉莲 en=Sousou No Frieren E=12 S=空 grp=喵萌奶茶屋@LoliHouse video_bit=10bit rt=空 |
动漫分支 |
一致(S 按 1 推定并标记 inferred) |
| M-15 |
Frieren.S01E12.1080p.NF.WEB-DL.DDP2.0.H.264-VARYG |
web=Netflix a=DDP 2.0 grp=None |
NF 平台 |
grp=VARYG(suffix) |
| M-16 |
主:黑暗荣耀 The Glory 2022 S01 Complete 1080p NF WEB-DL x264 DDP5.1-HHWEB;副:黑暗荣耀 第一季 全8集 宋慧乔 |
S=1 tE=8,E 为空 |
副标题"全N集" |
一致;整季包加上预期集数 8 |
| M-17 |
Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-MTeam |
en=Dune Part Two(Part 属于片名)a=Atmos |
Part 门槛(需要先有 year 等) |
一致 |
| M-18 |
Some.Show.S02E05E06.1080p.WEB-DL-GRP |
S=2 E=5-6 |
双集 |
一致 |
| M-19 |
狂飙 The Knockout 2023 全39集 1080p WEB-DL H264 AAC-OurTV |
T=TV S=空 tE=39 |
无季号的全集包 |
S 推定为 1,packHint=season_pack |
| M-20 |
Some.Show.2019.1080p.WEB-DL-GRP,副:第1-10集 全10集 |
E=1-10 tE=10 |
副标题集区间 |
一致 |
| M-21 |
[Nekomoe kissaten][Oshi no Ko][01-11 Fin][1080p] |
S=1 E=1-11 grp=Nekomoe kissaten |
Fin 区间 |
一致 |
| M-22 |
Show.Name.2019-2020.1080p.WEB-DL |
y=2019,没有集号 |
年份范围 |
一致 |
9.2 路径(MetaInfoPath)
| # |
路径 |
实测输出 |
覆盖分支 |
TS 期望 |
| P-01 |
/downloads/The.Last.of.Us.S01.2023.1080p.WEB-DL…-ADWeb/The.Last.of.Us.S01E03.1080p.WEB-DL…-ADWeb.mkv |
S=1 E=3 y=2023(year 从目录补入) |
文件优先、目录补空 |
一致 |
| P-02 |
/downloads/庆余年 第二季 Joy of Life S02 2024 2160p WEB-DL H265 DDP5.1-HHWEB/01.mp4 |
名称、S=2、y 来自目录,E=1 |
纯数字文件名 |
一致 |
| P-03 |
同一目录下的 第02集.mkv |
E=2,其余来自目录 |
中文集号文件名 |
一致 |
| P-04 |
/downloads/Breaking.Bad.S01-S05.1080p.BluRay.x264-ROVERS/S03/07.mkv |
名称来自祖父目录,S=3 来自父目录,E=7 |
两级父目录 |
一致 |
| P-05 |
…/Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR/Featurettes/Making.Of.mkv |
en=Making Of y=2023(附加内容被识别成名为 Making Of 的电影) |
附加内容没有特殊处理 |
由 Featurettes/Extras/SP/花絮 目录规则判为 extra,归属父电影(REQ §5.5) |
| P-06 |
…/Oppenheimer.2023.1080p.BluRay.x264-CHD/Oppenheimer.2023.1080p.BluRay.x264-CHD.chs.srt |
与视频相同的 meta |
字幕后缀也被当作 isfile |
字幕由附属文件配对决定归属,不单独识别 |
| P-07 |
/downloads/The Glory 2022 S01 1080p NF WEB-DL-HHWEB/简体.srt |
en=Downloads(辅助词清空名称后,父目录是 TV 不合并,结果取到了根目录名) |
merge 条件的缺陷 |
按附属文件处理,不产生身份 |
| P-08 |
/downloads/奔跑吧 第十二季 1080p WEB-DL-HHWEB/奔跑吧.20240426.第1期.mp4 |
S=12 E=1 |
期号 |
issue=1,airDate=2024-04-26 |
| P-09 |
同一目录下的 奔跑吧.2024.05.03.mp4 |
T=未知,没有季号(日期删除后文件无类型,TV 目录不合并) |
综艺日期命名 |
T=TV S=12 airDate=2024-05-03,episode 待按日期映射 |
| P-10 |
同一目录下的 奔跑吧.20240503.加更版.mp4 |
T=未知 |
加更版 |
标记为 extra(加更),关联 airDate |
| P-11 |
/downloads/Kill.Bill.2003.1080p.BluRay.x264-CHD/CD1.mkv |
名称和年份来自目录,part 丢失(CD1 这个词元出现时还没有名称,Part 门槛不满足) |
分盘 |
part=CD1 |
| P-12 |
/media/tv/庆余年 (2019)/Season 2/庆余年 - S02E03 - 第 3 集.mkv |
S=2 E=3 y=2019 |
媒体库目录回扫 |
一致(旧库登记) |
| P-13 |
…/[LoliHouse] Sousou no Frieren [01-28][WebRip…]/[LoliHouse] Sousou no Frieren - 13 [WebRip…].mkv |
E=13 S=空 |
动漫文件 |
一致 |
9.3 显式标签与识别词
| # |
输入 |
实测输出 |
| W-01 |
House.of.the.Dragon.S01E03.1080p [tmdbid=94997].mkv |
media_source=themoviedb media_id=94997 S=1 E=3 |
| W-02 |
{[tmdbid=94997;type=tv;s=1;e=3-4]}House.of.the.Dragon.1080p.mkv |
S=1 E=3-4,标签覆盖推断 |
| W-03 |
[Sub] Kimetsu no Yaiba - 45 [1080p].mkv + Kimetsu no Yaiba <> \[1080p\] >> EP-44 |
E=1,apply_words 记录了这条规则 |
| W-04 |
鬼灭之刃 第45集 1080p.mkv + 鬼灭之刃 => 鬼灭之刃 第四季、第 <> 集 >> EP-44 |
E=1,S=空("四"也被偏移了) |
| W-05 |
Some.Show.S01E13.1080p.WEB-DL.mkv + Some.Show.S01 => Some.Show.S02 && S02E <> .1080p >> EP-12 |
S=2 E=1 |
| W-06 |
Oppenheimer.2023.1080p.WEB-DL-FraMeSToR.mkv + 屏蔽词 FraMeSToR |
屏蔽成功,名称不变,grp=None |
parity 测试建议:packages/domain/test/title.parity.test.ts 读取 packages/testkit/fixtures/title-parity.json(输入加上本表的"TS 期望")。在 packages/testkit/scripts/ 下另外放一个可选脚本,调用本机 MP 的 Python 生成"上游实际值",只用于对比,不作为断言源。上游 tests/cases/meta.py(67 条)可以作为补充输入语料,需要先在 ledger 登记(标题字符串属于事实数据,但整份文件不直接拷贝进仓库)。
10. 刮削(NFO、图片)
按 2026-10 的实现改写;细节与排查见 docs/runbook/scrape.md。
| 上游 |
行为 |
本项目(现状) |
app/chain/scraping.py:148 ScrapingOption、:243 get_default_config |
按 (movie|tv|season|episode)_(nfo|poster|backdrop|…) 逐项配置策略 skip/missingonly/overwrite/upgrade,默认 missingonly |
没有逐项策略,只有全局覆盖方式 organize.scrapeMode:update 更新(缺省)/ missing 只补缺失 / overwrite 全部覆盖;图片与剧照各有一个开关 |
:320 _should_scrape |
skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 |
missing / overwrite 同 MP。新增 update:每次写入在 scrape_record.files[].hash 记内容 sha256,目标存在且与本工具上次写入的 hash 一致(没被改过)才用新内容覆盖,内容没变不重写;被改过的跳过(scrape_user_modified),不是本工具写的跳过(scrape_exists);旧版本没有 hash 的记录按修改时间兜底。不读 <lockdata>,也不写它 |
:356 _save_file |
先写临时文件,chmod 0666 & ~umask,再经存储链上传 |
同目录临时文件 + rename(services/scrape.ts atomicWrite),目标必须在登记目录内 |
app/modules/themoviedb/scraper.py:35 get_metadata_nfo |
电影、剧、季、集四种 NFO;标签包括 uniqueid(tmdb default, imdb, tvdb)、plot、genre、actor、director、studio、country、mpaa、rating、runtime |
packages/domain/src/scrape/nfo.ts(纯函数,与 MP 元素顺序一致,金样差分 apps/server/test/scrape/nfo-mp-diff.test.ts,偏离登记 nfo-deviations.json);uniqueid 只有 tmdb / imdb / tvdb / douban,没有 type="uhd";不写 mpaa、episodeguide、lockdata。合集 / 其他类作品按电影写(根元素 <movie>,判定函数 scrapeKindOf) |
| 季 / 集元数据 |
get_tv_season_detail 现取;集名缺时写占位 |
本地作品用投影后的季 / 集字段;单元缺元数据时按 TMDB id 现取季详情补齐,仍缺则该文件跳过(scrape_metadata_missing)、记录 partial,不写「季 N」「第 N 集」占位;没有 TMDB id 的作品(Local)无处可补,照旧写占位 |
| 元数据变化后 |
不会自动重刮 |
刷新元数据后 NFO 相关字段有变化、且作品刮削过 → 延后 2 分钟排一次 update 模式重刮(同目录排队记录合批防抖);改 TMDB 映射时先清旧作品的 tmdb 来源字段与没锁的 IMDb 映射,再 full 刷新,刷新后按同一规则重刮 |
| 失败语义 |
刮削在整理后执行,失败只记日志,不回滚转移 |
不重做搬运;刮削是独立任务 scrapeMedia(不重试,失败的重新刮削),每次一条 scrape_record(逐文件结果与原因);非写文件的意外错误记 scrape_internal_error 并保留已写文件结果 |
11. 综艺与日期编号:现状结论
| 问题 |
上游现状(实测) |
本项目要求 |
2024.05.03 或 2024-05-03 |
DATE_RE 在分词前删除,没有 air_date 字段 |
解析成 airDate,删除前先保存 |
20240503(8 位连写) |
不是年份(位数不对),也不是集号(位数 >3),被忽略 |
在 1990 到 2100 年、月日合法时识别为 airDate |
第N期 |
和 第N集 一样当作 episode |
存为 issue,和 episode 分开(REQ §5.1 "内容分类与集号分开建模") |
| 上/下、加更、会员版、纯享版 |
不识别(加更版 只进名称或被丢弃) |
识别为 segment 或 extra 标记(REQ §5.5) |
| 文件只有日期、目录是季 |
文件无类型,不与 TV 目录合并,最终识别失败 |
按 §2.3 的调整合并,进入按日期匹配 TMDB air_date 的流程;匹配不到时进入待识别池,不猜集号 |
| 季号 + 无集号 + 日期 |
被当作整季包(M-12) |
有日期就判为单期 |
12. 给 packages/domain 实现者的清单
- 解析器是纯函数,输入只有
{title, subtitle?, pathSegments?, words?, releaseGroups?, platforms?, mediaExts},不读全局配置。
- 词元阶段的优先级、"已有名称才识别"的门槛、
stop_name 语义,逐条对照 §3.2 实现,并用 §9 的样本逐字比对。
- 所有和上游不同的地方(M-01、M-08、M-11 到 M-13、M-15、P-05 到 P-11、W-04)都要在测试里显式写出
deviation: '<原因>'。
ParsedRelease 在上游字段的基础上增加:airDate、issue、cut、packHint、releaseGroup.source、evidence[]、parserVersion。
- 不引入
eval 或 new Function;用户正则进库前先校验,并设长度和时间上限。