recallTmdbCandidates searches cn then en names in the preferred language by type and fetches details for a bounded set of candidates (MP alias fallback); translations become aliases; bindAuto no longer registers generic or alias-only site titles; snapshot/feed title changes reparse and force a re-resolve that unbinds when it can no longer confirm. Co-Authored-By: Claude Opus 5.5 <[email protected]>
72 lines
3.6 KiB
TypeScript
72 lines
3.6 KiB
TypeScript
/**
|
|
* 身份识别的 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<MetadataCandidate[]> {
|
|
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<string, MetadataCandidate>();
|
|
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);
|
|
}
|