Files
UHD_Media_Tool/docs/porting/moviepilot-identity-match.md
T

11 KiB
Raw Blame History

MoviePilot 身份匹配(TMDB 识别)对照与差分(ID-2)

只记录上游行为与本项目的取舍,不复制上游源码。上游快照与许可见 moviepilot-metadata-recognition.md §0(e6820a8,GPLv3)。 标题解析(MetaInfo ↔ parseRelease)的字段级差分见 moviepilot-metadata-recognition.md 与 packages/domain/test/differential/meta-*;本文只管"解析结果 → 哪个 TMDB 条目"。

1. 结论

项 值
差分集 憨憨真实标题 113 条(50 部作品):主标题 + 副标题 77 条,RSS「[分类]英文名 … -GRP[中文名 / … | 第N集 | …][体积]」21 条,短剧 15 条;同一标题只留一条
MP 侧 99 条命中 TMDB,14 条无结果(短剧)
修前(c3aaf90) 100 / 113 与 MP 一致(88.5%);13 条不一致全是我们漏:RSS 分类前缀被当成中文名(武神主宰 / 三角洲 / 掌门低调点 6 条)、副标题中文名不参与(档案、猪猪侠 5 条)、中英名各命中一部(A Prophet)、英文名只在 TMDB 别名里而旧链路不取详情(China Now)
修后 112 / 113 一致;剩 1 条是 MP 误识别(短剧《人中之龙第一季》被 MP 按译名配到《如龙》2024),我们不自动绑定。非故意偏离项一致率 100%(MP 缺陷且我们正确的计为一致)
真机误识别回归 用户栈里被「电视剧」别名并入《东北有个周东北》的资源(32 条标题 / 副标题组合,含只有英文名的变体)全部识别回各自作品或不并入
TMDB 请求 上行 191 次(预算 200),全部经本地缓存代理;测试离线回放 tmdb-fixtures.json

2. MP 的识别链(按实际调用顺序)

  1. MetaInfo(title, subtitle):副标题只取季集(init_subtitle),不取片名。RSS 标题里内嵌的描述方括号整体丢弃;开头的分类方括号 [动漫] 被 _prepare_meta_input 去掉。所以憨憨标题在 MP 里只有英文名。
  2. TheMovieDbModule._prepare_search_names:[cn_name, 简体 cn_name, en_name] 去重,逐个名称识别,首个命中即停。
  3. _build_match_plan(app/modules/themoviedb/__init__.py):
    • 剧集:match(name, year, tv, season_year=year, season_number),再退到无年份;
    • 其他:电影 + 年份 → 剧集 + 年份 → match_multi(无类型无年份时直接 multi)。
  4. TmdbApi.match(tmdbapi.py):季年份步骤优先(__search_tv_by_season:首播年 = 季年份,或详情里该季 air_date 年份 = 季年份),再按年份 [y, y+1, y-1] 逐个搜;每次搜索结果按日期降序、按年份过滤后,标题 / 原标题规范化后完全相等即命中;否则逐个拉详情用别名 + 各语言译名(__get_names)比对,首个相等即命中。
  5. match_multi:电影在前、日期降序,同样先标题后别名。

特点:没有"证据强弱",第一个名称相等的候选就是答案;中英名只按顺序尝试,不会互相佐证;通用词(Archives)与续作序号没有特别处理,靠年份过滤和"第一个"兜底。

3. 我们的链路(修后)

parseRelease → recallTmdbCandidates(packages/adapters/src/tmdb/recall.ts)→ tmdbToCandidate(sources-identity/service/tmdb-candidate.ts)→ resolveIdentity(packages/domain/src/identity/match.ts)。DEC-1 的触发(何时查 TMDB)与池 / 合并事项逻辑没改。

