Files
UHD_Media_Tool/packages/adapters/src/tmdb/match.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock;
  remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports)
- tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions),
  test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types
- tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl
- server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting;
  drop @hono/node-server, tsx, esbuild build.mjs
- logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests
- relative imports get explicit extensions (sites/mp-sync paths left for follow-up)
- crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas
- single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml
- docs: README, runbook, ADR-001 superseded, plan §1 validation row

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:53:06 +08:00

140 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<MetadataCandidate | null> {
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<MetadataCandidate | null> {
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<MetadataCandidate | null> {
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<TmdbMatchResult | null> {
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<MetadataCandidate | null> {
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));
}