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:
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, {
|
||||
|
||||
Reference in new issue
Block a user