fix(identity): 识别缓存不把一次指定扩散给不相容的同名资源,召回详情失败不写负缓存(H2、M8)

回填只认用户本人的指定,资源没有年份、或作品年份 / 类型与资源不相容时不回填;
读缓存时核对条目与资源的年份(±1,剧集允许更晚)与类型,不相容按未命中处理,
命中后的唯一候选仍经 resolveIdentity 按资源年份 / 类型打分。
TMDB 召回中有候选拉详情失败时,这一轮无匹配不算结论,不写负缓存。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:31:06 +08:00
1 parent 4132324935
commit dca3012227
4 files changed
+69 -15

No files matched your search

@@ -226,7 +226,7 @@ export async function assignReleaseIdentity(
});
return { mediaEntityId: entityId, contentUnitIds: unitIds };
});
await backfillRecognizeCache(rt, parsed, done.mediaEntityId);
await backfillRecognizeCache(rt, parsed, done.mediaEntityId, actor.type);
if (created) await enqueueNewEntityRefresh(ctx, done.mediaEntityId);
return done;
} catch (e) {
@@ -196,24 +196,31 @@ async function tmdbCandidates(
parsed: ParsedRelease,
useCache: boolean,
siteIds: ReturnType<typeof siteExplicitIds> = [],
): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null }> {
): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null; detailFailed: boolean }> {
const tmdb = await rt.tmdb();
if (!tmdb) return { remote: [], cacheHit: false, meta: null };
if (!tmdb) return { remote: [], cacheHit: false, meta: null, detailFailed: false };
const meta = recognizeMetaOf(parsed);
// 站点详情页给出的 TMDB / IMDb ID:按 ID 直取(TMDB 详情 / find),命中就不再按标题搜索
const byId = await tmdbByExplicitIds(tmdb, siteIds);
if (byId.length) return { remote: byId, cacheHit: false, meta };
if (byId.length) return { remote: byId, cacheHit: false, meta, detailFailed: false };
if (meta && useCache) {
const hit = await loadRecognizeCache(rt, meta);
// 命中条件含年份 / 类型相容(loadRecognizeCache);命中后的唯一候选照样经 resolveIdentity 按资源年份 / 类型打分
const hit = await loadRecognizeCache(rt, meta, parsed);
if (hit) {
if (hit.id && hit.title && (hit.type === "movie" || hit.type === "tv")) {
return { remote: [await tmdb.details(hit.type, hit.id)], cacheHit: true, meta };
return { remote: [await tmdb.details(hit.type, hit.id)], cacheHit: true, meta, detailFailed: false };
}
return { remote: [], cacheHit: true, meta };
return { remote: [], cacheHit: true, meta, detailFailed: false };
}
}
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
return { remote, cacheHit: false, meta };
let detailFailed = false;
const remote = await recallTmdbCandidates(tmdb, {
...tmdbRecallInput(parsed),
onDetailError: () => {
detailFailed = true;
},
});
return { remote, cacheHit: false, meta, detailFailed };
}
async function tmdbByExplicitIds(
@@ -494,7 +501,11 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
}
let remote: MetadataCandidate[] = [];
let lookupError: string | null = null;
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null } = { cacheHit: false, meta: null };
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null; detailFailed: boolean } = {
cacheHit: false,
meta: null,
detailFailed: false,
};
try {
const r = await tmdbCandidates(rt, parsed, !input.force, siteIds);
remote = r.remote;
@@ -512,7 +523,8 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
top?.provider === "tmdb"
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
: undefined;
const info = hit ?? (resolution.status === "no_match" ? null : undefined);
// 召回时有候选拉详情失败(缺别名 / 季年份):这一轮"无匹配"不算结论,不写负缓存
const info = hit ?? (resolution.status === "no_match" && !recognize.detailFailed ? null : undefined);
await saveRecognizeCache(rt, recognize.meta, info);
}
@@ -1136,7 +1148,11 @@ export async function identifyParsed(
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
let remote: MetadataCandidate[] = [];
let lookupError: string | null = null;
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null } = { cacheHit: false, meta: null };
let recognize: { cacheHit: boolean; meta: RecognizeMeta | null; detailFailed: boolean } = {
cacheHit: false,
meta: null,
detailFailed: false,
};
if (lookup) {
try {
const r = await tmdbCandidates(rt, parsed, true);
@@ -1154,7 +1170,8 @@ export async function identifyParsed(
top?.provider === "tmdb"
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
: undefined;
const info = hit ?? (resolution.status === "no_match" ? null : undefined);
// 召回时有候选拉详情失败(缺别名 / 季年份):这一轮"无匹配"不算结论,不写负缓存
const info = hit ?? (resolution.status === "no_match" && !recognize.detailFailed ? null : undefined);
await saveRecognizeCache(rt, recognize.meta, info);
}
const candidates = resolution.ranked.slice(0, 5).map((r) => {
@@ -65,8 +65,31 @@ function localeOf(tmdb: unknown): string {
return (tmdb as { language?: string } | null)?.language ?? "zh-CN";
}
/** TmdbCache.get:未命中返回 null;类型冲突的脏条目删除后按未命中处理 */
export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta): Promise<RecognizeCacheEntry | null> {
/** 资源是否带剧集线索(季 / 集 / 类型提示) */
const tvLike = (p: ParsedRelease) => p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined;
/**
* 缓存条目与资源自身的年份 / 类型是否相容(H2):资源有年份时条目年份须在 ±1 内(剧集允许资源年份更晚,后续季);
* 资源有剧集线索时条目须是剧集。负缓存不带年份类型,总是相容。
*/
export function entryCompatible(p: ParsedRelease, e: Pick<RecognizeCacheEntry, "id" | "type" | "year">): boolean {
if (!e.id) return true;
if (tvLike(p) && e.type === "movie") return false;
const y = e.year ? Number(e.year) : Number.NaN;
if (p.year === undefined || !Number.isFinite(y)) return true;
if (Math.abs(p.year - y) <= 1) return true;
return e.type === "tv" && p.year > y;
}
/**
* TmdbCache.get:未命中返回 null;类型冲突的脏条目删除后按未命中处理。
* 传入资源解析时再核对条目与资源的年份 / 类型,不相容按未命中处理(不删除,别的资源可能正好相容)。
*/
export async function loadRecognizeCache(
rt: SourcesRuntime,
meta: RecognizeMeta,
parsed?: ParsedRelease,
): Promise<RecognizeCacheEntry | null> {
const tmdb = await rt.tmdb();
const key = recognizeKey(meta, localeOf(tmdb));
const cache = sharedCache(rt.ctx);
@@ -77,6 +100,7 @@ export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta
await cache.delete(CACHE_REGIONS.tmdbRecognize, key);
return null;
}
if (parsed && !entryCompatible(parsed, v)) return null;
return v;
}
@@ -121,13 +145,17 @@ export async function saveRecognizeCache(
/**
* 人工认定后回填识别缓存(MP update_recognize_cache:覆盖名称负缓存,避免同名资源再次回查)。
* 作品没有已接受的 TMDB 身份时不写。失败只记日志。
* H2:一次指定不扩散给不相容的同名资源——只回填用户本人的指定(Agent / 系统的不回填);资源没有年份不回填
* (键里年份是 None,会被所有无年份的同名资源命中);作品年份 / 类型与资源解析不相容(指定到了别的版本)不回填。
*/
export async function backfillRecognizeCache(
rt: SourcesRuntime,
parsed: ParsedRelease,
entityId: string,
actorType: string = "user",
): Promise<void> {
try {
if (actorType !== "user" || parsed.year === undefined) return;
const meta = recognizeMetaOf(parsed);
if (!meta || !(await rt.tmdb())) return;
const row = await rt.ctx.db
@@ -139,6 +167,12 @@ export async function backfillRecognizeCache(
.where("i.status", "=", "accepted")
.executeTakeFirst();
if (!row || (row.entity_type !== "movie" && row.entity_type !== "tv")) return;
const entry = {
id: Number(row.external_id),
type: row.entity_type,
year: row.year === null ? null : String(row.year),
};
if (!entryCompatible(parsed, entry)) return;
const md = (row.metadata ?? {}) as Record<string, { value?: unknown } | undefined>;
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
await saveRecognizeCache(rt, meta, {
+3
View File
@@ -22,6 +22,8 @@ export interface TmdbRecallInput {
maxDetails?: number;
/** 候选池上限,默认 20 */
limit?: number;
/** 拉详情失败(保留搜索结果继续)时回调:调用方据此知道这一轮召回不完整,例如不写负缓存 */
onDetailError?: (e: TmdbError, c: MetadataCandidate) => void;
}
const yearDistance = (c: MetadataCandidate, year: number | undefined, type: MediaEntityType): number => {
@@ -65,6 +67,7 @@ export async function recallTmdbCandidates(tmdb: TmdbClient, input: TmdbRecallIn
} catch (e) {
// 对照 MP get_info 失败返回 None 后继续:详情只用于补别名,取不到保留搜索结果
if (!(e instanceof TmdbError)) throw e;
input.onDetailError?.(e, c);
}
}
return [...seen.values()].slice(0, input.limit ?? 20);