环节 MP 我们 性质
分类前缀 [电视剧] [音乐] … 去掉 去掉,记为 parsed.category(词表:电影 / 电视剧 / 动漫 / 综艺 / 纪录片 / 音乐 / 短剧 / 体育 / 游戏 / 软件 / 学习 / 其他) 修复(修前当成中文名)
RSS 内嵌描述 -GRP[中文名 / … | 第N集 | …][体积] 丢弃 拆出来当副标题用(补中文名、别名、季集) 增强
副标题片名 不取 第一段按 / 拆:主标题无中文名时第一个中文名作 titleCn(去季号后缀),其余进 altTitles;字幕 / 音轨 / 简介字段不算 增强(元数据差分登记为 intentional)
Travis - 12 Memories — - 12 后紧跟普通单词时不是集号 修复
搜索名称 cn → 简体 cn → en,逐个短路 cn → en,两个都搜,候选合成一个池;通用词(分类词 / 单字 / 停用词)不搜 故意偏离:收集后打分,同名多候选进池而不是取第一个
搜索语言 TMDB_LOCALE 单语言 首选语言单语言(searchRaw) 对齐(修前是 zh-CN + en-US 各搜一遍)
类型 剧集线索只搜 tv;否则 movie → tv → multi 剧集线索只搜 tv;否则 movie + tv 对齐
别名回退 标题对不上时对全部候选逐个拉详情 年份相容的候选按年份相近度取前 3 个拉详情,直接命中的前 2 个也拉(看另一种文字的名称能否佐证);详情失败保留搜索结果 故意偏离:限次
标题 vs 别名 同等对待 主标题 / 原标题 / 其他语言本地化标题 = exact;alternative_titles + translations + 副标题别名 = alias 故意偏离:别名证据弱一级
年份 年份过滤 [y, y+1, y-1],季年份优先 精确 / 季首播年 / ±1 / 未知 / 不符(不符直接拒绝) 对齐
续作 / 章节 无 Chapter III、Part Two、第3章、Ⅲ 解析成序号,候选名里有序号且不一致 → 拒绝,一致 → 加分 增强
季号 只用于季年份 明写的季号不在候选(取了详情)的季列表里 → 扣分 增强
通用词 无 单个英文词只靠别名命中、且池里 ≥3 个候选名称含该词(Archives)→ 封顶待确认;中文名命中则不受影响 增强
别名佐证 无 只靠别名命中时需要年份相容或第二种文字的名称也命中,否则封顶待确认 增强(人中之龙 → 《如龙》即此类)
多候选 取第一个 同等级时:年份精确优先 → 命中的名称文字种数(中文 / 拉丁)→ 分值;严格占优才自动确认,完全对称进池 故意偏离

3.1 评分权重表(DEFAULT_MATCH_WEIGHTS,可由 MatchOptions.weights 覆盖)

证据 分值
标题 exact / alias / partial / none 4 / 3 / 1 / 0
年份 exact / season_year / ±1 / 未知 3 / 3 / 2 / 0(不符直接拒绝)
年份未知但明写季号且类型一致 +2
第二种文字的名称也命中 +1
续作序号一致 +1(冲突直接拒绝)
明写季号不在候选季列表 −3
定级阈值 ≥7 rule_confirmed,≥5 multi_evidence,≥1 needs_confirm

硬规则优先于分值:年份不符 / 续作冲突 / 标题无关 → 拒绝;部分包含、通用词只靠别名、别名缺佐证、类型冲突 → 封顶 needs_confirm;显式 ID 提升一级。默认值下 v0.1 的判定不变(identity.test.ts 原样通过)。分值不是概率,只用于定级和同级排序。

4. 差分方法

  • 样本:packages/domain/test/differential/identity/samples.json,2026-09-29 从用户栈 source_release(憨憨)只读导出,同标题去重;非短剧全收(98 条 / 35 部),短剧取前 15 部。
  • MP 侧:gen-identity-mp.py(MP 仓库 .venv,MetaInfo 未注入 Rust 加速器,走 Python 参考实现;名称识别直接调 TheMovieDbModule._prepare_search_names + _search_by_name,不含识别缓存、插件辅助识别与详情补全),结果 mp.json。
  • 我们侧:apps/server/test/sources-identity/identity-differential.lib.ts(与生产同一组函数),identity-differential.run.ts 录制,结果 ours.json。
  • TMDB:两侧都经本地缓存代理(会话临时目录,不入库):同一请求只上行一次;搜索结果集与 language 无关,所以 zh-CN 为空时其他语言直接判空;带年份的搜索先取无年份结果,总数 ≤20 时按日期年份本地过滤;上行计数封顶。上行 191 次后改为离线,离线未命中的 13 个详情请求按"取不到详情"处理(录进 fixture 为 404,回放同样走这条分支)。
  • 修前基线:在 c3aaf90 的工作树里用旧链路(searchAll 双语言、旧打分)跑同一批样本。基线需要的 en-US 搜索由代理从 zh-CN 结果 + 已缓存详情的英文译名合成,48 个未缓存请求按空结果近似——基线数字是近似值,修后数字是实测回放。
  • 归因:deviations.json(mp_defect / intentional / known_gap);测试要求未登记的样本全部与 MP 一致。
  • 回归测试:identity-differential.test.ts(unit 组,不连 PG、不连 TMDB),含 brief 三类案例与真机误识别场景(poisoned-local.json)。

