From dca3012227b7617d8223cb828abe945400bebc56 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(identity):=20=E8=AF=86=E5=88=AB=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E4=B8=8D=E6=8A=8A=E4=B8=80=E6=AC=A1=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E6=89=A9=E6=95=A3=E7=BB=99=E4=B8=8D=E7=9B=B8=E5=AE=B9=E7=9A=84?= =?UTF-8?q?=E5=90=8C=E5=90=8D=E8=B5=84=E6=BA=90,=E5=8F=AC=E5=9B=9E?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=99=E8=B4=9F?= =?UTF-8?q?=E7=BC=93=E5=AD=98(H2=E3=80=81M8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回填只认用户本人的指定,资源没有年份、或作品年份 / 类型与资源不相容时不回填; 读缓存时核对条目与资源的年份(±1,剧集允许更晚)与类型,不相容按未命中处理, 命中后的唯一候选仍经 resolveIdentity 按资源年份 / 类型打分。 TMDB 召回中有候选拉详情失败时,这一轮无匹配不算结论,不写负缓存。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../service/identity-admin.ts | 2 +- .../sources-identity/service/resolve.ts | 41 +++++++++++++------ .../service/tmdb-recognize-cache.ts | 38 ++++++++++++++++- packages/adapters/src/tmdb/recall.ts | 3 ++ 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index 05b088ad..441d8f6a 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -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) { diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 7b7beab1..a41e0008 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -196,24 +196,31 @@ async function tmdbCandidates( parsed: ParsedRelease, useCache: boolean, siteIds: ReturnType = [], -): 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) => { diff --git a/apps/server/src/modules/sources-identity/service/tmdb-recognize-cache.ts b/apps/server/src/modules/sources-identity/service/tmdb-recognize-cache.ts index 48d3888a..c7f2d18d 100644 --- a/apps/server/src/modules/sources-identity/service/tmdb-recognize-cache.ts +++ b/apps/server/src/modules/sources-identity/service/tmdb-recognize-cache.ts @@ -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 { +/** 资源是否带剧集线索(季 / 集 / 类型提示) */ +const tvLike = (p: ParsedRelease) => p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined; + +/** + * 缓存条目与资源自身的年份 / 类型是否相容(H2):资源有年份时条目年份须在 ±1 内(剧集允许资源年份更晚,后续季); + * 资源有剧集线索时条目须是剧集。负缓存不带年份类型,总是相容。 + */ +export function entryCompatible(p: ParsedRelease, e: Pick): 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 { 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 { 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; const str = (v: unknown) => (typeof v === "string" && v ? v : null); await saveRecognizeCache(rt, meta, { diff --git a/packages/adapters/src/tmdb/recall.ts b/packages/adapters/src/tmdb/recall.ts index 50f7f737..7f7e41f7 100644 --- a/packages/adapters/src/tmdb/recall.ts +++ b/packages/adapters/src/tmdb/recall.ts @@ -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);