/** * 身份识别的 TMDB 候选召回(ID-2),对照 MoviePilot TheMovieDbModule._prepare_search_names + TmdbApi 的 * __search_movie_by_name / __search_tv_by_name / _candidate_resolution: * - 名称顺序 [中文名, 英文名](MP:cn_name → 简体 cn_name → en_name); * - 按类型搜索(剧集线索只搜 tv,否则 movie + tv),单语言(首选语言,与 MP 相同); * - 搜索结果的标题/原标题都对不上名称时,按年份相近度取前几个拉详情,用别名 + 译名比对(MP 对全部候选逐个拉详情, * 这里设上限,打分交给 domain.resolveIdentity)。 * 与 MP 的差异:MP 在第一个命中处短路,这里收集候选池交给证据打分(同名多候选 → 进待识别池而不是取第一个)。 */ import { type MediaEntityType, type MetadataCandidate, type TmdbClient, TmdbError } from "./client.ts"; import { normalizeTitleForCompare } from "./match.ts"; export interface TmdbRecallInput { /** 查询名称,按优先级(中文名在前);空值与重复会被忽略 */ names: (string | null | undefined)[]; /** 有剧集线索时只搜 tv */ type?: MediaEntityType; year?: number; /** 最多用几个名称搜索,默认 2 */ maxNames?: number; /** 最多为几个候选拉详情(别名比对),默认 3 */ maxDetails?: number; /** 候选池上限,默认 20 */ limit?: number; } const yearDistance = (c: MetadataCandidate, year: number | undefined, type: MediaEntityType): number => { if (year === undefined || c.year === null) return 50; const d = year - c.year; // 剧集:发布标题的年份可能是后续季的年份(晚于首播) if (type === "tv" && d > 0) return Math.min(d, 5); return Math.abs(d); }; export async function recallTmdbCandidates(tmdb: TmdbClient, input: TmdbRecallInput): Promise { const names: string[] = []; for (const n of input.names) { const t = n?.trim(); if (t && !names.some((x) => normalizeTitleForCompare(x) === normalizeTitleForCompare(t))) names.push(t); } const types: MediaEntityType[] = input.type ? [input.type] : ["movie", "tv"]; const seen = new Map(); for (const name of names.slice(0, input.maxNames ?? 2)) { for (const type of types) { for (const m of await tmdb.searchRaw(type, name)) { const k = `${m.entityType}:${m.externalId}`; if (!seen.has(k)) seen.set(k, m); } } } const pool = [...seen.values()]; const keys = new Set(names.map(normalizeTitleForCompare)); const direct = (c: MetadataCandidate) => [c.title, c.originalTitle].some((t) => !!t && keys.has(normalizeTitleForCompare(t))); // 年份相容(±1,剧集允许季年份更晚)的候选按年份相近度排序:标题对不上的拉详情比对别名; // 直接命中的前两个也拉详情(自动绑定本来就要取详情),看另一种文字的名称能否佐证(中文名命中一部、英文名命中另一部时靠它区分) const compatible = pool .filter((c) => yearDistance(c, input.year, c.entityType) <= (c.entityType === "tv" ? 5 : 1)) .sort((a, b) => yearDistance(a, input.year, a.entityType) - yearDistance(b, input.year, b.entityType)); const directs = compatible.filter(direct); const needDetail = [...directs.slice(0, 2), ...compatible.filter((c) => !direct(c)).slice(0, input.maxDetails ?? 3)]; for (const c of needDetail) { try { seen.set(`${c.entityType}:${c.externalId}`, await tmdb.details(c.entityType, c.externalId)); } catch (e) { // 对照 MP get_info 失败返回 None 后继续:详情只用于补别名,取不到保留搜索结果 if (!(e instanceof TmdbError)) throw e; } } return [...seen.values()].slice(0, input.limit ?? 20); }