459 lines
46 KiB
Markdown
459 lines
46 KiB
Markdown
# 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、图片)
|
||
|
||
| 上游 | 行为 | 本项目 |
|
||
| --- | --- | --- |
|
||
| `app/chain/scraping.py:148 ScrapingOption`、`:243 get_default_config` | 按 `(movie|tv|season|episode)_(nfo|poster|backdrop|logo|disc|banner|thumb|clearart|landscape)` 逐项配置策略 `skip/missingonly/overwrite/upgrade`,默认 `missingonly` | 保留"逐项策略"的结构,放在 `policy_revision` 下的目录策略中(REQ §14.1.3:"NFO/海报写入受目录策略控制") |
|
||
| `:320 _should_scrape` | skip 时不写;文件不存在时写;文件存在时,只有 overwrite 或全局 overwrite 才写 | 保留;另外增加一条:**用户或其他工具已修改的 NFO(含 `<lockdata>true` 或 hash 与上次写入不一致)不覆盖** |
|
||
| `:356 _save_file` | 先写临时文件,chmod `0666 & ~umask`,再经存储链上传 | 保留"临时文件加原子替换" |
|
||
| `app/modules/themoviedb/scraper.py:35 get_metadata_nfo` | 电影、剧、季、集四种 NFO;季和集走 `get_tv_season_detail`,有剧集组时走 `get_tv_group_detail`;标签包括 `uniqueid(tmdb default, imdb, tvdb)`、`plot`、`genre`、`actor`、`director`、`studio`、`country`、`mpaa`、`rating`、`runtime` | `packages/adapters/src/nfo/writer.ts` 输出 Kodi/Emby 兼容 NFO;**另写一个 `uniqueid type="uhd"`,内容为本地 UUID** |
|
||
| 失败语义 | 刮削在整理后执行,失败只记日志,不回滚转移 | 保留"不重做搬运";改为**独立任务 `refreshMetadata`,有自己的状态和重试**,不能伪报全部步骤成功(REQ §14.1.3) |
|
||
|
||
---
|
||
|
||
## 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`;用户正则进库前先校验,并设长度和时间上限。
|