feat(sources): MP-aligned TMDB recall, alias registration guard, re-resolve on retitle (ID-2)

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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 17:41:22 +08:00
1 parent f6ec993b5f
commit 387f7931e0
9 files changed
+269 -39

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
# tmdb
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。`recallTmdbCandidates` 是身份识别的候选召回(ID-2,对照 MP 名称顺序与别名回退:中文名/英文名按类型单语言搜索,标题对不上的按年份相近度拉前几个详情比对别名/译名),打分交给 `domain.resolveIdentity`。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
仅对 mock 与官方 API 文档验证,未连接真实实例。
+1
View File
@@ -1,3 +1,4 @@
export { RequestLimiter, TtlLruCache } from "./cache.ts";
export * from "./client.ts";
export * from "./match.ts";
export * from "./recall.ts";
+71
View File
@@ -0,0 +1,71 @@
/**
* 身份识别的 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);
}