From 41323249353e380c726a3b5fb381fa511ecd91cf Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:30:57 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(identity):=20bgm=20=E6=8C=89=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E6=90=9C=E7=B4=A2=E7=9A=84=E7=BB=93=E6=9E=9C=E5=85=88?= =?UTF-8?q?=E4=B8=8E=E8=B5=84=E6=BA=90=E5=90=8D=E7=A7=B0=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?,=E5=94=AF=E4=B8=80=E9=AB=98=E5=88=86=E6=89=8D=E9=87=87?= =?UTF-8?q?=E7=94=A8(H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 搜索结果按名称 / 中文名 / 别名做相似度核对,搜索结果没别名时为前几个条目拉详情再核对; 拿不准就不走 bgm 投影。投影出的 TMDB 结果再与资源自己的年份 / 续作序号 / 名称核对, 名称对不上且 bgm 条目只是相似的降为歧义。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/service/meta-subjects.ts | 116 ++++++++++++++++-- .../sources-identity/service/resolve.ts | 43 ++++++- .../sources-identity/id-escalation.test.ts | 34 ++++- 3 files changed, 180 insertions(+), 13 deletions(-) diff --git a/apps/server/src/modules/sources-identity/service/meta-subjects.ts b/apps/server/src/modules/sources-identity/service/meta-subjects.ts index 1145d6a9..cb19e591 100644 --- a/apps/server/src/modules/sources-identity/service/meta-subjects.ts +++ b/apps/server/src/modules/sources-identity/service/meta-subjects.ts @@ -6,6 +6,7 @@ import { type MetaSubject, } from "@umt/adapters"; import { pgCacheFor } from "@umt/db"; +import { normalizeTitle, toSimplified } from "@umt/domain"; import type { SourcesRuntime } from "../runtime.ts"; import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.ts"; @@ -58,18 +59,119 @@ export async function loadSubject(rt: SourcesRuntime, source: MetaSource, id: st } } -/** bgm 按标题搜动画条目(公开 BT 站资源兜底):取第一个,顺手写缓存 */ -export async function searchBangumi(rt: SourcesRuntime, keyword: string): Promise { +/** 按标题搜到的 bgm 条目与资源名称的核对结果 */ +export interface BangumiMatch { + subject: DoubanSubject; + /** 0~1:名称完全相同 1,去掉季名后相同 0.95,否则取字符二元组相似度 */ + score: number; + /** 资源名称与条目名称(不去季名)完全相同 */ + exact: boolean; + /** 命中的资源名称 / 条目名称 */ + name: string; + matched: string; +} + +/** 采用 bgm 条目的最低相似度 */ +export const BANGUMI_MATCH_MIN = 0.85; +/** 搜索结果自身没带别名时,最多为几个条目拉详情(infobox 别名) */ +const BANGUMI_DETAIL_FETCH = 3; + +function bigrams(s: string): Map { + const out = new Map(); + const chars = [...s]; + for (let i = 0; i + 1 < chars.length; i++) { + const g = chars[i]! + chars[i + 1]!; + out.set(g, (out.get(g) ?? 0) + 1); + } + return out; +} + +/** 归一化后的字符二元组 Dice 相似度 */ +export function nameSimilarity(a: string, b: string): number { + if (!a || !b) return 0; + if (a === b) return 1; + const x = bigrams(a); + const y = bigrams(b); + let total = 0; + let common = 0; + for (const n of x.values()) total += n; + for (const n of y.values()) total += n; + for (const [g, n] of x) common += Math.min(n, y.get(g) ?? 0); + return total ? (2 * common) / total : 0; +} + +/** 与 normalizeTitle 相同但不去季名后缀("葬送的芙莉莲 第二季" 与第一季条目区分开) */ +const plainNorm = (s: string) => + toSimplified(s.normalize("NFKC")) + .toLowerCase() + .replace(/&/g, "and") + .replace(/[^\p{L}\p{N}]+/gu, ""); + +/** 单个条目与资源名称的最佳匹配 */ +export function scoreBangumiSubject(names: readonly string[], s: DoubanSubject): BangumiMatch { + let best: BangumiMatch = { subject: s, score: 0, exact: false, name: "", matched: "" }; + const queries = [...new Set(names.map(normalizeTitle).filter((n) => n.length >= 2))]; + for (const raw of [s.title, ...s.aliases]) { + const full = plainNorm(raw); + const stripped = normalizeTitle(raw); + for (const q of queries) { + const score = + q === full ? 1 : q === stripped ? 0.95 : Math.max(nameSimilarity(q, full), nameSimilarity(q, stripped)); + if (score > best.score) best = { subject: s, score, exact: score === 1, name: q, matched: raw }; + } + } + return best; +} + +const yearCompatible = (year: number | undefined, s: DoubanSubject) => + year === undefined || s.year === null || Math.abs(year - s.year) <= 1; + +/** + * 从搜索结果里挑出与资源名称明确匹配的条目(H1:不再取第一个):相似度达到 BANGUMI_MATCH_MIN,且是唯一高分—— + * 其它达标条目都更低且最高分是完全相同,或按年份只剩一个。拿不准就返回 null(不走 bgm 投影)。 + */ +export function pickBangumiSubject( + names: readonly string[], + year: number | undefined, + subjects: readonly DoubanSubject[], +): BangumiMatch | null { + const scored = subjects + .map((s) => scoreBangumiSubject(names, s)) + .filter((m) => m.score >= BANGUMI_MATCH_MIN) + .sort((a, b) => b.score - a.score); + const top = scored[0]; + if (!top) return null; + if (scored.length === 1) return top; + if (top.exact && !scored[1]!.exact) return top; + const fit = scored.filter((m) => yearCompatible(year, m.subject)); + return year !== undefined && fit.length === 1 ? fit[0]! : null; +} + +/** + * bgm 按标题搜动画条目(公开 BT 站资源兜底),与资源名称(中文名 / 英文名 / 标题 / 副标题别名)核对后才采用。 + * 搜索结果里没有达标的,为前几个结果拉条目详情(带 infobox 别名,写缓存)再核对一次。 + */ +export async function searchBangumi( + rt: SourcesRuntime, + keyword: string, + names: readonly string[] = [keyword], + year?: number, +): Promise { const clients = metaClients(rt); if (!clients || keyword.trim().length < 2) return null; + let found: DoubanSubject[]; try { - const top = (await clients.bangumi.search(keyword.trim(), 5))[0]; - if (!top) return null; - const cached = toCached(top, rt.now()); - await saveDoubanSubject(rt.ctx, cached, BANGUMI_SUBJECT_REGION); - return cached; + found = (await clients.bangumi.search(keyword.trim(), 5)).map((s) => toCached(s, rt.now())); } catch (e) { rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败"); return null; } + if (!found.length) return null; + const hit = pickBangumiSubject(names, year, found); + if (hit) return hit; + // 已经有达标的条目(只是不唯一)时不再拉详情:别名只会让它更不唯一 + if (found.some((s) => scoreBangumiSubject(names, s).score >= BANGUMI_MATCH_MIN)) return null; + const detailed: DoubanSubject[] = []; + for (const s of found.slice(0, BANGUMI_DETAIL_FETCH)) detailed.push((await loadSubject(rt, "bangumi", s.id)) ?? s); + return pickBangumiSubject(names, year, [...detailed, ...found.slice(BANGUMI_DETAIL_FETCH)]); } diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index 98b18813..7b7beab1 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -257,7 +257,7 @@ export interface EscalationEvidence { /** 豆瓣条目(豆瓣路径) */ douban?: { id: string; title: string; year: number | null; source: string }; /** bgm 条目(bangumi 路径);search = 按资源标题搜到的 */ - bangumi?: { id: string; title: string; year: number | null; search: boolean }; + bangumi?: { id: string; title: string; year: number | null; search: boolean; score?: number }; } /** @@ -353,13 +353,24 @@ async function escalateIdentity( const bgmId = after.find((x) => x.provider === "bangumi")?.id; let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null; const search = !subject && !best && (await isPublicBt(rt, release.site_id)); - if (search) subject = await searchBangumi(rt, parsed.titleCn ?? parsed.title ?? ""); + // H1:按标题搜到的条目要与资源名称明确匹配(唯一高分)才用,不再取第一个 + const searched = search + ? await searchBangumi(rt, parsed.titleCn ?? parsed.title ?? "", bangumiNames(parsed), parsed.year) + : null; + if (searched) subject = searched.subject; if (subject) { const d = await subjectRecall(rt, parsed, subject); if (d) { - ev.bangumi = { id: subject.id, title: subject.title, year: subject.year, search }; - const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]); - if (res.status === "auto" || (!best && res.status !== "no_match")) { + ev.bangumi = { + id: subject.id, + title: subject.title, + year: subject.year, + search, + ...(searched ? { score: Math.round(searched.score * 100) / 100 } : {}), + }; + let res: IdentityResolution | null = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]); + if (searched) res = checkAgainstRelease(parsed, res, searched.exact); + if (res && (res.status === "auto" || (!best && res.status !== "no_match"))) { ev.via = "bangumi"; best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed }; } @@ -368,6 +379,27 @@ async function escalateIdentity( return best ?? { evidence: ev }; } +/** 与 bgm 条目核对用的资源名称:中文名、英文名、标题、副标题别名 */ +function bangumiNames(p: ParsedRelease): string[] { + return [p.titleCn, p.titleEn, p.title, ...(p.altTitles ?? [])].filter((n): n is string => !!n); +} + +/** + * bgm 搜索投影出的 TMDB 结果再与资源自己的解析核对(不能只靠条目名):资源年份 / 续作序号与候选冲突 → 不采用; + * 资源名称对不上候选、且 bgm 条目也只是相似(不是同名)→ 降为歧义,只作候选。 + */ +export function checkAgainstRelease( + parsed: ParsedRelease, + res: IdentityResolution, + bgmExact: boolean, +): IdentityResolution | null { + if (res.status !== "auto" || !res.best) return res; + const own = scoreCandidate(parsed, res.best.candidate); + if (own.yearMatch === "mismatch" || own.signals.sequel === "conflict") return null; + if (own.titleMatch === "none" && !bgmExact) return { ...res, status: "ambiguous", reason: "bangumi_unverified" }; + return res; +} + async function isPublicBt(rt: SourcesRuntime, siteId: string | null): Promise { if (!siteId) return false; const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst(); @@ -875,6 +907,7 @@ const DETAIL_TEXT: Record = { multiple_candidates_same_level: "多个候选同级", automation_off: "自动化已关闭", no_local_match: "没有命中订阅或本地作品,未查询 TMDB", + bangumi_unverified: "bgm 条目与资源名称只是相似", }; const detailText = (d: string) => DETAIL_TEXT[d] ?? d; diff --git a/apps/server/test/sources-identity/id-escalation.test.ts b/apps/server/test/sources-identity/id-escalation.test.ts index 544fa970..095942a2 100644 --- a/apps/server/test/sources-identity/id-escalation.test.ts +++ b/apps/server/test/sources-identity/id-escalation.test.ts @@ -110,10 +110,31 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => { name_cn: "葬送的芙莉莲 第二季", date: "2026-01-16", }); - if (u.host === "api.bgm.tv" && u.pathname === "/v0/search/subjects") + // 条目详情带 infobox 别名(搜索结果没有别名,罗马音标题靠详情里的别名核对) + if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects/400602") + return json({ + id: 400602, + name: "葬送のフリーレン", + name_cn: "葬送的芙莉莲", + date: "2023-09-29", + infobox: [{ key: "别名", value: [{ v: "Sousou no Frieren" }, { v: "Frieren: Beyond Journey's End" }] }], + }); + if (u.host === "api.bgm.tv" && u.pathname === "/v0/subjects/400777") + return json({ id: 400777, name: "迷宮のフリーレン", name_cn: "迷宫芙莉莲", date: "2023-09-29" }); + if (u.host === "api.bgm.tv" && u.pathname === "/v0/search/subjects") { + const kw = (JSON.parse(String(init?.body ?? "{}")) as { keyword?: string }).keyword ?? ""; + // 「Labyrinth Frieren」:bgm 模糊搜索的第一条是名字不同的条目,不能采用 + if (kw.startsWith("Labyrinth")) + return json({ + data: [ + { id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }, + { id: 400777, name: "迷宮のフリーレン", name_cn: "迷宫芙莉莲", date: "2023-09-29" }, + ], + }); return json({ data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }], }); + } return new Response("", { status: 404 }); }, }); @@ -379,6 +400,17 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => { expect(res3.outcome).toBe("auto"); expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031"); expect(metaRequests).toContain("POST api.bgm.tv/v0/search/subjects"); + expect((await row(r3.id)).resolution_evidence).toMatchObject({ + escalation: { via: "bangumi", bangumi: { id: "400602", search: true, score: 1 } }, + }); + // H1:搜索结果第一条与资源名称对不上(也没有别名能对上)→ 不走 bgm 投影,不自动绑定 + const r5 = await release(pub.id, "rel_A5", "[SubsPlease] Labyrinth Frieren - 03 (1080p)"); + const res5 = await resolveRelease(h.rt, { sourceReleaseId: r5.id, trigger: "manual" }); + expect(res5.outcome).not.toBe("auto"); + expect((await row(r5.id)).media_entity_id).toBeNull(); + const ev5 = (await row(r5.id)).resolution_evidence as { escalation?: { via: string | null; bangumi?: unknown } }; + expect(ev5.escalation?.via ?? null).toBeNull(); + expect(ev5.escalation?.bangumi).toBeUndefined(); // PT 站的资源不按标题搜 bgm const m = metaRequests.length; const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL"); From dca3012227b7617d8223cb828abe945400bebc56 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:06 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(identity):=20=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E4=B8=8D=E6=8A=8A=E4=B8=80=E6=AC=A1=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E6=89=A9=E6=95=A3=E7=BB=99=E4=B8=8D=E7=9B=B8=E5=AE=B9?= =?UTF-8?q?=E7=9A=84=E5=90=8C=E5=90=8D=E8=B5=84=E6=BA=90,=E5=8F=AC?= =?UTF-8?q?=E5=9B=9E=E8=AF=A6=E6=83=85=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=99?= =?UTF-8?q?=E8=B4=9F=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); From fa18015354d2d06d106f69139752dd3532148a7b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:12 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(identity):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E4=BA=8B=E9=A1=B9=E6=8C=89=E5=B9=B4=E4=BB=BD=E4=B8=8E=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=88=86=E7=BB=84,=E7=BB=84=E5=86=85=E4=B8=8D?= =?UTF-8?q?=E7=9B=B8=E5=AE=B9=E7=9A=84=E8=B5=84=E6=BA=90=E4=B8=8D=E9=9A=8F?= =?UTF-8?q?=E7=BB=84=E9=94=81=E5=AE=9A;=E5=A4=8D=E6=9F=A5=E8=80=97?= =?UTF-8?q?=E5=B0=BD=20/=20=E7=9B=B8=E5=85=B3=E6=97=A0=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=9A=84=E8=B5=84=E6=BA=90=E5=BC=80=E5=BE=85=E5=A4=84=E7=90=86?= =?UTF-8?q?(H3=E3=80=81M4=E3=80=81M5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分组键 = 归一化标题 + 年份 + 剧集线索(解析有才带);选择后组内年份 / 类型与所选作品不相容的 资源不指定、留在事项里(事项保持打开,代表资源换成留下的那条)。 TMDB 查询失败复查耗尽的资源不论是否命中本地作品都开 identity_unresolved;命中本地作品却无结果的 资源带站点 ID 也开(升级链推迟中仍不开),不再静默留池。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/routes/decisions.ts | 47 +++++++++++++++++-- .../service/identity-decisions.ts | 15 +++++- .../sources-identity/service/resolve.ts | 37 ++++++++++----- .../sources-identity/lazy-identity.test.ts | 4 +- .../test/sources-identity/resolve.test.ts | 4 +- 5 files changed, 88 insertions(+), 19 deletions(-) diff --git a/apps/server/src/modules/sources-identity/routes/decisions.ts b/apps/server/src/modules/sources-identity/routes/decisions.ts index b4fad28a..285fd0c3 100644 --- a/apps/server/src/modules/sources-identity/routes/decisions.ts +++ b/apps/server/src/modules/sources-identity/routes/decisions.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts"; import { sql } from "@umt/db"; +import { type ParsedRelease, parseRelease } from "@umt/domain"; import { z } from "zod"; import { ApiException } from "../../../http/errors.ts"; import { decisionHookFor } from "../../decision-hooks.ts"; @@ -41,6 +42,18 @@ const SummaryGroup = z.object({ latestAt: z.string(), }); +/** + * 组内资源与所选作品是否相容:资源有剧集线索而作品是电影不相容;资源年份与作品年份差超过 1 年不相容 + * (剧集允许资源年份更晚,后续季)。缺年份按相容处理。 + */ +export function releaseFitsEntity(p: ParsedRelease, e: { kind: string; year: number | null }): boolean { + const tv = p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined; + if (tv && e.kind === "movie") return false; + if (p.year === undefined || e.year === null) return true; + if (Math.abs(p.year - e.year) <= 1) return true; + return e.kind !== "movie" && p.year > e.year; +} + export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { const db = () => rt().ctx.db; @@ -119,7 +132,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { return toApi(row); }); - /** 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它 */ + /** + * 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它。 + * H3:年份 / 类型与所选作品不相容的资源不随组锁定,留在事项里单独处理。返回留下的资源数。 + */ async function applyToGroup( decisionId: string, subjectId: string, @@ -127,15 +143,35 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { dismissing: boolean, note: string | null, actor: Parameters[2], - ) { + ): Promise { const subject = await db() .selectFrom("source_release") .select(["media_entity_id"]) .where("id", "=", subjectId) .executeTakeFirst(); - if (!dismissing && !subject?.media_entity_id) return; + if (!dismissing && !subject?.media_entity_id) return 0; + const entity = dismissing + ? null + : await db() + .selectFrom("media_entity") + .select(["kind", "year"]) + .where("id", "=", subject!.media_entity_id!) + .executeTakeFirst(); + let kept = 0; for (const srId of others) { try { + if (!dismissing && entity) { + const r = await db() + .selectFrom("source_release") + .select(["title", "subtitle"]) + .where("id", "=", srId) + .executeTakeFirst(); + const parsed = r ? parseRelease(r.title, r.subtitle ? { subtitle: r.subtitle } : {}) : null; + if (parsed && !releaseFitsEntity(parsed, entity)) { + kept++; + continue; + } + } if (dismissing) { const pool = await db() .selectFrom("identity_candidate") @@ -155,6 +191,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败"); } } + return kept; } route(app, D.decisions.resolve, async ({ params, body, actor }) => { @@ -231,6 +268,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) { ); } if (others.length) await applyToGroup(d.id, d.subject_id, others, dismissing, body.note ?? null, actor); + // 组里还有没处理的资源(不相容而留下的):事项保持打开,代表资源已换成留下的那条 + if (!dismissing && others.length && (await groupMemberIds(db(), d.id)).length) { + return toApi(await db().selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow()); + } } // 站点登录需人工验证:"重试登录" = 入队刷新登录(结果另行更新账号状态与待处理) if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") { diff --git a/apps/server/src/modules/sources-identity/service/identity-decisions.ts b/apps/server/src/modules/sources-identity/service/identity-decisions.ts index 3547fec5..fa3b08ae 100644 --- a/apps/server/src/modules/sources-identity/service/identity-decisions.ts +++ b/apps/server/src/modules/sources-identity/service/identity-decisions.ts @@ -1,5 +1,6 @@ import type { DecisionOption, PoolCandidate } from "@umt/contracts"; import { sql } from "@umt/db"; +import type { ParsedRelease } from "@umt/domain"; import { storageSettings } from "../../../shared-cache.ts"; import type { ServerContext } from "../../types.ts"; import { jsonb } from "../rows.ts"; @@ -8,7 +9,7 @@ import { closeDecisions, type Exec, openDecision } from "./common.ts"; // DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13): // - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项; -// - 同一来源 + 同一归一化标题合并为一条事项(dedupeKey identity-group::),成员 = 池里 +// - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group::<分组键>),成员 = 池里 // decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条); // - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项; // - unmatched 池条目超过 storage.retentionDays.identityPoolArchive 天没有新证据就归档。 @@ -19,6 +20,18 @@ export const GROUP_LIST_LIMIT = 50; export const groupDedupeKey = (siteId: string, groupKey: string) => `identity-group:${siteId}:${groupKey}`; +/** + * 合并事项的分组键(H3):归一化标题,解析出年份 / 剧集线索时带上(`标题:年份:tv`)。同名不同年份或电影 / 剧集 + * 不进同一组,选择也就不会被整组套用到别的作品上。 + */ +export function identityGroupKey( + normalized: string, + p: Pick, +) { + const tv = p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined; + return [normalized, ...(p.year !== undefined ? [String(p.year)] : []), ...(tv ? ["tv"] : [])].join(":"); +} + /** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */ function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] { return [ diff --git a/apps/server/src/modules/sources-identity/service/resolve.ts b/apps/server/src/modules/sources-identity/service/resolve.ts index a41e0008..b7018efc 100644 --- a/apps/server/src/modules/sources-identity/service/resolve.ts +++ b/apps/server/src/modules/sources-identity/service/resolve.ts @@ -46,7 +46,12 @@ import { unitsAllowed, } from "./episode-resolution.ts"; import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.ts"; -import { detachFromIdentityDecisions, openGroupDecision, refreshGroupDecision } from "./identity-decisions.ts"; +import { + detachFromIdentityDecisions, + identityGroupKey, + openGroupDecision, + refreshGroupDecision, +} from "./identity-decisions.ts"; import { loadSubject, searchBangumi } from "./meta-subjects.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; @@ -933,6 +938,20 @@ interface ResolveScope { escalation: EscalationEvidence | null; } +/** 进池时是否开(或保留)身份类待处理 */ +export function decisionWanted( + reason: PoolReason, + candidateCount: number, + tracked: boolean, + delay: number | null, + pendingEscalation: boolean, +): boolean { + if (pendingEscalation) return false; + if (reason === "ambiguous" || reason === "proposed") return candidateCount > 0; + if (reason === "lookup_failed") return delay === null; + return tracked && reason === "no_match"; +} + /** 推迟的升级(限额已满 / 入库时不抓)多久后复查 */ const ESCALATION_RETRY_MS = 15 * 60_000; @@ -997,22 +1016,18 @@ async function toPool( evidenceVersion: version, query, }; - // 只收相关的:查过 TMDB 仍有歧义(有候选可选),或与本地作品相关却无结果 / 查询失败且复查已停 - // ID-3:能从站点拿到 ID 的资源不开"没识别出作品":升级链还在推迟中(之后会抓详情)不开任何事项; - // 资源上已有 IMDb / TMDB / 豆瓣 ID 却仍无结果的,不开 identity_unresolved(只留在池里,可手动指定) - const hasSiteIds = siteExplicitIds(release.raw).length > 0 || (scope.escalation?.ids.length ?? 0) > 0; - const wantsDecision = pendingEscalation - ? false - : reason === "ambiguous" || reason === "proposed" - ? candidates.length > 0 - : scope.tracked && !hasSiteIds && (reason === "no_match" || (reason === "lookup_failed" && delay === null)); + // 只收相关的:查过 TMDB 仍有歧义(有候选可选),与本地作品相关却无结果,或查询失败且复查已停。 + // ID-3:升级链还在推迟中(之后会抓详情)不开任何事项。 + // 复查耗尽的查询失败不论是否命中本地作品都开(新作品收录规则 / eager 触发的也要让人看到); + // 与本地作品相关却无结果的,资源带站点 ID 也开(升级链已跑过仍没认出,不再静默留池) + const wantsDecision = decisionWanted(reason, candidates.length, scope.tracked, delay, pendingEscalation); let decisionId: string | null = null; if (wantsDecision) { const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0; decisionId = await openGroupDecision(ctx, trx, { kind: ambiguous ? "identity_ambiguous" : "identity_unresolved", siteId: release.site_id, - groupKey: normalized ?? release.id, + groupKey: normalized ? identityGroupKey(normalized, parsed) : release.id, workTitle: parsed.title ?? null, sourceReleaseId: release.id, releaseTitle: release.title, diff --git a/apps/server/test/sources-identity/lazy-identity.test.ts b/apps/server/test/sources-identity/lazy-identity.test.ts index dee354d0..e3c53e3a 100644 --- a/apps/server/test/sources-identity/lazy-identity.test.ts +++ b/apps/server/test/sources-identity/lazy-identity.test.ts @@ -174,7 +174,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => { const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); expect(res.outcome).toBe("ambiguous"); } - const twinsGroup = `identity-group:${siteId}:twins`; + const twinsGroup = `identity-group:${siteId}:twins:1988`; const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup); expect(ds).toHaveLength(1); const d = ds[0]!; @@ -210,7 +210,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => { ids.push(r.id); expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous"); } - const open = (await identityDecisions()).filter((x) => x.dedupe_key === `identity-group:${siteId}:nightfall`); + const open = (await identityDecisions()).filter((x) => x.dedupe_key === `identity-group:${siteId}:nightfall:1995`); expect(open).toHaveLength(1); const d = open[0]!; const pool = await h.db diff --git a/apps/server/test/sources-identity/resolve.test.ts b/apps/server/test/sources-identity/resolve.test.ts index 00b31fb3..d391215e 100644 --- a/apps/server/test/sources-identity/resolve.test.ts +++ b/apps/server/test/sources-identity/resolve.test.ts @@ -147,7 +147,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => { expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now()); const d = await h.db.selectFrom("decision").selectAll().where("id", "=", res.decisionId!).executeTakeFirstOrThrow(); expect(d).toMatchObject({ kind: "identity_ambiguous", status: "open", subject_id: r.id, severity: "warning" }); - expect(d.dedupe_key).toBe(`identity-group:${siteId}:twins`); + expect(d.dedupe_key).toBe(`identity-group:${siteId}:twins:1988`); expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([ "c0", "c1", @@ -178,7 +178,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => { await h.db .selectFrom("decision") .select("id") - .where("dedupe_key", "=", `identity-group:${siteId}:twins`) + .where("dedupe_key", "=", `identity-group:${siteId}:twins:1988`) .execute(), ).toHaveLength(1); }); From 074dab04cafdb3a1965d27aa77f9e6fa8b62dd4e Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(identity):=20=E5=AD=A3=E8=A1=A8?= =?UTF-8?q?=E5=8F=AA=E8=AE=A1=20TMDB=20/=20=E4=BA=BA=E5=B7=A5=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E6=88=96=E5=B7=B2=E7=A1=AE=E8=AE=A4=E7=9A=84=E5=AD=A3?= =?UTF-8?q?,=E8=B5=84=E6=BA=90=E6=87=92=E5=BB=BA=E7=9A=84=E4=B8=B4?= =?UTF-8?q?=E6=97=B6=E5=AD=A3=E4=B8=8D=E7=AE=97(M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/service/episode-resolution.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/sources-identity/service/episode-resolution.ts b/apps/server/src/modules/sources-identity/service/episode-resolution.ts index 0b346edd..670fcfbd 100644 --- a/apps/server/src/modules/sources-identity/service/episode-resolution.ts +++ b/apps/server/src/modules/sources-identity/service/episode-resolution.ts @@ -38,7 +38,11 @@ const ENDED_STATUSES = new Set(["ended", "canceled", "cancelled"]); /** 待处理证据里最多列多少条资源 */ const DECISION_LIST_LIMIT = 50; -/** 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元 */ +/** + * 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。 + * 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表, + * 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。 + */ export async function loadSeasonTable(exec: Exec, entityId: string): Promise { const entity = await exec .selectFrom("media_entity") @@ -52,6 +56,8 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise`to_char(air_date, 'YYYY-MM-DD')`.as("air"), ]) .where("media_entity_id", "=", entityId) @@ -63,6 +69,7 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise(); for (const u of units) { if (u.kind !== "season" || u.season_number === null || u.season_number < 0) continue; + if (u.status !== "confirmed" && u.source !== "tmdb" && u.source !== "user") continue; const cur = seasons.get(u.season_number); // 重复季单元(并发懒建遗留)取已知的较大总集数 const total = u.episode_total ?? null; From 1959e62f3f8d1359a0dc6b9179fb4cde2d69bd2d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH 5/7] =?UTF-8?q?feat(sources):=20=E7=AB=99=E7=82=B9?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E8=BF=9E=E7=BB=AD=E5=A4=B1=E8=B4=A5=203=20?= =?UTF-8?q?=E6=AC=A1=E5=BC=80=E7=AB=99=E7=82=B9=E4=B8=8D=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E4=BA=8B=E9=A1=B9,=E6=8C=89=E7=AB=99=E7=82=B9=E5=8E=BB?= =?UTF-8?q?=E9=87=8D,=E6=81=A2=E5=A4=8D=E5=90=8E=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=85=B3=E9=97=AD(M9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 连续失败次数记在 site.snapshot_cursor.failStreak;凭据失效仍走原有登录失效事项。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../sources-identity/service/snapshot.ts | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/server/src/modules/sources-identity/service/snapshot.ts b/apps/server/src/modules/sources-identity/service/snapshot.ts index 63a65e29..d0dbee3d 100644 --- a/apps/server/src/modules/sources-identity/service/snapshot.ts +++ b/apps/server/src/modules/sources-identity/service/snapshot.ts @@ -8,7 +8,7 @@ import { jsonb } from "../rows.ts"; import type { SourcesRuntime } from "../runtime.ts"; import { siteCan } from "../site-kinds.ts"; import { enqueueRelogin } from "./account.ts"; -import { type Exec, openDecision } from "./common.ts"; +import { closeDecisions, type Exec, openDecision } from "./common.ts"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts"; // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 @@ -129,6 +129,35 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site await enqueueRelogin(rt, site.id); } +/** 站点快照连续失败(改版 / Cloudflare 挑战 / 限流 / 网络,凭据失效另有事项)多少次后开待处理 */ +export const SNAPSHOT_FAIL_DECISION_AT = 3; + +/** 快照连续失败达到阈值 → 开站点不可达事项(按站点去重);恢复(本次成功或部分成功)→ 自动关闭 */ +async function syncSnapshotFailureDecision( + rt: SourcesRuntime, + trx: Exec, + site: SiteRow, + failStreak: number, + err: { kind: string; message: string } | null, +) { + const dedupeKey = `site-snapshot:${site.id}`; + if (failStreak === 0) { + await closeDecisions(rt.ctx, trx, dedupeKey, "resolved", { by: "snapshot_recovered" }, "system"); + return; + } + if (failStreak < SNAPSHOT_FAIL_DECISION_AT || !err) return; + await openDecision(rt.ctx, trx, { + kind: "source_unreachable", + severity: "warning", + title: `站点「${site.name}」列表抓取连续失败`, + summary: `连续 ${failStreak} 次没抓到列表(${err.kind}:${err.message})。可能是站点改版、Cloudflare 验证或限流;检查站点设置或稍后重试,恢复后自动关闭。`, + subjectType: "site", + subjectId: site.id, + dedupeKey, + evidence: { siteId: site.id, kind: err.kind, message: err.message.slice(0, 300), failStreak }, + }); +} + export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Promise { const { db } = rt.ctx; const none = { snapshotId: null, pages: [], itemCount: 0, newCount: 0, releaseIds: [] }; @@ -215,6 +244,9 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr if (error instanceof SiteError && error.isAuthError) { await onAuthError(rt, trx, site, error); } else if (input.kind !== "live_search") { + const prevStreak = Number((site.snapshot_cursor as { failStreak?: unknown } | null)?.failStreak) || 0; + const failStreak = status === "failed" ? prevStreak + 1 : 0; + await syncSnapshotFailureDecision(rt, trx, site, failStreak, errJson); await trx .updateTable("site") .set({ @@ -226,6 +258,7 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr lastItemCount: releases.length, lastStatus: status, lastError: errJson, + failStreak, at: rt.now().toISOString(), }), }) From 92bce3b6012e7b7d12eb8b4898809899acfdf3ec Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(identity):=20=E6=94=B9=20TMDB=20?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=88=A0=E6=97=A7=E4=BD=9C=E5=93=81=E7=9A=84?= =?UTF-8?q?=20tmdb=20=E5=88=AB=E5=90=8D;=E5=90=88=E5=B9=B6=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=97=B6=E5=86=85=E5=AE=B9=E5=8D=95=E5=85=83=E8=BF=81?= =?UTF-8?q?=E5=88=B0=E7=9B=AE=E6=A0=87=E4=BD=9C=E5=93=81(M3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改映射:没锁的 tmdb 来源作品别名删除,人工 / 站点别名保留,刷新后按新详情重新登记。 合并:被合并作品的单元改归目标作品(单元级别名一并改),与目标同季同集的按重复单元合并、 所有引用改指向保留的那条;目标是电影时源单元退役。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../service/identity-admin.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) 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 441d8f6a..7b02c6ff 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -33,6 +33,7 @@ import { clearTmdbMetadata } from "./metadata.ts"; import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts"; import { recordAttempt } from "./resolve.ts"; import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts"; +import { mergeDuplicateUnits } from "./unit-merge.ts"; // 人工纠错:指定来源资源身份(加锁)、改作品外部映射(新增修订、旧映射退役)、合并作品(旧 ID 跳转)。 // 所有变更留 identity_revision / resolution_attempt / audit_event,不直接覆盖历史。 @@ -314,6 +315,15 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in .execute(); for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed"); await clearTmdbMetadata(trx, id, rt.now()); + // 旧 TMDB 条目带来的别名(没锁的)删除:留着会让旧作品的同名资源继续本地召回到这里;人工 / 站点别名保留, + // 刷新后按新详情重新登记 tmdb 别名 + await trx + .deleteFrom("title_alias") + .where("media_entity_id", "=", id) + .where("content_unit_id", "is", null) + .where("source", "=", "tmdb") + .where("locked", "=", false) + .execute(); } await audit( trx, @@ -414,7 +424,7 @@ export async function updateExternalIdentity( /** * 合并重复作品:from → into。旧 ID 保留(status=merged + merged_into_id)并写 identity_redirect; - * 本模块的引用(来源资源、别名、待识别池、外部身份)迁到目标;其它模块的表按 merged_into_id 跳转解析。 + * 本模块的引用(来源资源、别名、待识别池、外部身份、内容单元)迁到目标;其它模块的表按 merged_into_id 跳转解析。 */ export async function mergeEntities( rt: SourcesRuntime, @@ -483,6 +493,35 @@ export async function mergeEntities( select ${intoResolved}, null, alias, normalized, language, kind, source, locked from title_alias where media_entity_id = ${fromId} and content_unit_id is null on conflict (media_entity_id, content_unit_id, normalized, kind) do nothing`.execute(trx); + // 内容单元随作品迁到目标(M3):单元改归目标作品;与目标已有单元同季同集的 + // 按重复单元合并(所有引用改指向保留的那条,见 unit-merge.ts)。目标是电影时源作品的单元退役 + const into = await trx + .selectFrom("media_entity") + .select(["kind"]) + .where("id", "=", intoResolved) + .executeTakeFirstOrThrow(); + if (into.kind === "movie") { + await trx.updateTable("content_unit").set({ status: "retired" }).where("media_entity_id", "=", fromId).execute(); + } else { + const moved = ( + await trx + .updateTable("content_unit") + .set({ media_entity_id: intoResolved }) + .where("media_entity_id", "=", fromId) + .returning(["id"]) + .execute() + ).map((u) => u.id); + if (moved.length) { + // 单元级元数据 / 覆盖只挂 content_unit_id,随单元走;单元级别名还带作品 ID,一并改 + await trx + .updateTable("title_alias") + .set({ media_entity_id: intoResolved }) + .where("media_entity_id", "=", fromId) + .where("content_unit_id", "in", moved) + .execute(); + await mergeDuplicateUnits(trx, intoResolved); + } + } await trx .updateTable("source_release") .set({ media_entity_id: intoResolved }) From 17aa9304c06fe98819bb0756bdcca0766222d031 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 23:31:20 +0800 Subject: [PATCH 7/7] =?UTF-8?q?test(identity):=20=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=87=86=E7=A1=AE=E5=BA=A6=E4=B8=8E=E5=A4=B1=E8=B4=A5=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bgm 核对、缓存相容、分组键、组内相容、季表、快照失败事项、复查耗尽事项、详情失败不写负缓存、 改映射后旧别名不再召回、合并作品迁移单元。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei --- .../identity-accuracy.test.ts | 478 ++++++++++++++++++ 1 file changed, 478 insertions(+) create mode 100644 apps/server/test/sources-identity/identity-accuracy.test.ts diff --git a/apps/server/test/sources-identity/identity-accuracy.test.ts b/apps/server/test/sources-identity/identity-accuracy.test.ts new file mode 100644 index 00000000..159b41b5 --- /dev/null +++ b/apps/server/test/sources-identity/identity-accuracy.test.ts @@ -0,0 +1,478 @@ +/** + * 识别准确度与失败可见性(T4):bgm 搜索结果要与资源名称核对;人工指定回填的识别缓存不扩散给年份 / 类型不相容的同名资源; + * 合并事项按年份 / 类型分组,组内不相容的资源不随组锁定;临时季不进季表;快照连续失败 / TMDB 复查耗尽开待处理; + * 召回拉详情失败不写负缓存;改 TMDB 映射后旧 tmdb 别名不再召回;合并作品时内容单元迁到目标作品。全部走 mock,零真实请求。 + */ +import { SiteError } from "@umt/adapters"; +import { sql } from "@umt/db"; +import { parseRelease } from "@umt/domain"; +import { createMockTmdb, type MockTmdb, type TmdbFixture } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { releaseFitsEntity } from "../../src/modules/sources-identity/routes/decisions.ts"; +import type { DoubanSubject } from "../../src/modules/sources-identity/service/douban-subject.ts"; +import { loadSeasonTable } from "../../src/modules/sources-identity/service/episode-resolution.ts"; +import { + assignReleaseIdentity, + mergeEntities, + setEntityIdentity, +} from "../../src/modules/sources-identity/service/identity-admin.ts"; +import { identityGroupKey } from "../../src/modules/sources-identity/service/identity-decisions.ts"; +import { pickBangumiSubject } from "../../src/modules/sources-identity/service/meta-subjects.ts"; +import { decisionWanted, resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; +import { SNAPSHOT_FAIL_DECISION_AT, snapshotSite } from "../../src/modules/sources-identity/service/snapshot.ts"; +import { entryCompatible } from "../../src/modules/sources-identity/service/tmdb-recognize-cache.ts"; +import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts"; + +const pg = await pgAvailable(); + +const subject = (id: string, title: string, aliases: string[] = [], year: number | null = 2023): DoubanSubject => ({ + id, + title, + aliases, + year, + source: "bangumi_api", + ref: `https://bgm.tv/subject/${id}`, + at: "2026-10-01T00:00:00.000Z", +}); + +describe("纯函数:bgm 核对 / 缓存相容 / 分组键 / 组内相容 / 事项开关", () => { + it("bgm 搜索结果:第一条名称对不上不采用;唯一同名才采用;多个同名靠年份区分,区分不了不采用", () => { + const wrong = subject("1", "迷宫芙莉莲", ["迷宮のフリーレン"]); + const right = subject("2", "葬送的芙莉莲", ["葬送のフリーレン", "Sousou no Frieren"]); + expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong])).toBeNull(); + expect(pickBangumiSubject(["Sousou no Frieren"], undefined, [wrong, right])?.subject.id).toBe("2"); + // 第二季条目去掉季名后也同名,但第一季条目是完全同名:取完全同名的 + const s2 = subject("3", "葬送的芙莉莲 第二季", [], 2026); + expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [s2, right])?.subject.id).toBe("2"); + // 两个完全同名(重制版):资源年份能区分才采用 + const remake = subject("4", "葬送的芙莉莲", [], 2030); + expect(pickBangumiSubject(["葬送的芙莉莲"], undefined, [right, remake])).toBeNull(); + expect(pickBangumiSubject(["葬送的芙莉莲"], 2030, [right, remake])?.subject.id).toBe("4"); + }); + + it("识别缓存条目与资源相容:年份 ±1(剧集允许更晚),剧集线索不配电影,负缓存总相容", () => { + const p = (t: string) => parseRelease(t); + expect(entryCompatible(p("Echo Valley 2025 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(true); + expect(entryCompatible(p("Echo Valley 2010 1080p"), { id: 1, type: "movie", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley S01E01 2010"), { id: 1, type: "tv", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley S03E01 2027"), { id: 1, type: "tv", year: "2025" })).toBe(true); + expect(entryCompatible(p("Echo Valley S01E01"), { id: 1, type: "movie", year: "2025" })).toBe(false); + expect(entryCompatible(p("Echo Valley 2010"), { id: 0 })).toBe(true); + }); + + it("合并事项分组键带年份与剧集线索;组内资源与所选作品的相容判定", () => { + expect(identityGroupKey("twins", parseRelease("Twins.1988.1080p.BluRay"))).toBe("twins:1988"); + expect(identityGroupKey("show", parseRelease("Show.S01E02.1080p"))).toBe("show:tv"); + expect(identityGroupKey("show", parseRelease("Show 1080p WEB-DL"))).toBe("show"); + expect(releaseFitsEntity(parseRelease("Twins.1988.1080p"), { kind: "movie", year: 1988 })).toBe(true); + expect(releaseFitsEntity(parseRelease("Twins.2016.1080p"), { kind: "movie", year: 1988 })).toBe(false); + expect(releaseFitsEntity(parseRelease("Twins.S01E01.1080p"), { kind: "movie", year: 1988 })).toBe(false); + expect(releaseFitsEntity(parseRelease("Twins.S03E01.2020"), { kind: "series", year: 2016 })).toBe(true); + expect(releaseFitsEntity(parseRelease("Twins.1080p"), { kind: "movie", year: 1988 })).toBe(true); + }); + + it("进池开事项:查询失败复查耗尽不论是否命中本地作品;无结果命中本地作品就开;升级推迟中不开", () => { + expect(decisionWanted("lookup_failed", 0, false, null, false)).toBe(true); + expect(decisionWanted("lookup_failed", 0, true, 60_000, false)).toBe(false); + expect(decisionWanted("no_match", 0, true, 60_000, false)).toBe(true); + expect(decisionWanted("no_match", 0, false, null, false)).toBe(false); + expect(decisionWanted("no_match", 0, true, null, true)).toBe(false); + expect(decisionWanted("ambiguous", 2, false, 60_000, false)).toBe(true); + }); +}); + +const fx = ( + type: "movie" | "tv", + id: number, + en: string, + zh: string, + date: string, + alt: string[] = [], +): TmdbFixture => ({ + type, + id, + original_title: en, + original_language: "en", + origin_country: ["US"], + date, + titles: { "zh-CN": zh, "en-US": en }, + popularity: 10, + alternative_titles: alt.map((title) => ({ iso_3166_1: "US", title, type: "" })), + external_ids: {}, + ...(type === "tv" + ? { + seasons: [ + { season_number: 1, air_date: date, names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, episodes: [] }, + ], + } + : {}), +}); + +describe.skipIf(!pg)("识别准确度与失败可见性", () => { + let h: Harness; + let tmdb: MockTmdb; + let siteId: string; + let n = 0; + const USER = { type: "user" as const, id: "tester" }; + + beforeAll(async () => { + tmdb = await createMockTmdb({ + fixtures: [ + // 同名两部剧集:2025 与 2010 + fx("tv", 880001, "Echo Valley", "回声谷", "2025-03-01"), + fx("tv", 880002, "Echo Valley", "回声谷", "2010-03-01"), + // 同名电影各两部:1995 与 2016(各自同年同名 → 歧义) + fx("movie", 880011, "Harbor Lights", "港湾灯火", "1995-05-01"), + fx("movie", 880012, "Harbor Lights", "港口之光", "1995-08-01"), + fx("movie", 880013, "Harbor Lights", "海港之灯", "2016-05-01"), + fx("movie", 880014, "Harbor Lights", "港灯", "2016-08-01"), + // 英文名只在 TMDB 别名里:不取详情就对不上 + fx("movie", 880021, "Glass Field", "玻璃原野", "2024-04-01", ["Quartz Meadow"]), + // 改映射:旧作品带别名 Copper Harbour,新作品没有 + fx("movie", 880031, "Copper Bay", "铜湾", "2020-06-01", ["Copper Harbour"]), + fx("movie", 880032, "Iron Bay", "铁湾", "2020-06-01"), + ], + }); + h = await createHarness("identity_accuracy", { tmdbUrl: tmdb.url }); + siteId = (await seedSite(h, { key: "accuracy", baseUrl: "https://accuracy.example/" })).site.id; + }); + afterAll(async () => { + await h?.close(); + await tmdb?.close(); + }); + + const release = (title: string) => + h.db + .insertInto("source_release") + .values({ site_id: siteId, site_release_id: `acc-${++n}`, title, raw: JSON.stringify({}) } as never) + .returningAll() + .executeTakeFirstOrThrow(); + const row = (id: string) => + h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow(); + const tmdbIdOf = async (entityId: string) => + ( + await h.db + .selectFrom("external_identity") + .select("external_id") + .where("media_entity_id", "=", entityId) + .where("provider", "=", "tmdb") + .where("status", "=", "accepted") + .executeTakeFirstOrThrow() + ).external_id; + const recognizeKeys = async () => + (await sql<{ key: string }>`select key from kv_cache where region = 'tmdb.recognize'`.execute(h.db)).rows.map( + (r) => r.key, + ); + + it("H2:人工指定不扩散——无年份的资源不回填缓存,同名剧集照常召回出两个候选而不是只剩被指定的那部", async () => { + const a = await release("Echo Valley S01E01 1080p WEB-DL-GRP"); + expect((await resolveRelease(h.rt, { sourceReleaseId: a.id, trigger: "search" })).outcome).toBe("ambiguous"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-None-1"); + const b = await release("Echo Valley S01E02 1080p WEB-DL-GRP"); + const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" }); + expect(rb.outcome).toBe("ambiguous"); + expect((await row(b.id)).media_entity_id).toBeNull(); + }); + + it("H2:指定到年份不相容的作品不回填;同名不同年份的资源按自己的年份认到另一部", async () => { + const a = await release("Echo Valley 2010 S01E01 1080p HDTV-OLD"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2010-1"); + const b = await release("Echo Valley 2010 S01E05 1080p HDTV-OLD"); + const rb = await resolveRelease(h.rt, { sourceReleaseId: b.id, trigger: "search" }); + expect(rb.outcome).toBe("auto"); + expect(await tmdbIdOf(rb.mediaEntityId!)).toBe("880002"); + }); + + it("H2:用户指定且年份相容的照常回填(同年同名命中);Agent 的指定不回填", async () => { + const a = await release("Echo Valley 2025 S01E03 2160p WEB-DL-NEW"); + await assignReleaseIdentity( + h.rt, + a.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + USER, + ); + expect(await recognizeKeys()).toContain("[电视剧][zh-CN]Echo Valley-2025-1"); + const c = await release("Echo Valley 2025 S02E01 2160p WEB-DL-NEW"); + await assignReleaseIdentity( + h.rt, + c.id, + { external: { provider: "tmdb", entityType: "tv", externalId: "880001" }, lock: true }, + { type: "agent", id: "agent-1" }, + ); + expect(await recognizeKeys()).not.toContain("[电视剧][zh-CN]Echo Valley-2025-2"); + }); + + it("H3:同名不同年份分成两个合并事项;旧的宽分组里年份不相容的资源不随组锁定,留在事项里", async () => { + const ids: string[] = []; + for (const t of [ + "Harbor.Lights.1995.1080p.BluRay-AAA", + "Harbor.Lights.1995.720p.BluRay-BBB", + "Harbor.Lights.2016.1080p.WEB-DL-CCC", + ]) { + const r = await release(t); + ids.push(r.id); + expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous"); + } + const decisions = await h.db + .selectFrom("decision") + .select(["id", "dedupe_key", "subject_id"]) + .where("dedupe_key", "like", `identity-group:${siteId}:harborlights%`) + .where("status", "=", "open") + .execute(); + expect(decisions.map((d) => d.dedupe_key).sort()).toEqual([ + `identity-group:${siteId}:harborlights:1995`, + `identity-group:${siteId}:harborlights:2016`, + ]); + // 模拟升级前按「站点 + 标题」建的宽分组:2016 的资源也挂在 1995 的事项上 + const d1995 = decisions.find((d) => d.dedupe_key.endsWith(":1995"))!; + await h.db + .updateTable("identity_candidate") + .set({ decision_id: d1995.id }) + .where("source_release_id", "=", ids[2]!) + .execute(); + const res = await h.req(`/decisions/${d1995.id}/resolve`, { body: { optionKey: "c0" } }); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { status: string; subject_id?: string; subjectId?: string } }; + expect(body.data.status).toBe("open"); + const r1995 = await Promise.all([row(ids[0]!), row(ids[1]!)]); + expect(r1995.every((r) => r.media_entity_id && r.resolution_locked)).toBe(true); + expect(r1995[0]!.media_entity_id).toBe(r1995[1]!.media_entity_id); + const r2016 = await row(ids[2]!); + expect(r2016.media_entity_id).toBeNull(); + expect(r2016.resolution_locked).toBe(false); + const after = await h.db.selectFrom("decision").selectAll().where("id", "=", d1995.id).executeTakeFirstOrThrow(); + expect(after.status).toBe("open"); + expect(after.subject_id).toBe(ids[2]); + }); + + it("季表只计 TMDB / 人工 / 已确认的季:资源懒建的临时季不进季表、不抬高最新季", async () => { + const e = await h.db + .insertInto("media_entity") + .values({ kind: "series", title: "季表测试" }) + .returning("id") + .executeTakeFirstOrThrow(); + await h.db + .insertInto("content_unit") + .values([ + { + media_entity_id: e.id, + kind: "season", + season_number: 1, + episode_total: 10, + status: "confirmed", + source: "tmdb", + }, + { media_entity_id: e.id, kind: "season", season_number: 2, status: "provisional", source: "user" }, + { media_entity_id: e.id, kind: "season", season_number: 5, status: "provisional", source: "release" }, + { media_entity_id: e.id, kind: "season", season_number: 6, status: "provisional", source: "file" }, + ]) + .execute(); + const table = await loadSeasonTable(h.db, e.id); + expect(table.seasons.map((s) => s.season)).toEqual([1, 2]); + expect(table.latestSeason).toBe(2); + }); + + it("快照连续失败 N 次开站点不可达事项(按站点去重),恢复后自动关闭", async () => { + // 用有站点定义(支持列表抓取)的 key,适配器换成会失败的假实现 + const { site } = await seedSite(h, { key: "hhanclub", baseUrl: "https://hhanclub.example/" }); + let fail = true; + const orig = h.rt.siteAdapter.bind(h.rt); + // biome-ignore lint/suspicious/noExplicitAny: 测试替换适配器 + (h.rt as any).siteAdapter = (s: { id: string }) => + s.id === site.id + ? { + definitionVersion: "test", + list: async () => { + if (fail) throw new SiteError(site.id, "challenge", "Cloudflare 验证页"); + return { items: [], totalPages: 1 }; + }, + } + : orig(s as never); + const open = () => + h.db + .selectFrom("decision") + .select(["id", "kind", "status"]) + .where("dedupe_key", "=", `site-snapshot:${site.id}`) + .where("status", "=", "open") + .execute(); + for (let i = 1; i < SNAPSHOT_FAIL_DECISION_AT; i++) { + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed"); + expect(await open()).toHaveLength(0); + } + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("failed"); + expect(await open()).toEqual([expect.objectContaining({ kind: "source_unreachable" })]); + // 再失败一次不重复开 + await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" }); + expect(await open()).toHaveLength(1); + fail = false; + expect((await snapshotSite(h.rt, { siteId: site.id, kind: "periodic" })).status).toBe("ok"); + expect(await open()).toHaveLength(0); + const closed = await h.db + .selectFrom("decision") + .select(["status", "resolution"]) + .where("dedupe_key", "=", `site-snapshot:${site.id}`) + .executeTakeFirstOrThrow(); + expect(closed).toMatchObject({ status: "resolved", resolution: { by: "snapshot_recovered" } }); + // biome-ignore lint/suspicious/noExplicitAny: 还原 + (h.rt as any).siteAdapter = orig; + }); + + it("TMDB 查询失败复查耗尽:不命中本地作品的资源也开待处理", async () => { + const r = await release("Zephyr Quill 2026 1080p WEB-DL-ZZ"); + tmdb.fault(/\/3\/search\//, { status: 401, times: 100 }); + try { + const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" }); + expect(first.outcome).toBe("lookup_failed"); + expect(first.decisionId).toBeNull(); + // 已复查 7 次(同一证据):这次是第 8 次,停止自动复查 + await h.db.updateTable("identity_candidate").set({ attempts: 7 }).where("source_release_id", "=", r.id).execute(); + const last = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "review", force: true }); + expect(last.outcome).toBe("lookup_failed"); + expect(last.decisionId).not.toBeNull(); + const d = await h.db + .selectFrom("decision") + .select(["kind", "status", "summary"]) + .where("id", "=", last.decisionId!) + .executeTakeFirstOrThrow(); + expect(d).toMatchObject({ kind: "identity_unresolved", status: "open" }); + expect(d.summary).toMatch(/元数据查询多次失败/); + const pool = await h.db + .selectFrom("identity_candidate") + .select(["next_review_at"]) + .where("source_release_id", "=", r.id) + .executeTakeFirstOrThrow(); + expect(pool.next_review_at).toBeNull(); + } finally { + tmdb.clearFaults(); + } + }); + + it("召回时候选拉详情失败:这一轮无匹配不写负缓存;详情恢复后照常认出", async () => { + const r = await release("Quartz Meadow 2024 1080p WEB-DL-QM"); + tmdb.fault(/\/3\/movie\/880021/, { status: 404, times: 100 }); + try { + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome).toBe("no_match"); + expect(await recognizeKeys()).not.toContain("[未知][zh-CN]Quartz Meadow-2024-None"); + } finally { + tmdb.clearFaults(); + } + const again = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(again.outcome).toBe("auto"); + expect(await tmdbIdOf(again.mediaEntityId!)).toBe("880021"); + }); + + it("改 TMDB 映射:旧作品的 tmdb 别名删除、人工别名保留;旧别名的同名资源不再本地召回到这部作品", async () => { + const r = await release("Copper Bay 2020 1080p BluRay-CB"); + const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); + expect(res.outcome).toBe("auto"); + const id = res.mediaEntityId!; + await h.db + .insertInto("title_alias") + .values({ media_entity_id: id, alias: "铜湾人工名", normalized: "铜湾人工名", source: "user" }) + .execute(); + const aliases = async () => + ( + await h.db.selectFrom("title_alias").select(["alias", "source"]).where("media_entity_id", "=", id).execute() + ).map((a) => `${a.source}:${a.alias}`); + expect(await aliases()).toContain("tmdb:Copper Harbour"); + await setEntityIdentity( + h.rt, + id, + { provider: "tmdb", entityType: "movie", externalId: "880032", lock: true }, + USER, + ); + const after = await aliases(); + expect(after.filter((a) => a.startsWith("tmdb:"))).toEqual([]); + expect(after).toContain("user:铜湾人工名"); + // 旧作品的别名 Copper Harbour:不再命中本地作品(未订阅、不查 TMDB,只进池) + const old = await release("Copper Harbour 2020 1080p WEB-DL-CH"); + const ro = await resolveRelease(h.rt, { sourceReleaseId: old.id }); + expect(ro.outcome).toBe("unmatched"); + expect((await row(old.id)).media_entity_id).toBeNull(); + }); + + it("合并作品:被合并作品的内容单元迁到目标,同季同集的并入目标已有单元,资源引用跟着走", async () => { + const mk = async (title: string) => + ( + await h.db + .insertInto("media_entity") + .values({ kind: "series", title }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + const from = await mk("合并源"); + const into = await mk("合并目标"); + const unit = async (entity: string, kind: "season" | "episode", season: number, episode: number | null) => + ( + await h.db + .insertInto("content_unit") + .values({ + media_entity_id: entity, + kind, + season_number: season, + episode_number: episode, + status: "provisional", + source: "release", + }) + .returning("id") + .executeTakeFirstOrThrow() + ).id; + await unit(from, "season", 1, null); + const fromE1 = await unit(from, "episode", 1, 1); + const fromE3 = await unit(from, "episode", 1, 3); + await unit(into, "season", 1, null); + const intoE1 = await unit(into, "episode", 1, 1); + const r1 = await release("合并源 S01E01"); + const r3 = await release("合并源 S01E03"); + await h.db + .updateTable("source_release") + .set({ media_entity_id: from, content_unit_id: fromE1 }) + .where("id", "=", r1.id) + .execute(); + await h.db + .updateTable("source_release") + .set({ media_entity_id: from, content_unit_id: fromE3 }) + .where("id", "=", r3.id) + .execute(); + + await mergeEntities(h.rt, from, into, "重复作品", USER); + const left = await h.db + .selectFrom("content_unit") + .select("id") + .where("media_entity_id", "=", from) + .where("status", "<>", "retired") + .execute(); + expect(left).toHaveLength(0); + const units = await h.db + .selectFrom("content_unit") + .select(["id", "kind", "season_number", "episode_number"]) + .where("media_entity_id", "=", into) + .orderBy("kind", "desc") + .orderBy("episode_number") + .execute(); + expect(units.map((u) => `${u.kind}:${u.season_number}:${u.episode_number}`)).toEqual([ + "season:1:null", + "episode:1:1", + "episode:1:3", + ]); + const a1 = await row(r1.id); + const a3 = await row(r3.id); + expect(a1).toMatchObject({ media_entity_id: into }); + expect([intoE1, fromE1]).toContain(a1.content_unit_id); + expect(units.map((u) => u.id)).toContain(a1.content_unit_id); + expect(a3).toMatchObject({ media_entity_id: into, content_unit_id: fromE3 }); + }); +});