merge: fix/g-identity

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-03 23:32:36 +08:00
commit b491f21769
13 files changed
+897 -50

No files matched your search

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts"; import { type Decision, DecisionKind, DecisionStatus, Id, type PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import { type ParsedRelease, parseRelease } from "@umt/domain";
import { z } from "zod"; import { z } from "zod";
import { ApiException } from "../../../http/errors.ts"; import { ApiException } from "../../../http/errors.ts";
import { decisionHookFor } from "../../decision-hooks.ts"; import { decisionHookFor } from "../../decision-hooks.ts";
@@ -41,6 +42,18 @@ const SummaryGroup = z.object({
latestAt: z.string(), 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) { export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
const db = () => rt().ctx.db; const db = () => rt().ctx.db;
@@ -119,7 +132,10 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
return toApi(row); return toApi(row);
}); });
/** 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它 */ /**
* 组内其它资源:忽略 → 各自池条目 dismissed;指定 → 关联到代表资源已确定的作品(同样加人工锁)。单条失败不影响其它。
* H3:年份 / 类型与所选作品不相容的资源不随组锁定,留在事项里单独处理。返回留下的资源数。
*/
async function applyToGroup( async function applyToGroup(
decisionId: string, decisionId: string,
subjectId: string, subjectId: string,
@@ -127,15 +143,35 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
dismissing: boolean, dismissing: boolean,
note: string | null, note: string | null,
actor: Parameters<typeof dismissPoolEntry>[2], actor: Parameters<typeof dismissPoolEntry>[2],
) { ): Promise<number> {
const subject = await db() const subject = await db()
.selectFrom("source_release") .selectFrom("source_release")
.select(["media_entity_id"]) .select(["media_entity_id"])
.where("id", "=", subjectId) .where("id", "=", subjectId)
.executeTakeFirst(); .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) { for (const srId of others) {
try { 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) { if (dismissing) {
const pool = await db() const pool = await db()
.selectFrom("identity_candidate") .selectFrom("identity_candidate")
@@ -155,6 +191,7 @@ export function decisionRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败"); rt().ctx.logger.warn({ err: e, srId, decisionId }, "合并事项组内资源处理失败");
} }
} }
return kept;
} }
route(app, D.decisions.resolve, async ({ params, body, actor }) => { 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 (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") { if (d.dedupe_key?.startsWith("site-login:") && opt?.key === "retry") {
@@ -38,7 +38,11 @@ const ENDED_STATUSES = new Set(["ended", "canceled", "cancelled"]);
/** 待处理证据里最多列多少条资源 */ /** 待处理证据里最多列多少条资源 */
const DECISION_LIST_LIMIT = 50; const DECISION_LIST_LIMIT = 50;
/** 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元 */ /**
* 作品季表:季号 ≥ 0 的季单元;最新季 = 季号 ≥ 1 的最大季单元。
* 只计元数据(tmdb)/ 人工(user)来源或已确认的季单元:资源 / 文件懒建的临时季(解析错的 S05 之类)不进季表,
* 否则它总集数为空会让该季资源一律按「元数据滞后」放行,还会抬高最新季。
*/
export async function loadSeasonTable(exec: Exec, entityId: string): Promise<SeasonTable> { export async function loadSeasonTable(exec: Exec, entityId: string): Promise<SeasonTable> {
const entity = await exec const entity = await exec
.selectFrom("media_entity") .selectFrom("media_entity")
@@ -52,6 +56,8 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
"season_number", "season_number",
"episode_number", "episode_number",
"episode_total", "episode_total",
"status",
"source",
sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air"), sql<string | null>`to_char(air_date, 'YYYY-MM-DD')`.as("air"),
]) ])
.where("media_entity_id", "=", entityId) .where("media_entity_id", "=", entityId)
@@ -63,6 +69,7 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
const seasons = new Map<number, SeasonInfo>(); const seasons = new Map<number, SeasonInfo>();
for (const u of units) { for (const u of units) {
if (u.kind !== "season" || u.season_number === null || u.season_number < 0) continue; 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 cur = seasons.get(u.season_number);
// 重复季单元(并发懒建遗留)取已知的较大总集数 // 重复季单元(并发懒建遗留)取已知的较大总集数
const total = u.episode_total ?? null; const total = u.episode_total ?? null;
@@ -33,6 +33,7 @@ import { clearTmdbMetadata } from "./metadata.ts";
import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts"; import { metadataLanguages, metadataTextOptions } from "./metadata-rich.ts";
import { recordAttempt } from "./resolve.ts"; import { recordAttempt } from "./resolve.ts";
import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts"; import { backfillRecognizeCache } from "./tmdb-recognize-cache.ts";
import { mergeDuplicateUnits } from "./unit-merge.ts";
// 人工纠错:指定来源资源身份(加锁)、改作品外部映射(新增修订、旧映射退役)、合并作品(旧 ID 跳转)。 // 人工纠错:指定来源资源身份(加锁)、改作品外部映射(新增修订、旧映射退役)、合并作品(旧 ID 跳转)。
// 所有变更留 identity_revision / resolution_attempt / audit_event,不直接覆盖历史。 // 所有变更留 identity_revision / resolution_attempt / audit_event,不直接覆盖历史。
@@ -226,7 +227,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) {
@@ -314,6 +315,15 @@ export async function setEntityIdentity(rt: SourcesRuntime, entityId: string, in
.execute(); .execute();
for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed"); for (const o of derived) await retireIdentity(trx, o, actor, "tmdb_mapping_changed");
await clearTmdbMetadata(trx, id, rt.now()); 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( await audit(
trx, trx,
@@ -414,7 +424,7 @@ export async function updateExternalIdentity(
/** /**
* 合并重复作品:from → into。旧 ID 保留(status=merged + merged_into_id)并写 identity_redirect; * 合并重复作品:from → into。旧 ID 保留(status=merged + merged_into_id)并写 identity_redirect;
* 本模块的引用(来源资源、别名、待识别池、外部身份)迁到目标;其它模块的表按 merged_into_id 跳转解析。 * 本模块的引用(来源资源、别名、待识别池、外部身份、内容单元)迁到目标;其它模块的表按 merged_into_id 跳转解析。
*/ */
export async function mergeEntities( export async function mergeEntities(
rt: SourcesRuntime, rt: SourcesRuntime,
@@ -483,6 +493,35 @@ export async function mergeEntities(
select ${intoResolved}, null, alias, normalized, language, kind, source, locked from title_alias select ${intoResolved}, null, alias, normalized, language, kind, source, locked from title_alias
where media_entity_id = ${fromId} and content_unit_id is null 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); 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 await trx
.updateTable("source_release") .updateTable("source_release")
.set({ media_entity_id: intoResolved }) .set({ media_entity_id: intoResolved })
@@ -1,5 +1,6 @@
import type { DecisionOption, PoolCandidate } from "@umt/contracts"; import type { DecisionOption, PoolCandidate } from "@umt/contracts";
import { sql } from "@umt/db"; import { sql } from "@umt/db";
import type { ParsedRelease } from "@umt/domain";
import { storageSettings } from "../../../shared-cache.ts"; import { storageSettings } from "../../../shared-cache.ts";
import type { ServerContext } from "../../types.ts"; import type { ServerContext } from "../../types.ts";
import { jsonb } from "../rows.ts"; import { jsonb } from "../rows.ts";
@@ -8,7 +9,7 @@ import { closeDecisions, type Exec, openDecision } from "./common.ts";
// DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13): // DEC-1 身份类待处理的合并与池治理(REQ §7.5、§13):
// - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项; // - 只有与订阅 / 本地作品相关(本地别名命中)或已查过 TMDB 仍有歧义的资源才开事项;
// - 同一来源 + 同一归一化标题合并为一条事项(dedupeKey identity-group:<siteId>:<normalized>),成员 = 池里 // - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group:<siteId>:<分组键>),成员 = 池里
// decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条); // decision_id 指向它且仍 pending 的条目;事项的 subject 与候选选项取自代表资源(最近一次进来的那条);
// - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项; // - 成员被识别 / 指定 / 忽略时从组里摘除,组空了才关闭事项;
// - unmatched 池条目超过 storage.retentionDays.identityPoolArchive 天没有新证据就归档。 // - 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}`; export const groupDedupeKey = (siteId: string, groupKey: string) => `identity-group:${siteId}:${groupKey}`;
/**
* 合并事项的分组键(H3):归一化标题,解析出年份 / 剧集线索时带上(`标题:年份:tv`)。同名不同年份或电影 / 剧集
* 不进同一组,选择也就不会被整组套用到别的作品上。
*/
export function identityGroupKey(
normalized: string,
p: Pick<ParsedRelease, "year" | "season" | "episodes" | "mediaTypeHint">,
) {
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 的对应字段 */ /** 身份类事项的手动指定选项;resolve 时按 input.requires 取 DecisionResolveInput.identity 的对应字段 */
function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] { function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
return [ return [
@@ -6,6 +6,7 @@ import {
type MetaSubject, type MetaSubject,
} from "@umt/adapters"; } from "@umt/adapters";
import { pgCacheFor } from "@umt/db"; import { pgCacheFor } from "@umt/db";
import { normalizeTitle, toSimplified } from "@umt/domain";
import type { SourcesRuntime } from "../runtime.ts"; import type { SourcesRuntime } from "../runtime.ts";
import { DOUBAN_SUBJECT_REGION, type DoubanSubject, loadDoubanSubject, saveDoubanSubject } from "./douban-subject.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 站资源兜底):取第一个,顺手写缓存 */ /** 按标题搜到的 bgm 条目与资源名称的核对结果 */
export async function searchBangumi(rt: SourcesRuntime, keyword: string): Promise<DoubanSubject | null> { 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<string, number> {
const out = new Map<string, number>();
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<BangumiMatch | null> {
const clients = metaClients(rt); const clients = metaClients(rt);
if (!clients || keyword.trim().length < 2) return null; if (!clients || keyword.trim().length < 2) return null;
let found: DoubanSubject[];
try { try {
const top = (await clients.bangumi.search(keyword.trim(), 5))[0]; found = (await clients.bangumi.search(keyword.trim(), 5)).map((s) => toCached(s, rt.now()));
if (!top) return null;
const cached = toCached(top, rt.now());
await saveDoubanSubject(rt.ctx, cached, BANGUMI_SUBJECT_REGION);
return cached;
} catch (e) { } catch (e) {
rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败"); rt.ctx.logger.info({ keyword, err: (e as Error).message }, "bgm.tv 搜索失败");
return null; 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)]);
} }
@@ -46,7 +46,12 @@ import {
unitsAllowed, unitsAllowed,
} from "./episode-resolution.ts"; } from "./episode-resolution.ts";
import { doubanRecall, fetchDetailForIds, newIds, subjectRecall } from "./id-escalation.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 { loadSubject, searchBangumi } from "./meta-subjects.ts";
import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts"; import { tmdbRecallInput, tmdbToCandidate } from "./tmdb-candidate.ts";
import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts"; import { loadRecognizeCache, type RecognizeMeta, recognizeMetaOf, saveRecognizeCache } from "./tmdb-recognize-cache.ts";
@@ -196,24 +201,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(
@@ -257,7 +269,7 @@ export interface EscalationEvidence {
/** 豆瓣条目(豆瓣路径) */ /** 豆瓣条目(豆瓣路径) */
douban?: { id: string; title: string; year: number | null; source: string }; douban?: { id: string; title: string; year: number | null; source: string };
/** bgm 条目(bangumi 路径);search = 按资源标题搜到的 */ /** 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 +365,24 @@ async function escalateIdentity(
const bgmId = after.find((x) => x.provider === "bangumi")?.id; const bgmId = after.find((x) => x.provider === "bangumi")?.id;
let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null; let subject = bgmId ? await loadSubject(rt, "bangumi", bgmId) : null;
const search = !subject && !best && (await isPublicBt(rt, release.site_id)); 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) { if (subject) {
const d = await subjectRecall(rt, parsed, subject); const d = await subjectRecall(rt, parsed, subject);
if (d) { if (d) {
ev.bangumi = { id: subject.id, title: subject.title, year: subject.year, search }; ev.bangumi = {
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]); id: subject.id,
if (res.status === "auto" || (!best && res.status !== "no_match")) { 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"; ev.via = "bangumi";
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed }; best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
} }
@@ -368,6 +391,27 @@ async function escalateIdentity(
return best ?? { evidence: ev }; 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<boolean> { async function isPublicBt(rt: SourcesRuntime, siteId: string | null): Promise<boolean> {
if (!siteId) return false; if (!siteId) return false;
const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst(); const row = await rt.ctx.db.selectFrom("site").select("kind").where("id", "=", siteId).executeTakeFirst();
@@ -462,7 +506,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;
@@ -480,7 +528,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);
} }
@@ -875,6 +924,7 @@ const DETAIL_TEXT: Record<string, string> = {
multiple_candidates_same_level: "多个候选同级", multiple_candidates_same_level: "多个候选同级",
automation_off: "自动化已关闭", automation_off: "自动化已关闭",
no_local_match: "没有命中订阅或本地作品,未查询 TMDB", no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
bangumi_unverified: "bgm 条目与资源名称只是相似",
}; };
const detailText = (d: string) => DETAIL_TEXT[d] ?? d; const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
@@ -888,6 +938,20 @@ interface ResolveScope {
escalation: EscalationEvidence | null; 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; const ESCALATION_RETRY_MS = 15 * 60_000;
@@ -952,22 +1016,18 @@ async function toPool(
evidenceVersion: version, evidenceVersion: version,
query, query,
}; };
// 只收相关的:查过 TMDB 仍有歧义(有候选可选),或与本地作品相关却无结果 / 查询失败且复查已停 // 只收相关的:查过 TMDB 仍有歧义(有候选可选),与本地作品相关却无结果,或查询失败且复查已停。
// ID-3:能从站点拿到 ID 的资源不开"没识别出作品":升级链还在推迟中(之后会抓详情)不开任何事项; // ID-3:升级链还在推迟中(之后会抓详情)不开任何事项。
// 资源上已有 IMDb / TMDB / 豆瓣 ID 却仍无结果的,不开 identity_unresolved(只留在池里,可手动指定) // 复查耗尽的查询失败不论是否命中本地作品都开(新作品收录规则 / eager 触发的也要让人看到);
const hasSiteIds = siteExplicitIds(release.raw).length > 0 || (scope.escalation?.ids.length ?? 0) > 0; // 与本地作品相关却无结果的,资源带站点 ID 也开(升级链已跑过仍没认出,不再静默留池)
const wantsDecision = pendingEscalation const wantsDecision = decisionWanted(reason, candidates.length, scope.tracked, delay, pendingEscalation);
? false
: reason === "ambiguous" || reason === "proposed"
? candidates.length > 0
: scope.tracked && !hasSiteIds && (reason === "no_match" || (reason === "lookup_failed" && delay === null));
let decisionId: string | null = null; let decisionId: string | null = null;
if (wantsDecision) { if (wantsDecision) {
const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0; const ambiguous = (reason === "ambiguous" || reason === "proposed") && candidates.length > 0;
decisionId = await openGroupDecision(ctx, trx, { decisionId = await openGroupDecision(ctx, trx, {
kind: ambiguous ? "identity_ambiguous" : "identity_unresolved", kind: ambiguous ? "identity_ambiguous" : "identity_unresolved",
siteId: release.site_id, siteId: release.site_id,
groupKey: normalized ?? release.id, groupKey: normalized ? identityGroupKey(normalized, parsed) : release.id,
workTitle: parsed.title ?? null, workTitle: parsed.title ?? null,
sourceReleaseId: release.id, sourceReleaseId: release.id,
releaseTitle: release.title, releaseTitle: release.title,
@@ -1103,7 +1163,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);
@@ -1121,7 +1185,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) => {
@@ -8,7 +8,7 @@ import { jsonb } from "../rows.ts";
import type { SourcesRuntime } from "../runtime.ts"; import type { SourcesRuntime } from "../runtime.ts";
import { siteCan } from "../site-kinds.ts"; import { siteCan } from "../site-kinds.ts";
import { enqueueRelogin } from "./account.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"; import { hasDetailPage, saveDetailPage } from "./detail-page.ts";
// 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。 // 站点快照(周期列表)、补漏(Feed 疑似缺口,有页数预算)、实时补查(工作台搜索)。
@@ -129,6 +129,35 @@ async function onAuthError(rt: SourcesRuntime, trx: Exec, site: SiteRow, e: Site
await enqueueRelogin(rt, site.id); 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<SnapshotResult> { export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Promise<SnapshotResult> {
const { db } = rt.ctx; const { db } = rt.ctx;
const none = { snapshotId: null, pages: [], itemCount: 0, newCount: 0, releaseIds: [] }; 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) { if (error instanceof SiteError && error.isAuthError) {
await onAuthError(rt, trx, site, error); await onAuthError(rt, trx, site, error);
} else if (input.kind !== "live_search") { } 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 await trx
.updateTable("site") .updateTable("site")
.set({ .set({
@@ -226,6 +258,7 @@ export async function snapshotSite(rt: SourcesRuntime, input: SnapshotInput): Pr
lastItemCount: releases.length, lastItemCount: releases.length,
lastStatus: status, lastStatus: status,
lastError: errJson, lastError: errJson,
failStreak,
at: rt.now().toISOString(), at: rt.now().toISOString(),
}), }),
}) })
@@ -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, {
@@ -110,10 +110,31 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
name_cn: "葬送的芙莉莲 第二季", name_cn: "葬送的芙莉莲 第二季",
date: "2026-01-16", 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({ return json({
data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }], data: [{ id: 400602, name: "葬送のフリーレン", name_cn: "葬送的芙莉莲", date: "2023-09-29" }],
}); });
}
return new Response("", { status: 404 }); return new Response("", { status: 404 });
}, },
}); });
@@ -379,6 +400,17 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
expect(res3.outcome).toBe("auto"); expect(res3.outcome).toBe("auto");
expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031"); expect(await tmdbIdOf(res3.mediaEntityId!)).toBe("990031");
expect(metaRequests).toContain("POST api.bgm.tv/v0/search/subjects"); 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 // PT 站的资源不按标题搜 bgm
const m = metaRequests.length; const m = metaRequests.length;
const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL"); const r4 = await release(hhan, "91099", "Sousou no Frieren S01E03 1080p WEB-DL");
@@ -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 });
});
});
@@ -174,7 +174,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" });
expect(res.outcome).toBe("ambiguous"); 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); const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup);
expect(ds).toHaveLength(1); expect(ds).toHaveLength(1);
const d = ds[0]!; const d = ds[0]!;
@@ -210,7 +210,7 @@ describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => {
ids.push(r.id); ids.push(r.id);
expect((await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" })).outcome).toBe("ambiguous"); 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); expect(open).toHaveLength(1);
const d = open[0]!; const d = open[0]!;
const pool = await h.db const pool = await h.db
@@ -147,7 +147,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now()); expect(pool.next_review_at!.getTime()).toBeGreaterThan(Date.now());
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", res.decisionId!).executeTakeFirstOrThrow(); 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).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([ expect((d.options as { key: string }[]).map((o) => o.key)).toEqual([
"c0", "c0",
"c1", "c1",
@@ -178,7 +178,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
await h.db await h.db
.selectFrom("decision") .selectFrom("decision")
.select("id") .select("id")
.where("dedupe_key", "=", `identity-group:${siteId}:twins`) .where("dedupe_key", "=", `identity-group:${siteId}:twins:1988`)
.execute(), .execute(),
).toHaveLength(1); ).toHaveLength(1);
}); });
+3
View File
@@ -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);