- 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]>
140 lines
6.2 KiB
TypeScript
140 lines
6.2 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|