/** * 识别准确度与失败可见性(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("季表:临时季下已有 TMDB 正式集(DH-1 前的半转正数据)也进季表", async () => { const e = await h.db .insertInto("media_entity") .values({ kind: "series", title: "半转正季表" }) .returning("id") .executeTakeFirstOrThrow(); const s1 = await h.db .insertInto("content_unit") .values({ media_entity_id: e.id, kind: "season", season_number: 1, episode_total: 47, status: "provisional" }) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("content_unit") .values([ { media_entity_id: e.id, parent_unit_id: s1.id, kind: "episode", season_number: 1, episode_number: 1, status: "confirmed", source: "tmdb", air_date: "2026-09-11", }, // 只有资源懒建的临时集:不算覆盖 { media_entity_id: e.id, kind: "season", season_number: 3, status: "provisional", source: "release" }, { media_entity_id: e.id, kind: "episode", season_number: 3, episode_number: 1, status: "provisional", source: "release", }, ]) .execute(); const table = await loadSeasonTable(h.db, e.id); expect(table.seasons.map((s) => [s.season, s.total])).toEqual([[1, 47]]); expect(table.latestSeason).toBe(1); }); 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 }); }); });