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

463 lines
47 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 标题识别与刮削链路:迁移对照
> 格式遵循 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`
```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`
```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'|'season_pack'|'multi_season_pack'|'unknown'` | 把上游的隐含约定变成显式字段,方便 Slot 满足度和整理计划使用 |
---
## 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 | 分词:以 `. 空白 ( ) [ ] - 【 】 / ~ ; & | # _ 「 」 ~` 为分隔 | `app/domain/tokens.py Tokens.load_text` |
| 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}$|^DVD..|^DISK..|^DISC..`;下一个词元是 1 位数字、`0x`、A/B/C/I/II/III 时拼接上去;单独一个 `PART` 最后会清空 | 已有名称,并且已有 year、季、集、分辨率、来源中至少一项 |
| 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})$|S(\d{1,3})E`;`Season` 后跟 <3 位数字;**第二个更大的季号成为 end_season(S01-S05)**;文件时季区间 >1 就丢弃 end;TV 类型但没有季号时默认 begin_season=1 | — |
| 6 | `__init_episode` :578 | `EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})`;紧跟在集号后面的更大数字作为 end;2 到 3 位的裸数字,前一个词元不是年份或编码、也不是 pending_name 时视为集号;`EPISODE` 后跟 <5 位数字 | — |
| 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|12|16)[\s._-]*bits?` | 同上 |
| 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`
```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|E(?:book|)|Music|Web)`;hhanclub: `HHWEB`;chdbits: `CHD(?:Bits|PAD|(?:|HK)TV|WEB|)`… 以及 `others`、`anime`)加上用户自定义 | **不照抄这张表**。在 `packages/domain/src/title/release-groups.ts` 里保留"站点 → 组名"的数据结构,用我们自己维护的种子数据,并支持在设置表里扩展 |
| `:117 __get_groups_re` | `(?<=[-@\[£【&])(?:groups)(?=$|[@.\s\]\[】&])`,不区分大小写 | 保留前后界定符语义 |
| `:127 match` | 所有匹配去重后用 `@` 连接 | 保留,输出改为数组 |
| **限制** | 组名不在表里时 `resource_team=None`(M-01 `FraMeSToR`、M-15 `VARYG` 实测都识别不到) | **增加兜底**:取标题末尾 `-GROUP`(后缀剥离后),输出 `releaseGroup: { name, source: 'known'|'suffix' }`。REQ §4.5 要求"发布组偏好与站点身份分开",偏好规则需要看到未知组 |
| `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`
```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`(纯函数:候选排序、年份过滤、标题规范化、消歧)
```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 |
---
## 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`;用户正则进库前先校验,并设长度和时间上限。