Merge branch 'id3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
root committed 2026-10-01 00:15:49 +08:00
commit a9511a75f6
23 files changed
+1320 -14

No files matched your search

@@ -152,6 +152,16 @@
}
]
},
"doubanid": {
"selector": "a[href*=\"douban.com/subject/\"]",
"attribute": "href",
"filters": [
{
"name": "re_search",
"args": ["subject/(\\d+)", 1]
}
]
},
"freedate": {
"selector": "img[class^=\"pro_\"]",
"attribute": "title",
@@ -78,5 +78,6 @@ export function spiderTorrentToRelease(
};
if (t.description) release.subtitle = t.description;
if (t.imdbid) release.imdbId = t.imdbid;
if (t.doubanid) release.doubanId = t.doubanid;
return release;
}
@@ -67,6 +67,8 @@ export interface SpiderTorrent {
seeders?: number;
size?: number;
imdbid?: string;
/** UMT 扩展(ID-3,MP 没有):列表行里的豆瓣 subject id,字段 `doubanid` 与 imdbid 同样的选择器写法 */
doubanid?: string;
downloadvolumefactor?: number;
uploadvolumefactor?: number;
pubdate?: string;
@@ -476,6 +478,7 @@ export class SiteSpider {
this.getSeeders(row);
this.getSize(row);
this.getImdbid(row);
this.getDoubanid(row);
this.getVolumeFactor(row, "downloadvolumefactor");
this.getVolumeFactor(row, "uploadvolumefactor");
this.getPubdate(row);
@@ -659,6 +662,13 @@ export class SiteSpider {
}
}
private getDoubanid(row: Sel) {
if (!("doubanid" in this.fields)) return;
const selector = this.fields.doubanid ?? {};
const v = this.filterText(this.safeQuery(row, selector), selector.filters);
if (v != null && /^\d{4,10}$/.test(String(v))) this.info.doubanid = String(v);
}
private getImdbid(row: Sel) {
if (!("imdbid" in this.fields)) return;
const selector = this.fields.imdbid ?? {};
@@ -0,0 +1,30 @@
/**
* ID-3 列表页外部 ID:观众列表行右侧的评分徽标带 IMDb / 豆瓣链接(DET-2 列表样本 rl-audiences-list,脱敏)。
* imdbid 是 MP 字段;doubanid 是 UMT 扩展字段(MP SiteSpider 不读,索引差分只比 MP 字段,不受影响)。
*/
import { describe, expect, it } from "vitest";
import { audiencesDefinition, SiteSpider } from "../../src/sites/index.ts";
import { spiderTorrentToRelease } from "../../src/sites/spider/map.ts";
import { readInput } from "./diff-util.ts";
describe("观众列表页:IMDb / 豆瓣 ID(真实样本)", () => {
const html = readInput("inputs/indexer/rl-audiences-list.html.txt");
const rows = new SiteSpider(audiencesDefinition, {}).parse(html).torrents;
const byId = (id: string) => rows.find((r) => r.page_url?.includes(`id=${id}&`));
it("有豆瓣徽标的行都抽到豆瓣 ID,有 IMDb 徽标的抽到 IMDb", () => {
expect(byId("725858")).toMatchObject({ imdbid: "tt25399154", doubanid: "35322132" });
expect(byId("726249")).toMatchObject({ imdbid: "tt0039689", doubanid: "1302338" });
// 剧集行只有豆瓣(IMDb 在详情页里)
const prophet = byId("727077")!;
expect(prophet.doubanid).toBe("37297001");
expect(prophet.imdbid).toBeUndefined();
expect(rows.filter((r) => r.doubanid).length).toBe(10);
});
it("映射到 SiteRelease.doubanId / imdbId", () => {
const r = spiderTorrentToRelease(byId("727077")!, audiencesDefinition, "2026-09-30T00:00:00.000Z");
expect(r).toMatchObject({ siteReleaseId: "727077", doubanId: "37297001" });
expect(r?.imdbId).toBeUndefined();
});
});
+7
View File
@@ -496,6 +496,11 @@ export const IdentitySettings = z.object({
* 缺省开;关掉后只用白名单网站的摘要与网页。旧设置没有这个键,按开处理
*/
enrichSnippetAllSites: z.boolean().optional(),
/**
* ID-3 识别升级:标题识别不确定时,每个站点每小时最多为识别抓多少个详情页(取 IMDb / TMDB / 豆瓣 ID);0 = 不抓。
* 只在搜索结果、订阅相关的候选、池条目补全和手动重新识别时抓,RSS 批量入库不抓。旧设置没有这个键,按缺省 10
*/
detailIdsPerSiteHour: z.number().int().min(0).max(200).optional(),
});
export type IdentitySettings = z.infer<typeof IdentitySettings>;
@@ -699,6 +704,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
enrichDailyBudget: 20,
enrichDomains: DEFAULT_ENRICH_DOMAINS,
enrichSnippetAllSites: true,
detailIdsPerSiteHour: 10,
},
search: { defaultSiteMode: "default", siteIds: [] },
storage: DEFAULT_STORAGE_SETTINGS,
@@ -998,6 +1004,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"identity.enrichDailyBudget",
"identity.enrichDomains",
"identity.enrichSnippetAllSites",
"identity.detailIdsPerSiteHour",
"cache.globalImageCache",
"cache.globalImageCacheDays",
],
+20
View File
@@ -86,6 +86,13 @@ export interface MatchOptions {
* rankCandidates / resolveIdentity 按池自动计算;单独调用 scoreCandidate 时可传入。
*/
genericNames?: ReadonlySet<string>;
/**
* 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,
@@ -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))
@@ -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 命中"
}
}
}
@@ -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"
}
}
}
@@ -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"
}
]
}
+98
View File
@@ -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 = <T>(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<string, { match: string | null }> }>("site-ids-mp.json").results;
const deviations = read<{ deviations: Record<string, { kind: string; note: string }> }>(
"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");
});
});