/** * TMDB 名称匹配链(API 侧行为),对照 MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8): * __compare_names + foundation/text.py remove_punctuation → normalizeTitleForCompare / compareNames * _generate_year_range → yearRange(year, year+1, year-1) * _build_match_plan / _match_resolution → matchTmdb 的步骤顺序(季年份步骤优先,再按名称+年份容差) * __search_movie_by_name / __search_tv_by_name / _build_name_candidate_plan → 按日期降序、年份前缀过滤、 * 标题/原标题直达,否则逐个拉详情用别名+译名比较(_alias_matches) * __search_tv_by_season / _season_candidate_matches / _season_year_matches → 季年份匹配 * match_multi / _sort_multi_results → matchTmdbMulti(电影优先、日期降序) * 调整:MP 查询失败返回 None 后继续下一步;这里 TMDB 错误直接抛出,避免把"查不到"与"查询失败"混为一谈。 * 未迁移:剧集组(group_seasons)季年份判断。打分/身份判定在 packages/domain。 */ import type { MediaEntityType, MetadataCandidate, TmdbClient } from "./client.ts"; const PUNCT = /[、.。,,·::;;!!??'’"“”()()[\]【】「」\-—―+|\\_/&#~~]/g; /** 对照 remove_punctuation(allow_space=False)+ upper:去标点、零宽字符与所有空白后转大写。 */ export function normalizeTitleForCompare(v: string): string { return v.replace(PUNCT, "").replace(/[​-‍]/g, "").replace(/\s+/g, "").toUpperCase(); } /** 规范化后完全相等才算匹配(不是模糊包含)。 */ export function compareNames( name: string, candidates: (string | null | undefined)[] | string | null | undefined, ): boolean { if (!name || !candidates) return false; const list = Array.isArray(candidates) ? candidates : [candidates]; const n = normalizeTitleForCompare(name); return list.some((c) => !!c && normalizeTitleForCompare(c.trim()) === n); } /** [year, year+1, year-1];无年份时 [undefined]。 */ export function yearRange(year?: string | number | null): (string | undefined)[] { if (year === undefined || year === null || year === "") return [undefined]; const y = Number(year); return [String(y), String(y + 1), String(y - 1)]; } const dateOf = (c: MetadataCandidate) => c.releaseDate ?? "0000-00-00"; const titleMatches = (name: string, c: MetadataCandidate) => compareNames(name, [c.title, c.originalTitle]); async function aliasResolve( tmdb: TmdbClient, candidates: MetadataCandidate[], accepts: (d: MetadataCandidate) => boolean, ): Promise { for (const c of candidates) { const d = await tmdb.details(c.entityType, c.externalId); if (accepts(d)) return d; } return null; } async function searchByName( tmdb: TmdbClient, name: string, type: MediaEntityType, year: string | undefined, ): Promise { const items = await tmdb.searchRaw(type, name, year); if (!items.length) return null; let cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0)); if (year) cands = cands.filter((c) => (c.releaseDate ?? "").slice(0, 4) === year); const direct = cands.find((c) => titleMatches(name, c)); if (direct) return direct; return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases)); } function seasonYearMatches(d: MetadataCandidate, seasonYear: string, seasonNumber: number): boolean { return (d.seasons ?? []).some((s) => s.seasonNumber === seasonNumber && (s.airDate ?? "").slice(0, 4) === seasonYear); } async function searchBySeason( tmdb: TmdbClient, name: string, seasonYear: string, seasonNumber: number, ): Promise { const items = await tmdb.searchRaw("tv", name); if (!items.length) return null; const cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0)); const direct = cands.find((c) => titleMatches(name, c) && (c.releaseDate ?? "").slice(0, 4) === seasonYear); if (direct) return direct; return aliasResolve( tmdb, cands, (d) => (titleMatches(name, d) || compareNames(name, d.aliases)) && ((d.releaseDate ?? "").slice(0, 4) === seasonYear || seasonYearMatches(d, seasonYear, seasonNumber)), ); } export interface TmdbMatchInput { name: string; type: MediaEntityType; /** 电影上映年份 / 剧集首播年份 */ year?: string | number; /** 当前季首播年份(与 seasonNumber 同时给出时先按季匹配) */ seasonYear?: string | number; seasonNumber?: number; } export interface TmdbMatchResult { candidate: MetadataCandidate; /** 命中的步骤,便于记录证据 */ step: { target: "movie_name" | "tv_name" | "tv_season"; year: string | undefined }; } /** 对照 TmdbApi.match:按冻结计划逐步查询,首个命中短路;全部未命中返回 null。 */ export async function matchTmdb(tmdb: TmdbClient, input: TmdbMatchInput): Promise { if (!input.name) return null; const steps: TmdbMatchResult["step"][] = []; if (input.type === "tv" && input.seasonYear !== undefined && input.seasonNumber !== undefined) { steps.push({ target: "tv_season", year: String(input.seasonYear) }); } for (const y of yearRange(input.year)) steps.push({ target: input.type === "tv" ? "tv_name" : "movie_name", year: y }); for (const step of steps) { const hit = step.target === "tv_season" ? await searchBySeason(tmdb, input.name, step.year!, input.seasonNumber!) : await searchByName(tmdb, input.name, input.type, step.year); if (hit) return { candidate: hit, step }; } return null; } /** 对照 TmdbApi.match_multi:无类型无年份时用 /search/multi;电影优先,日期降序。 */ export async function matchTmdbMulti(tmdb: TmdbClient, name: string): Promise { if (!name) return null; const multis = await tmdb.searchMulti(name); if (!multis.length) return null; const key = (c: MetadataCandidate) => (c.entityType === "movie" ? "1" : "0") + dateOf(c); const cands = [...multis].sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0)); const direct = cands.find((c) => titleMatches(name, c)); if (direct) return direct; return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases)); }