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 };
|
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);
|
if (created) await enqueueNewEntityRefresh(ctx, done.mediaEntityId);
|
||||||
return done;
|
return done;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -196,24 +196,31 @@ async function tmdbCandidates(
|
|||||||
parsed: ParsedRelease,
|
parsed: ParsedRelease,
|
||||||
useCache: boolean,
|
useCache: boolean,
|
||||||
siteIds: ReturnType<typeof siteExplicitIds> = [],
|
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();
|
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);
|
const meta = recognizeMetaOf(parsed);
|
||||||
// 站点详情页给出的 TMDB / IMDb ID:按 ID 直取(TMDB 详情 / find),命中就不再按标题搜索
|
// 站点详情页给出的 TMDB / IMDb ID:按 ID 直取(TMDB 详情 / find),命中就不再按标题搜索
|
||||||
const byId = await tmdbByExplicitIds(tmdb, siteIds);
|
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) {
|
if (meta && useCache) {
|
||||||
const hit = await loadRecognizeCache(rt, meta);
|
// 命中条件含年份 / 类型相容(loadRecognizeCache);命中后的唯一候选照样经 resolveIdentity 按资源年份 / 类型打分
|
||||||
|
const hit = await loadRecognizeCache(rt, meta, parsed);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
if (hit.id && hit.title && (hit.type === "movie" || hit.type === "tv")) {
|
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));
|
let detailFailed = false;
|
||||||
return { remote, cacheHit: false, meta };
|
const remote = await recallTmdbCandidates(tmdb, {
|
||||||
|
...tmdbRecallInput(parsed),
|
||||||
|
onDetailError: () => {
|
||||||
|
detailFailed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { remote, cacheHit: false, meta, detailFailed };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tmdbByExplicitIds(
|
async function tmdbByExplicitIds(
|
||||||
@@ -494,7 +501,11 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
|||||||
}
|
}
|
||||||
let remote: MetadataCandidate[] = [];
|
let remote: MetadataCandidate[] = [];
|
||||||
let lookupError: string | null = null;
|
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 {
|
try {
|
||||||
const r = await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
const r = await tmdbCandidates(rt, parsed, !input.force, siteIds);
|
||||||
remote = r.remote;
|
remote = r.remote;
|
||||||
@@ -512,7 +523,8 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
|||||||
top?.provider === "tmdb"
|
top?.provider === "tmdb"
|
||||||
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
|
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
|
||||||
: undefined;
|
: 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);
|
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);
|
const lookup = await lookupReason(rt, parsed, trigger, local.length > 0, null);
|
||||||
let remote: MetadataCandidate[] = [];
|
let remote: MetadataCandidate[] = [];
|
||||||
let lookupError: string | null = null;
|
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) {
|
if (lookup) {
|
||||||
try {
|
try {
|
||||||
const r = await tmdbCandidates(rt, parsed, true);
|
const r = await tmdbCandidates(rt, parsed, true);
|
||||||
@@ -1154,7 +1170,8 @@ export async function identifyParsed(
|
|||||||
top?.provider === "tmdb"
|
top?.provider === "tmdb"
|
||||||
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
|
? remote.find((m) => m.externalId === top.externalId && m.entityType === top.entityType)
|
||||||
: undefined;
|
: 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);
|
await saveRecognizeCache(rt, recognize.meta, info);
|
||||||
}
|
}
|
||||||
const candidates = resolution.ranked.slice(0, 5).map((r) => {
|
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";
|
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 tmdb = await rt.tmdb();
|
||||||
const key = recognizeKey(meta, localeOf(tmdb));
|
const key = recognizeKey(meta, localeOf(tmdb));
|
||||||
const cache = sharedCache(rt.ctx);
|
const cache = sharedCache(rt.ctx);
|
||||||
@@ -77,6 +100,7 @@ export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta
|
|||||||
await cache.delete(CACHE_REGIONS.tmdbRecognize, key);
|
await cache.delete(CACHE_REGIONS.tmdbRecognize, key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (parsed && !entryCompatible(parsed, v)) return null;
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +145,17 @@ export async function saveRecognizeCache(
|
|||||||
/**
|
/**
|
||||||
* 人工认定后回填识别缓存(MP update_recognize_cache:覆盖名称负缓存,避免同名资源再次回查)。
|
* 人工认定后回填识别缓存(MP update_recognize_cache:覆盖名称负缓存,避免同名资源再次回查)。
|
||||||
* 作品没有已接受的 TMDB 身份时不写。失败只记日志。
|
* 作品没有已接受的 TMDB 身份时不写。失败只记日志。
|
||||||
|
* H2:一次指定不扩散给不相容的同名资源——只回填用户本人的指定(Agent / 系统的不回填);资源没有年份不回填
|
||||||
|
* (键里年份是 None,会被所有无年份的同名资源命中);作品年份 / 类型与资源解析不相容(指定到了别的版本)不回填。
|
||||||
*/
|
*/
|
||||||
export async function backfillRecognizeCache(
|
export async function backfillRecognizeCache(
|
||||||
rt: SourcesRuntime,
|
rt: SourcesRuntime,
|
||||||
parsed: ParsedRelease,
|
parsed: ParsedRelease,
|
||||||
entityId: string,
|
entityId: string,
|
||||||
|
actorType: string = "user",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (actorType !== "user" || parsed.year === undefined) return;
|
||||||
const meta = recognizeMetaOf(parsed);
|
const meta = recognizeMetaOf(parsed);
|
||||||
if (!meta || !(await rt.tmdb())) return;
|
if (!meta || !(await rt.tmdb())) return;
|
||||||
const row = await rt.ctx.db
|
const row = await rt.ctx.db
|
||||||
@@ -139,6 +167,12 @@ export async function backfillRecognizeCache(
|
|||||||
.where("i.status", "=", "accepted")
|
.where("i.status", "=", "accepted")
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!row || (row.entity_type !== "movie" && row.entity_type !== "tv")) return;
|
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 md = (row.metadata ?? {}) as Record<string, { value?: unknown } | undefined>;
|
||||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||||
await saveRecognizeCache(rt, meta, {
|
await saveRecognizeCache(rt, meta, {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export interface TmdbRecallInput {
|
|||||||
maxDetails?: number;
|
maxDetails?: number;
|
||||||
/** 候选池上限,默认 20 */
|
/** 候选池上限,默认 20 */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/** 拉详情失败(保留搜索结果继续)时回调:调用方据此知道这一轮召回不完整,例如不写负缓存 */
|
||||||
|
onDetailError?: (e: TmdbError, c: MetadataCandidate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const yearDistance = (c: MetadataCandidate, year: number | undefined, type: MediaEntityType): number => {
|
const yearDistance = (c: MetadataCandidate, year: number | undefined, type: MediaEntityType): number => {
|
||||||
@@ -65,6 +67,7 @@ export async function recallTmdbCandidates(tmdb: TmdbClient, input: TmdbRecallIn
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 对照 MP get_info 失败返回 None 后继续:详情只用于补别名,取不到保留搜索结果
|
// 对照 MP get_info 失败返回 None 后继续:详情只用于补别名,取不到保留搜索结果
|
||||||
if (!(e instanceof TmdbError)) throw e;
|
if (!(e instanceof TmdbError)) throw e;
|
||||||
|
input.onDetailError?.(e, c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...seen.values()].slice(0, input.limit ?? 20);
|
return [...seen.values()].slice(0, input.limit ?? 20);
|
||||||
|
|||||||
Reference in new issue
Block a user