From a6d655d2ce844aacdbc21b4a9a68306faf6d2c70 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 1 Oct 2026 00:15:20 +0800 Subject: [PATCH] feat(identity): trust site-provided IMDb/TMDB IDs in scoring (ID-3) MatchOptions.trustedIds: a candidate fetched by a site-given ID is at least multi_evidence even when titles differ; year/type conflicts stay candidates. Differential group site-ids-* against MP search/result.py _match_source. Co-Authored-By: Claude Opus 5.5 --- packages/domain/src/identity/match.ts | 20 ++++ .../differential/identity/gen-site-ids-mp.py | 62 ++++++++++++ .../identity/site-ids-deviations.json | 13 +++ .../differential/identity/site-ids-mp.json | 27 +++++ .../identity/site-ids-samples.json | 97 ++++++++++++++++++ packages/domain/test/identity-id3.test.ts | 98 +++++++++++++++++++ 6 files changed, 317 insertions(+) create mode 100644 packages/domain/test/differential/identity/gen-site-ids-mp.py create mode 100644 packages/domain/test/differential/identity/site-ids-deviations.json create mode 100644 packages/domain/test/differential/identity/site-ids-mp.json create mode 100644 packages/domain/test/differential/identity/site-ids-samples.json create mode 100644 packages/domain/test/identity-id3.test.ts diff --git a/packages/domain/src/identity/match.ts b/packages/domain/src/identity/match.ts index 97f9184d..25097e61 100644 --- a/packages/domain/src/identity/match.ts +++ b/packages/domain/src/identity/match.ts @@ -86,6 +86,13 @@ export interface MatchOptions { * rankCandidates / resolveIdentity 按池自动计算;单独调用 scoreCandidate 时可传入。 */ genericNames?: ReadonlySet; + /** + * ID-3:站点列表 / 详情页给出的外部 ID(IMDb / TMDB)。候选按这些 ID 取来(或已登记同一 ID)时视为同一作品的强证据: + * 标题对不上也至少 multi_evidence(可自动确认);年份冲突降为 needs_confirm(ID 可能挂错,只作候选); + * 续作序号冲突 / 类型冲突不放宽。对照 MP search/result.py `_match_source`:种子的 IMDb ID 与作品一致即判为命中,不再比标题。 + * 标题里的 `[tmdbid=…]` 不算(仍按 explicit_id 只提升一级)。 + */ + trustedIds?: readonly { provider: string; id: string }[]; } /** 池里撞名的阈值:单个拉丁词被 ≥ 3 个候选的名称包含 */ @@ -293,6 +300,19 @@ export function scoreCandidate(p: ParsedRelease, c: IdentityCandidate, opts: Mat evidence.push("type_conflict"); } if (explicit && level !== "rejected" && tm !== "none") level = raise(level); + const trusted = + opts.trustedIds?.some( + (x) => + (x.provider === c.provider && x.id === c.externalId) || + c.linkedExternalIds?.some((l) => l.provider === x.provider && l.id === x.id), + ) ?? false; + if (trusted && sequel !== "conflict" && ty !== "mismatch") { + evidence.push("site_id"); + if (ym === "mismatch") { + level = "needs_confirm"; + evidence.push("site_id_year_conflict"); + } else if (LEVEL_RANK[level] < LEVEL_RANK.multi_evidence) level = "multi_evidence"; + } return { candidate: c, level, diff --git a/packages/domain/test/differential/identity/gen-site-ids-mp.py b/packages/domain/test/differential/identity/gen-site-ids-mp.py new file mode 100644 index 00000000..bc7237c8 --- /dev/null +++ b/packages/domain/test/differential/identity/gen-site-ids-mp.py @@ -0,0 +1,62 @@ +"""身份匹配差分(ID-3 站点外部 ID 组):用 MP 上游 search/result.py `_match_source` 生成 site-ids-mp.json。 + +用法(MP 仓库根目录,用其 .venv;不访问网络): + cd /opt/moviepilot-plus/upstream/MoviePilot && PYTHONPATH=. \ + .venv/bin/python /opt/uhd_media_tool/packages/domain/test/differential/identity/gen-site-ids-mp.py + +每条样本 = 站点种子(标题 / 副标题 / 站点给出的 IMDb ID)+ 目标作品(TMDB 标题 / 年份 / 类型 / IMDb ID)。 +记录 MP `_match_source` 的判定:种子 IMDb 与作品一致时返回 "imdb"(不再比标题),否则走标题匹配。 +只记录行为输出,不复制 MP 源码。 +""" + +import json +import pathlib +import subprocess + +from app.chain.search.result import _match_source +from app.domain.context import MediaInfo, TorrentInfo +from app.domain.metainfo import MetaInfo +from app.schemas.types import MediaSource, MediaType + +HERE = pathlib.Path(__file__).parent +samples = json.loads((HERE / "site-ids-samples.json").read_text(encoding="utf-8"))["samples"] + + +def run(s: dict) -> dict: + t = s["torrent"] + w = s["work"] + torrent = TorrentInfo( + site=1, + site_name="site", + title=t["title"], + description=t.get("subtitle"), + media_source=MediaSource.IMDb if t.get("imdbId") else None, + media_id=t.get("imdbId"), + ) + meta = MetaInfo(t["title"], t.get("subtitle")) + media = MediaInfo() + media.type = MediaType.MOVIE if w["type"] == "movie" else MediaType.TV + media.title = w["title"] + media.original_title = w.get("originalTitle") or w["title"] + media.en_title = w.get("originalTitle") or w["title"] + media.year = str(w["year"]) + media.tmdb_id = w["tmdbId"] + media.imdb_id = w.get("imdbId") + media.names = [] + try: + by = _match_source(torrent, meta, media, {}) + except Exception as e: # noqa: BLE001 - 标题匹配分支需要的上下文缺失时记为未命中 + return {"match": None, "error": f"{type(e).__name__}: {e}"} + return {"match": by} + + +out = { + "generatedFrom": { + "repo": "MoviePilot", + "commit": subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], text=True).strip(), + "entry": "app.chain.search.result._match_source", + }, + "results": {s["id"]: run(s) for s in samples}, +} +(HERE / "site-ids-mp.json").write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") +print(json.dumps(out["results"], ensure_ascii=False, indent=1)) diff --git a/packages/domain/test/differential/identity/site-ids-deviations.json b/packages/domain/test/differential/identity/site-ids-deviations.json new file mode 100644 index 00000000..edfce1cd --- /dev/null +++ b/packages/domain/test/differential/identity/site-ids-deviations.json @@ -0,0 +1,13 @@ +{ + "note": "ID-3 站点外部 ID 组的逐条归因。MP `_match_source` 只要种子 IMDb 与作品一致就判命中;我们把站点 ID 当强证据但保留年份 / 类型硬规则。", + "deviations": { + "S-004": { + "kind": "intentional", + "note": "年份差 37 年:站点 IMDb 链接挂错(挂到同名新片)的典型。我们只作候选(needs_confirm → 待确认),不自动绑定;MP 直接按 IMDb 命中" + }, + "S-006": { + "kind": "intentional", + "note": "剧集标题(S01)配电影 IMDb:类型冲突,只作候选;MP 按 IMDb 命中" + } + } +} diff --git a/packages/domain/test/differential/identity/site-ids-mp.json b/packages/domain/test/differential/identity/site-ids-mp.json new file mode 100644 index 00000000..1006e3ee --- /dev/null +++ b/packages/domain/test/differential/identity/site-ids-mp.json @@ -0,0 +1,27 @@ +{ + "generatedFrom": { + "repo": "MoviePilot", + "commit": "e6820a8", + "entry": "app.chain.search.result._match_source" + }, + "results": { + "S-001": { + "match": "imdb" + }, + "S-002": { + "match": "imdb" + }, + "S-003": { + "match": "imdb" + }, + "S-004": { + "match": "imdb" + }, + "S-005": { + "match": "imdb" + }, + "S-006": { + "match": "imdb" + } + } +} diff --git a/packages/domain/test/differential/identity/site-ids-samples.json b/packages/domain/test/differential/identity/site-ids-samples.json new file mode 100644 index 00000000..e5e0961f --- /dev/null +++ b/packages/domain/test/differential/identity/site-ids-samples.json @@ -0,0 +1,97 @@ +{ + "note": "ID-3 站点外部 ID 组:种子标题 / 副标题取自观众(DET-2)详情样本与憨憨样本,IMDb ID 为样本页面上的链接;作品侧 TMDB id / 标题为测试夹具(不是 TMDB 真实数据)。expectOurs = 我们在 trustedIds 下的判定(auto / ambiguous / no_match)。", + "samples": [ + { + "id": "S-001", + "note": "观众 725858:标题与作品名一致,IMDb 一致", + "torrent": { + "title": "Rose 2026 1080p BluRay AVC DTS-HD MA 5.1-DIY@Audies", + "subtitle": "罗斯 / 扑朔迷离的露丝(港)【德版原盘DIY 简繁字幕】", + "imdbId": "tt25399154" + }, + "work": { + "type": "movie", + "tmdbId": 990001, + "title": "罗斯", + "originalTitle": "Rose", + "year": 2026, + "imdbId": "tt25399154" + }, + "expectOurs": "auto" + }, + { + "id": "S-002", + "note": "观众 727077:英文名(A Prophet)与作品英文名对不上,IMDb 一致", + "torrent": { + "title": "A Prophet S01E16 2026 2160p iQIYI WEB-DL H265 HDR DDP5.1-ADWeb", + "subtitle": "我不是大师 第16集 | 类型:剧情 悬疑 | 主演:李现 李一桐 吴刚 刘冠麟 果靖霖 | 长风起 / 将相 / 江相 / 我是个算命先生", + "imdbId": "tt36758489" + }, + "work": { + "type": "tv", + "tmdbId": 990002, + "title": "我不是大师", + "originalTitle": "The Fortune Teller", + "year": 2026, + "imdbId": "tt36758489" + }, + "expectOurs": "auto" + }, + { + "id": "S-003", + "note": "标题完全对不上(站点用拼音名),IMDb 一致", + "torrent": { "title": "Wu Ji 2005 1080p BluRay x264-WiKi", "imdbId": "tt0417976" }, + "work": { + "type": "movie", + "tmdbId": 990003, + "title": "The Promise", + "originalTitle": "The Promise", + "year": 2005, + "imdbId": "tt0417976" + }, + "expectOurs": "auto" + }, + { + "id": "S-004", + "note": "IMDb 一致但年份差 37 年(站点 IMDb 链接挂错的典型)", + "torrent": { "title": "Dune 1984 1080p BluRay x264-GRP", "imdbId": "tt1160419" }, + "work": { + "type": "movie", + "tmdbId": 990004, + "title": "Dune", + "originalTitle": "Dune", + "year": 2021, + "imdbId": "tt1160419" + }, + "expectOurs": "ambiguous" + }, + { + "id": "S-005", + "note": "IMDb 一致,标题对不上,年份差 1 年(容差内)", + "torrent": { "title": "Hero 2003 1080p BluRay x264-GRP", "imdbId": "tt0299977" }, + "work": { + "type": "movie", + "tmdbId": 990005, + "title": "英雄", + "originalTitle": "英雄", + "year": 2002, + "imdbId": "tt0299977" + }, + "expectOurs": "auto" + }, + { + "id": "S-006", + "note": "剧集标题(S01)配电影的 IMDb:类型冲突", + "torrent": { "title": "Sherlock S01 2010 1080p BluRay x264-GRP", "imdbId": "tt1475582" }, + "work": { + "type": "movie", + "tmdbId": 990006, + "title": "Sherlock Holmes", + "originalTitle": "Sherlock Holmes", + "year": 2009, + "imdbId": "tt1475582" + }, + "expectOurs": "ambiguous" + } + ] +} diff --git a/packages/domain/test/identity-id3.test.ts b/packages/domain/test/identity-id3.test.ts new file mode 100644 index 00000000..fc371e77 --- /dev/null +++ b/packages/domain/test/identity-id3.test.ts @@ -0,0 +1,98 @@ +/** + * ID-3:站点列表 / 详情页给出的外部 ID(trustedIds)作为强证据。 + * 差分组:packages/domain/test/differential/identity/site-ids-*.json(MP search/result.py `_match_source`, + * 由 gen-site-ids-mp.py 生成)。MP 只要种子 IMDb 与作品一致就判命中;我们年份冲突 / 类型冲突时只作候选(登记的偏离)。 + */ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { type IdentityCandidate, parseRelease, resolveIdentity, scoreCandidate } from "../src/index.ts"; + +const DIR = new URL("./differential/identity/", import.meta.url); +const read = (name: string): T => JSON.parse(readFileSync(new URL(name, DIR), "utf8")) as T; + +interface Sample { + id: string; + note: string; + torrent: { title: string; subtitle?: string; imdbId?: string }; + work: { type: "movie" | "tv"; tmdbId: number; title: string; originalTitle?: string; year: number; imdbId?: string }; + expectOurs: "auto" | "ambiguous" | "no_match"; +} + +const samples = read<{ samples: Sample[] }>("site-ids-samples.json").samples; +const mp = read<{ results: Record }>("site-ids-mp.json").results; +const deviations = read<{ deviations: Record }>( + "site-ids-deviations.json", +).deviations; + +const candidateOf = (w: Sample["work"]): IdentityCandidate => ({ + provider: "tmdb", + entityType: w.type, + externalId: String(w.tmdbId), + titles: [...new Set([w.title, ...(w.originalTitle ? [w.originalTitle] : [])])], + year: w.year, + aliases: [], + ...(w.imdbId ? { linkedExternalIds: [{ provider: "imdb", id: w.imdbId }] } : {}), +}); + +const ours = (s: Sample) => { + const p = parseRelease(s.torrent.title, s.torrent.subtitle ? { subtitle: s.torrent.subtitle } : {}); + const trustedIds = s.torrent.imdbId ? [{ provider: "imdb", id: s.torrent.imdbId }] : []; + return resolveIdentity(p, [candidateOf(s.work)], { trustedIds }); +}; + +describe("差分:站点外部 ID(MP _match_source)", () => { + it("样本与 MP 输出一一对应", () => { + expect(samples.length).toBeGreaterThanOrEqual(6); + for (const s of samples) expect(mp[s.id], s.id).toBeDefined(); + }); + + for (const s of samples) { + it(`${s.id} ${s.note}`, () => { + const r = ours(s); + expect(r.status, s.id).toBe(s.expectOurs); + const mpMatched = mp[s.id]!.match !== null; + const oursMatched = r.status === "auto"; + if (mpMatched === oursMatched) expect(deviations[s.id], `${s.id} 已一致,请删除登记`).toBeUndefined(); + else expect(deviations[s.id]?.kind, `${s.id} 与 MP 不一致但没有登记`).toBe("intentional"); + }); + } +}); + +describe("trustedIds 评分", () => { + const movie: IdentityCandidate = { + provider: "tmdb", + entityType: "movie", + externalId: "872585", + titles: ["Oppenheimer", "奥本海默"], + year: 2023, + aliases: [], + linkedExternalIds: [{ provider: "imdb", id: "tt15398776" }], + }; + it("标题对不上:trusted → multi_evidence(auto);不 trusted 仍是 rejected", () => { + const p = parseRelease("Something Else 2023 1080p WEB-DL"); + expect(scoreCandidate(p, movie).level).toBe("rejected"); + const r = scoreCandidate(p, movie, { trustedIds: [{ provider: "imdb", id: "tt15398776" }] }); + expect(r.level).toBe("multi_evidence"); + expect(r.evidence).toContain("site_id"); + }); + it("TMDB ID 直接对上候选同样算", () => { + const p = parseRelease("Something Else 2023 1080p WEB-DL"); + expect(scoreCandidate(p, movie, { trustedIds: [{ provider: "tmdb", id: "872585" }] }).level).toBe("multi_evidence"); + }); + it("标题也对上时不降级(rule_confirmed 保留)", () => { + const p = parseRelease("Oppenheimer 2023 1080p WEB-DL"); + expect(scoreCandidate(p, movie, { trustedIds: [{ provider: "imdb", id: "tt15398776" }] }).level).toBe( + "rule_confirmed", + ); + }); + it("别的 ID 不影响", () => { + const p = parseRelease("Something Else 2023 1080p WEB-DL"); + expect(scoreCandidate(p, movie, { trustedIds: [{ provider: "imdb", id: "tt0000001" }] }).level).toBe("rejected"); + }); + it("年份冲突 → needs_confirm(ID 可能挂错)", () => { + const p = parseRelease("Oppenheimer 1990 1080p WEB-DL"); + const r = scoreCandidate(p, movie, { trustedIds: [{ provider: "imdb", id: "tt15398776" }] }); + expect(r.level).toBe("needs_confirm"); + expect(r.evidence).toContain("site_id_year_conflict"); + }); +});