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:
commit
b491f21769
13 files changed
+897
-50
No files matched your search
@@ -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<typeof dismissPoolEntry>[2],
|
||||
) {
|
||||
): Promise<number> {
|
||||
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") {
|
||||
|
||||
@@ -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<SeasonTable> {
|
||||
const entity = await exec
|
||||
.selectFrom("media_entity")
|
||||
@@ -52,6 +56,8 @@ export async function loadSeasonTable(exec: Exec, entityId: string): Promise<Sea
|
||||
"season_number",
|
||||
"episode_number",
|
||||
"episode_total",
|
||||
"status",
|
||||
"source",
|
||||
sql<string | null>`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<Sea
|
||||
const seasons = new Map<number, SeasonInfo>();
|
||||
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;
|
||||
|
||||
@@ -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,不直接覆盖历史。
|
||||
@@ -226,7 +227,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) {
|
||||
@@ -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 })
|
||||
|
||||
@@ -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:<siteId>:<normalized>),成员 = 池里
|
||||
// - 同一来源 + 同一归一化标题(+ 年份 / 剧集线索)合并为一条事项(dedupeKey identity-group:<siteId>:<分组键>),成员 = 池里
|
||||
// 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<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 的对应字段 */
|
||||
function manualIdentityOptions(sourceReleaseId: string): DecisionOption[] {
|
||||
return [
|
||||
|
||||
@@ -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<DoubanSubject | null> {
|
||||
/** 按标题搜到的 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<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);
|
||||
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)]);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -196,24 +201,31 @@ async function tmdbCandidates(
|
||||
parsed: ParsedRelease,
|
||||
useCache: boolean,
|
||||
siteIds: ReturnType<typeof siteExplicitIds> = [],
|
||||
): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null }> {
|
||||
): Promise<{ remote: MetadataCandidate[]; cacheHit: boolean; meta: RecognizeMeta | null; detailFailed: boolean }> {
|
||||
const tmdb = await rt.tmdb();
|
||||
if (!tmdb) return { remote: [], cacheHit: false, meta: null };
|
||||
if (!tmdb) return { remote: [], cacheHit: false, meta: null, detailFailed: false };
|
||||
const meta = recognizeMetaOf(parsed);
|
||||
// 站点详情页给出的 TMDB / IMDb ID:按 ID 直取(TMDB 详情 / find),命中就不再按标题搜索
|
||||
const byId = await tmdbByExplicitIds(tmdb, siteIds);
|
||||
if (byId.length) return { remote: byId, cacheHit: false, meta };
|
||||
if (byId.length) return { remote: byId, cacheHit: false, meta, detailFailed: false };
|
||||
if (meta && useCache) {
|
||||
const hit = await loadRecognizeCache(rt, meta);
|
||||
// 命中条件含年份 / 类型相容(loadRecognizeCache);命中后的唯一候选照样经 resolveIdentity 按资源年份 / 类型打分
|
||||
const hit = await loadRecognizeCache(rt, meta, parsed);
|
||||
if (hit) {
|
||||
if (hit.id && hit.title && (hit.type === "movie" || hit.type === "tv")) {
|
||||
return { remote: [await tmdb.details(hit.type, hit.id)], cacheHit: true, meta };
|
||||
return { remote: [await tmdb.details(hit.type, hit.id)], cacheHit: true, meta, detailFailed: false };
|
||||
}
|
||||
return { remote: [], cacheHit: true, meta };
|
||||
return { remote: [], cacheHit: true, meta, detailFailed: false };
|
||||
}
|
||||
}
|
||||
const remote = await recallTmdbCandidates(tmdb, tmdbRecallInput(parsed));
|
||||
return { remote, cacheHit: false, meta };
|
||||
let detailFailed = false;
|
||||
const remote = await recallTmdbCandidates(tmdb, {
|
||||
...tmdbRecallInput(parsed),
|
||||
onDetailError: () => {
|
||||
detailFailed = true;
|
||||
},
|
||||
});
|
||||
return { remote, cacheHit: false, meta, detailFailed };
|
||||
}
|
||||
|
||||
async function tmdbByExplicitIds(
|
||||
@@ -257,7 +269,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 +365,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 +391,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<boolean> {
|
||||
if (!siteId) return false;
|
||||
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 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;
|
||||
@@ -480,7 +528,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);
|
||||
}
|
||||
|
||||
@@ -875,6 +924,7 @@ const DETAIL_TEXT: Record<string, string> = {
|
||||
multiple_candidates_same_level: "多个候选同级",
|
||||
automation_off: "自动化已关闭",
|
||||
no_local_match: "没有命中订阅或本地作品,未查询 TMDB",
|
||||
bangumi_unverified: "bgm 条目与资源名称只是相似",
|
||||
};
|
||||
const detailText = (d: string) => DETAIL_TEXT[d] ?? d;
|
||||
|
||||
@@ -888,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;
|
||||
|
||||
@@ -952,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,
|
||||
@@ -1103,7 +1163,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);
|
||||
@@ -1121,7 +1185,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) => {
|
||||
|
||||
@@ -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<SnapshotResult> {
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -65,8 +65,31 @@ function localeOf(tmdb: unknown): string {
|
||||
return (tmdb as { language?: string } | null)?.language ?? "zh-CN";
|
||||
}
|
||||
|
||||
/** TmdbCache.get:未命中返回 null;类型冲突的脏条目删除后按未命中处理 */
|
||||
export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta): Promise<RecognizeCacheEntry | null> {
|
||||
/** 资源是否带剧集线索(季 / 集 / 类型提示) */
|
||||
const tvLike = (p: ParsedRelease) => p.mediaTypeHint === "tv" || p.season !== undefined || p.episodes !== undefined;
|
||||
|
||||
/**
|
||||
* 缓存条目与资源自身的年份 / 类型是否相容(H2):资源有年份时条目年份须在 ±1 内(剧集允许资源年份更晚,后续季);
|
||||
* 资源有剧集线索时条目须是剧集。负缓存不带年份类型,总是相容。
|
||||
*/
|
||||
export function entryCompatible(p: ParsedRelease, e: Pick<RecognizeCacheEntry, "id" | "type" | "year">): boolean {
|
||||
if (!e.id) return true;
|
||||
if (tvLike(p) && e.type === "movie") return false;
|
||||
const y = e.year ? Number(e.year) : Number.NaN;
|
||||
if (p.year === undefined || !Number.isFinite(y)) return true;
|
||||
if (Math.abs(p.year - y) <= 1) return true;
|
||||
return e.type === "tv" && p.year > y;
|
||||
}
|
||||
|
||||
/**
|
||||
* TmdbCache.get:未命中返回 null;类型冲突的脏条目删除后按未命中处理。
|
||||
* 传入资源解析时再核对条目与资源的年份 / 类型,不相容按未命中处理(不删除,别的资源可能正好相容)。
|
||||
*/
|
||||
export async function loadRecognizeCache(
|
||||
rt: SourcesRuntime,
|
||||
meta: RecognizeMeta,
|
||||
parsed?: ParsedRelease,
|
||||
): Promise<RecognizeCacheEntry | null> {
|
||||
const tmdb = await rt.tmdb();
|
||||
const key = recognizeKey(meta, localeOf(tmdb));
|
||||
const cache = sharedCache(rt.ctx);
|
||||
@@ -77,6 +100,7 @@ export async function loadRecognizeCache(rt: SourcesRuntime, meta: RecognizeMeta
|
||||
await cache.delete(CACHE_REGIONS.tmdbRecognize, key);
|
||||
return null;
|
||||
}
|
||||
if (parsed && !entryCompatible(parsed, v)) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -121,13 +145,17 @@ export async function saveRecognizeCache(
|
||||
/**
|
||||
* 人工认定后回填识别缓存(MP update_recognize_cache:覆盖名称负缓存,避免同名资源再次回查)。
|
||||
* 作品没有已接受的 TMDB 身份时不写。失败只记日志。
|
||||
* H2:一次指定不扩散给不相容的同名资源——只回填用户本人的指定(Agent / 系统的不回填);资源没有年份不回填
|
||||
* (键里年份是 None,会被所有无年份的同名资源命中);作品年份 / 类型与资源解析不相容(指定到了别的版本)不回填。
|
||||
*/
|
||||
export async function backfillRecognizeCache(
|
||||
rt: SourcesRuntime,
|
||||
parsed: ParsedRelease,
|
||||
entityId: string,
|
||||
actorType: string = "user",
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (actorType !== "user" || parsed.year === undefined) return;
|
||||
const meta = recognizeMetaOf(parsed);
|
||||
if (!meta || !(await rt.tmdb())) return;
|
||||
const row = await rt.ctx.db
|
||||
@@ -139,6 +167,12 @@ export async function backfillRecognizeCache(
|
||||
.where("i.status", "=", "accepted")
|
||||
.executeTakeFirst();
|
||||
if (!row || (row.entity_type !== "movie" && row.entity_type !== "tv")) return;
|
||||
const entry = {
|
||||
id: Number(row.external_id),
|
||||
type: row.entity_type,
|
||||
year: row.year === null ? null : String(row.year),
|
||||
};
|
||||
if (!entryCompatible(parsed, entry)) return;
|
||||
const md = (row.metadata ?? {}) as Record<string, { value?: unknown } | undefined>;
|
||||
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||
await saveRecognizeCache(rt, meta, {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" });
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user