Files
UHD_Media_Tool/docs/porting/moviepilot-metadata-recognition.md

48 KiB
Raw Permalink Blame History

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. 调用链总览

下载完成 / 手动整理
  └─ app/chain/transfer/request.py:83 _build_path_meta ──► MetaInfoPath(path, custom_words, force_video=True)
        └─ app/chain/transfer/request.py:108 _apply_meta_overrides   (手动季号 / FormatParser 集数定位与偏移)
  └─ app/chain/transfer/execution.py:320-373   (优先使用下载历史里的 media_id,否则按 meta 识别)
        ├─ MediaChain.recognize_media(media_source, media_id)       显式身份
        └─ MediaChain.recognize_by_meta(meta)                       名称识别
              └─ app/chain/media/recognition.py:232 _recognize_with_fallback_by_meta
                    └─ app/chain/media/plugin.py:252 select_recognize_source(native_fn, plugin_fn)
                          ├─ native: app/chain/_recognition.py:531 recognize_media
                          │     └─ _recognition_steps (:395) NATIVE → SUPPLEMENT(插件补充) → REPORT/QUERY_SHARE(共享识别)
                          │           └─ app/modules/themoviedb/__init__.py:928 recognize_media
                          │                 └─ _recognize_steps (:744) LOAD_CACHE → LOOKUP_IDENTITY | SEARCH_NAME → LOAD_DETAILS → SAVE_CACHE → BUILD_RESULT
                          │                       └─ app/modules/themoviedb/tmdbapi.py:546 match / :746 match_multi
                          └─ plugin: app/chain/media/plugin.py:343 recognize_help (NameRecognize 事件,辅助识别,例如 LLM 插件)
  └─ 刮削:app/chain/scraping.py:995 scrape_metadata  →  app/modules/themoviedb/scraper.py:35 get_metadata_nfo / :87 get_metadata_img

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

export interface ExplicitTags { identity?: { provider: 'tmdb'|'douban'|'bangumi'|'anilist'; id: string };
  mediaType?: 'movie'|'tv'; beginSeason?: number; endSeason?: number; beginEpisode?: number; endEpisode?: number; episodeGroup?: string }
export function extractExplicitTags(title: string): { rest: string; tags: ExplicitTags }
保留 调整 原因
四种标签写法、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

export function parseReleasePath(segments: { file: string; parent?: string; grandParent?: string },
  opts: ParseOptions): ParsedRelease  // 纯函数,不访问文件系统
export function mergeParsed(primary: ParsedRelease, fallback: ParsedRelease): ParsedRelease
保留 调整 原因
文件优先、父目录只补空、身份原子合并、最多两级父目录、纯数字文件名视为集号、文件多集区间 ≤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

export type RecognitionWord = { kind: 'block'; pattern: string } | { kind: 'replace'; from: string; to: string }
  | { kind: 'offset'; front: string; back: string; expr: string } | { kind: 'replace_offset'; from: string; to: string; front: string; back: string; expr: string }
export function applyRecognitionWords(title: string, words: RecognitionWord[]): { title: string; applied: RecognitionWord[] }
export function evalEpisodeOffset(expr: string, ep: number): number   // 自写安全解析器,禁止 eval/Function
export function splitEpisodeByTemplate(fileName: string, rule: EpisodeFormatRule, meta: ParsedRelease): { begin?: number; end?: number; part?: string }
保留 调整 原因
四种语法、偏移的方向排序、零宽度保留、偏移只执行一次 ① 表达式求值禁止 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

export type IdentityEvidence =
  | { kind: 'manual_lock'; entityId: string }                    // 最高:人工锁定
  | { kind: 'download_task'; entityId: string; slotId?: string } // 提交下载时就绑定了本地实体
  | { kind: 'explicit_tag'; provider: string; id: string }
  | { kind: 'title_parse'; parsed: ParsedRelease }
export function planIdentityResolution(evidence: IdentityEvidence[]): IdentityResolutionPlan
保留 调整 原因
"下载时的身份优先于下载后重新识别",以及年份冲突时回退 下载历史换成 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(纯函数:候选排序、年份过滤、标题规范化、消歧)

export function yearWindow(year?: number): (number | undefined)[]           // [y, y+1, y-1]
export function normalizeTitleForCompare(s: string): string                  // 去标点 + 大写
export function pickDirectCandidate(name: string, items: TmdbSearchItem[], kind: 'movie'|'tv', year?: number): TmdbSearchItem | undefined
export function aliasCandidatesOrder(items: TmdbSearchItem[], kind: 'movie'|'tv', year?: number): TmdbSearchItem[]
export function disambiguateById(tv: TmdbDetail, movie: TmdbDetail, meta: ParsedRelease): 'tv'|'movie'|'ambiguous'
export interface TmdbPort { searchMovie(q: string, year?: number): Promise<TmdbSearchItem[]>; searchTv(q: string, firstAirYear?: number): Promise<TmdbSearchItem[]>;
  searchMulti(q: string): Promise<TmdbSearchItem[]>; detail(kind: 'movie'|'tv', id: number): Promise<TmdbDetail | null> }
// packages/tasks: resolveObservation 编排 → 结果写 identity_revision
保留 调整 原因
查询名顺序、按类型分步计划、年份容差 ±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
别名与译名(__get_names 用全部 alternative_titles + 全部 translations;__update_tmdbinfo_extra_title 取 en / hk / tw / sg 译名,__update_tmdbinfo_cn_title 取 CN 地区别名) 多语言保留规则(2026-10-06 用户规则,packages/adapters/src/tmdb/language-policy.ts):译名、别名、简介、标语只留简中(zh-CN / zh-SG / zh-Hans)、繁中(zh-TW / zh-HK / zh-MO / zh-Hant)、英文(en-*)、作品原语言;alternative_titles 按地区推断语言(CN / SG 简中、TW / HK / MO 繁中、英语区英文、原产地区原语言)并校验文字,推断不出或文字不符的丢弃;原产地区的罗马字别名(type 含 roma)记 <原语言>-Latn。识别比较(details().aliases、match.ts 别名回退、tmdbToCandidate、按作品搜资源的 work-match.ts)同样只用这些 用户规则:俄 / 葡 / 越 / 西 / 泰等与作品无关的语言别名只会制造误匹配和噪声;MP 的 en / hk / tw / sg / CN 取值都在保留范围内

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 实现者的清单

  1. 解析器是纯函数,输入只有 {title, subtitle?, pathSegments?, words?, releaseGroups?, platforms?, mediaExts},不读全局配置。
  2. 词元阶段的优先级、"已有名称才识别"的门槛、stop_name 语义,逐条对照 §3.2 实现,并用 §9 的样本逐字比对。
  3. 所有和上游不同的地方(M-01、M-08、M-11 到 M-13、M-15、P-05 到 P-11、W-04)都要在测试里显式写出 deviation: '<原因>'。
  4. ParsedRelease 在上游字段的基础上增加:airDate、issue、cut、packHint、releaseGroup.source、evidence[]、parserVersion。
  5. 不引入 eval 或 new Function;用户正则进库前先校验,并设长度和时间上限。