重新录制:

# 1. 起缓存代理(TMDB key 只从 .local/admin-credentials 读),见会话记录;然后
cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. TMDB_PROXY=http://127.0.0.1:18731 \
  .venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/identity/gen-identity-mp.py
TMDB_PROXY=http://127.0.0.1:18731 deno run -A apps/server/test/sources-identity/identity-differential.run.ts

5. brief 三类案例

案例 修前 修后
[动漫]Martial Master S01 2020 …[武神主宰 / … ](3 条) 分类词「动漫」当中文名,英文名在 zh-CN 结果里对不上 → 无匹配 中文名 武神主宰 精确 + 2020 精确 → rule_confirmed,2023 的同名剧年份不符被拒 → 自动确认 tv:110181
Mononoke The Movie Chapter III … 2026 生产里判歧义 候选 1322562 的英文译名精确 + 年份精确 + 第 3 章序号一致 → 自动确认;第 1 / 2 章若在池里按序号冲突拒绝
Archives S01 2009(副标题 档案 / 档案解密) 只有英文名,部分包含 → 待确认 中文名 档案 精确 + 2009 → 自动确认 tv:201401;没有副标题时 Archives 只靠别名且池里撞名 → 待确认

6. 真机误识别(2026-09-29,用户栈)

憨憨 RSS 标题带分类前缀,修前解析器把「电视剧」当中文名;第一条这样的资源经本地别名命中《东北有个周东北》后,bindAuto 把「电视剧」和各资源的英文名(Irreplaceable、Spark of the Long March、A Prophet、Spring of the Blade)都登记成站点别名,后续资源再经这些别名以"别名 + 年份精确"自动并入,链式扩散。修复:

  1. 解析:分类前缀识别为 category,不进片名。
  2. 比对:通用词(分类词 / 单字 / ≤2 个拉丁字符 / 纯数字 / 停用词)不作为查询名,也不作为候选别名参与比对;只靠别名命中需要年份相容或第二种文字的名称佐证。
  3. 登记:自动绑定只在标题精确命中(或别名命中且有第二种文字佐证)时登记站点别名,通用词不登记。
  4. 标题更新:快照 upsert 在标题 / 副标题变化时重算 parsed 并发 release.observed(isNew=false);Feed 标题修正同样;路由对 isNew=false 带 force 重新识别(触发来源仍是 observed,DEC-1 不变),重新识别后不能确认的撤销原自动绑定。
  5. 存量:scripts/identity-reevaluate.ts --rollback-wrong(见 docs/runbook/identity.md)。在用户库副本上演练:解除 23 条误并入、删除 7 个站点别名(「电视剧」「动漫」「综艺」与 4 个链式英文名),资源退回池并排入立即复查。

7. 已知差距

  • 别名回退限次(前 3 + 直接命中前 2):MP 会把全部候选的详情拉一遍。候选多且目标排得靠后时,我们可能拿不到别名证据而进池(不会误绑)。
  • 同名多候选我们进池,MP 取日期最新的一个:MP 在这类场景"答对"时算我们漏。本批样本未出现。
  • 剧集组(episode group)季年份未迁移(同 match.ts 头注释)。
  • 副标题片名只取第一段;第一段是简介句子的站点(非憨憨格式)会被 cnTitleFromSubtitle 的长度 / 空白 / 句读守卫挡掉,但守卫是启发式。