import { sql } from "@umt/db"; import { normalizeTitle, parseReleasePath } from "@umt/domain"; import { createMockTmdb, type MockTmdb, TMDB_FIXTURES, type TmdbFixture } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { down, up } from "../../../../packages/db/migrations/0047_identity_pool.ts"; import { identifyParsed, resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts"; import { createHarness, type Harness, json, pgAvailable, seedSite } from "./harness.ts"; // DEC-1 惰性识别(对照 MP:RSS / 站点资源只在匹配订阅或用户搜索时识别,识别失败不告警)与待识别池治理。 const pg = await pgAvailable(); const twin = (id: number, en = "Twins", zh = "龙兄鼠弟", date = "1988-12-09"): TmdbFixture => ({ type: "movie", id, original_title: en, original_language: "en", origin_country: ["US"], date, titles: { "zh-CN": zh, "en-US": en }, popularity: 10 + id / 1000, alternative_titles: [], external_ids: {}, }); describe.skipIf(!pg)("DEC-1 惰性识别 + 待识别池", () => { let h: Harness; let tmdb: MockTmdb; let siteId: string; let n = 0; beforeAll(async () => { tmdb = await createMockTmdb({ fixtures: [ ...TMDB_FIXTURES, twin(111), twin(112), twin(211, "Nightfall", "夜幕", "1995-03-01"), twin(212, "Nightfall", "夜幕", "1995-03-01"), twin(311, "Harborlight", "港灯", "2003-05-01"), twin(312, "Harborlight", "港灯", "2003-05-01"), ], }); h = await createHarness("lazyid", { tmdbUrl: tmdb.url }); siteId = (await seedSite(h, { baseUrl: "https://hhan.example/" })).site.id; }); afterAll(async () => { await h?.close(); await tmdb?.close(); }); const release = async (title: string) => h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: String(900000 + ++n), title }) .returningAll() .executeTakeFirstOrThrow(); const tmdbCalls = () => tmdb.callsTo(/\/3\//).length; const identityDecisions = () => h.db .selectFrom("decision") .selectAll() .where("kind", "in", ["identity_ambiguous", "identity_unresolved"]) .where("status", "in", ["open", "investigating", "awaiting_user"]) .execute(); it("RSS 100 条都不相关 → 0 条待处理、100 条池条目(unmatched)、0 次 TMDB 请求", async () => { const calls = tmdbCalls(); const ids: string[] = []; for (let i = 0; i < 100; i++) { const r = await release(`真仙觉醒,我透视万物${i} 1080p WEB-DL H265 AAC-HHWEB`); ids.push(r.id); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id }); expect(res).toMatchObject({ outcome: "unmatched", decisionId: null, reason: "no_local_match" }); } expect(tmdbCalls()).toBe(calls); expect(await identityDecisions()).toHaveLength(0); const pool = await h.db .selectFrom("identity_candidate") .selectAll() .where("source_release_id", "in", ids) .execute(); expect(pool).toHaveLength(100); expect(pool.every((p) => p.status === "pending" && p.reason === "unmatched" && p.decision_id === null)).toBe(true); // 有界复查:每天一次本地召回 const next = pool[0]!.next_review_at!.getTime() - Date.now(); expect(next).toBeGreaterThan(23 * 3600_000); // 池可检索 const list = await json(await h.req("/identity-pool?reason=unmatched&q=真仙觉醒&limit=200")); expect(list.status).toBe(200); expect(list.body.data.items.length).toBe(100); expect(list.body.data.items[0].normalizedTitle).toBeTruthy(); const rel = await h.db .selectFrom("source_release") .select(["resolution_status", "resolution_evidence"]) .where("id", "=", ids[0]!) .executeTakeFirstOrThrow(); expect(rel.resolution_status).toBe("unidentifiable"); expect((rel.resolution_evidence as { lookup: unknown }).lookup).toBeNull(); }); it("复查次数有上限:unmatched 3 次后不再自动复查", async () => { const r = await release("完全无关的短剧 1080p WEB-DL-HHWEB"); for (let i = 0; i < 3; i++) await resolveRelease(h.rt, { sourceReleaseId: r.id, force: true }); const p = await h.db .selectFrom("identity_candidate") .selectAll() .where("source_release_id", "=", r.id) .executeTakeFirstOrThrow(); expect(p.attempts).toBe(3); expect(p.next_review_at).toBeNull(); }); let oppId: string; it("手动识别(池里点重新识别 / Agent)总是查 TMDB;命中订阅作品别名的新资源查 TMDB 并 resolved", async () => { const seed = await release("Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT"); const lazy = await resolveRelease(h.rt, { sourceReleaseId: seed.id }); expect(lazy.outcome).toBe("unmatched"); const manual = await resolveRelease(h.rt, { sourceReleaseId: seed.id, force: true, trigger: "manual" }); expect(manual.outcome).toBe("auto"); oppId = manual.mediaEntityId!; const pool = await h.db .selectFrom("identity_candidate") .select(["status"]) .where("source_release_id", "=", seed.id) .executeTakeFirstOrThrow(); expect(pool.status).toBe("resolved"); // 同一作品的新 RSS 条目:标题命中本地别名 → 相关 → 查 TMDB → 自动识别 const r = await release("奥本海默.Oppenheimer.2023.1080p.WEB-DL.H264-GRP"); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id }); expect(res).toMatchObject({ outcome: "auto", mediaEntityId: oppId }); const rel = await h.db .selectFrom("source_release") .select(["resolution_evidence", "resolution_status"]) .where("id", "=", r.id) .executeTakeFirstOrThrow(); expect(rel.resolution_status).toBe("resolved"); expect((rel.resolution_evidence as { lookup: string }).lookup).toBe("tracked_alias"); }); it("新增相近别名(新证据)立即唤醒 unmatched 池条目;复查命中别名后查 TMDB", async () => { const r = await release("The.Bear.S03E02.2160p.DSNP.WEB-DL.DDP5.1.H.265-FLUX"); expect((await resolveRelease(h.rt, { sourceReleaseId: r.id })).outcome).toBe("unmatched"); const before = await h.db .selectFrom("identity_candidate") .select(["next_review_at"]) .where("source_release_id", "=", r.id) .executeTakeFirstOrThrow(); expect(before.next_review_at!.getTime()).toBeGreaterThan(Date.now() + 3600_000); const created = await json(await h.req("/media-entities", { body: { kind: "series", title: "The Bear" } })); expect(created.status).toBe(200); const woke = await h.db .selectFrom("identity_candidate") .select(["next_review_at"]) .where("source_release_id", "=", r.id) .executeTakeFirstOrThrow(); expect(woke.next_review_at!.getTime()).toBeLessThanOrEqual(Date.now()); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, force: true }); expect(res.outcome).not.toBe("unmatched"); expect(["auto", "ambiguous", "proposed"]).toContain(res.outcome); }); it("歧义 → 同来源同标题合并为 1 条事项;选候选对整组生效", async () => { const titles = [ "Twins.1988.1080p.BluRay.x264-GRP", "Twins.1988.720p.BluRay.x264-OTHER", "Twins.1988.2160p.UHD.BluRay.REMUX-THIRD", ]; const ids: string[] = []; for (const t of titles) { const r = await release(t); ids.push(r.id); const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "search" }); expect(res.outcome).toBe("ambiguous"); } const twinsGroup = `identity-group:${siteId}:twins`; const ds = (await identityDecisions()).filter((d) => d.dedupe_key === twinsGroup); expect(ds).toHaveLength(1); const d = ds[0]!; expect(d.severity).toBe("warning"); expect(d.title).toMatch(/^来源「.+」的 3 条资源没识别出作品:Twins$/); const ev = d.evidence as { count: number; releases: { sourceReleaseId: string }[] }; expect(ev.count).toBe(3); expect(ev.releases.map((x) => x.sourceReleaseId).sort()).toEqual([...ids].sort()); const pool = await h.db .selectFrom("identity_candidate") .select(["decision_id"]) .where("source_release_id", "in", ids) .execute(); expect(pool.every((p) => p.decision_id === d.id)).toBe(true); const ok = await json(await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "c0" } })); expect(ok.status).toBe(200); expect(ok.body.data.status).toBe("resolved"); const rels = await h.db .selectFrom("source_release") .select(["media_entity_id", "resolution_locked"]) .where("id", "in", ids) .execute(); expect(new Set(rels.map((r) => r.media_entity_id)).size).toBe(1); expect(rels.every((r) => r.media_entity_id && r.resolution_locked)).toBe(true); expect((await identityDecisions()).filter((x) => x.dedupe_key === twinsGroup)).toHaveLength(0); }); it("组内一条被单独指定 → 只从组里摘除;最后一条处理完才关闭事项", async () => { const ids: string[] = []; for (const t of ["Nightfall.1995.1080p.WEB-DL-AAA", "Nightfall.1995.720p.WEB-DL-BBB"]) { const r = await release(t); 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`); expect(open).toHaveLength(1); const d = open[0]!; const pool = await h.db .selectFrom("identity_candidate") .select(["id", "source_release_id"]) .where("decision_id", "=", d.id) .where("status", "=", "pending") .execute(); const first = pool[0]!; expect( (await json(await h.req(`/identity-pool/${first.id}/assign`, { body: { mediaEntityId: oppId } }))).status, ).toBe(200); expect(pool).toHaveLength(2); const after = await h.db.selectFrom("decision").selectAll().where("id", "=", d.id).executeTakeFirstOrThrow(); expect(after.status).toBe("open"); expect((after.evidence as { count: number }).count).toBe(1); expect(after.subject_id).toBe(pool[1]!.source_release_id); expect(after.title).toMatch(/^没识别出作品:Nightfall\.1995\./); // 最后一条忽略 → 事项关闭 const last = await h.db .selectFrom("identity_candidate") .select(["id"]) .where("source_release_id", "=", pool[1]!.source_release_id) .executeTakeFirstOrThrow(); expect((await h.req(`/identity-pool/${last.id}/dismiss`, { body: {} })).status).toBe(200); const closed = await h.db .selectFrom("decision") .select(["status"]) .where("id", "=", d.id) .executeTakeFirstOrThrow(); expect(closed.status).toBe("dismissed"); }); it("收件箱级别筛选:minSeverity=warning 不含仅信息类", async () => { await h.db .insertInto("decision") .values({ kind: "other", severity: "info", title: "巡检报告", dedupe_key: "test:info" }) .execute(); const all = await json(await h.req("/decisions/summary")); const warn = await json(await h.req("/decisions/summary?minSeverity=warning")); expect(all.body.data.total - warn.body.data.total).toBe(1); const list = await json(await h.req("/decisions?minSeverity=warning")); expect(list.body.data.items.every((d: { severity: string }) => d.severity !== "info")).toBe(true); }); it("过期归档:unmatched 超过 poolArchiveDays 没有新证据 → archived", async () => { const { archiveStalePool } = await import("../../src/modules/sources-identity/service/identity-decisions.ts"); const r = await release("很久以前的短剧 1080p WEB-DL-HHWEB"); await resolveRelease(h.rt, { sourceReleaseId: r.id }); await h.db .updateTable("identity_candidate") .set({ last_attempt_at: new Date(Date.now() - 31 * 86_400_000) }) .where("source_release_id", "=", r.id) .execute(); expect(await archiveStalePool(h.rt, { force: true })).toBeGreaterThanOrEqual(1); const p = await h.db .selectFrom("identity_candidate") .select(["status", "next_review_at"]) .where("source_release_id", "=", r.id) .executeTakeFirstOrThrow(); expect(p).toMatchObject({ status: "archived", next_review_at: null }); }); it("QB-1 接管任务按文件名识别(trigger=takeover):不需要本地别名也查 TMDB,auto 建档;歧义只返回候选,不进池不开待处理", async () => { const calls = tmdbCalls(); const parsed = parseReleasePath( "Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP/Dune.Part.Two.2024.2160p.UHD.BluRay.x265-GRP.mkv", ); const r = await identifyParsed(h.rt, parsed, { trigger: "takeover" }); expect(tmdbCalls()).toBeGreaterThan(calls); expect(r.outcome).toBe("auto"); expect(r.mediaEntityId).toBeTruthy(); const ent = await h.db .selectFrom("media_entity") .selectAll() .where("id", "=", r.mediaEntityId!) .executeTakeFirstOrThrow(); expect(ent.year).toBe(2024); // 同名两部 → 歧义:只给候选 const pool = await h.db .selectFrom("identity_candidate") .select((eb) => eb.fn.countAll().as("n")) .executeTakeFirstOrThrow(); const amb = await identifyParsed(h.rt, parseReleasePath("Harborlight.2003.1080p.BluRay.mkv"), { trigger: "takeover", }); expect(amb.outcome).toBe("ambiguous"); expect(amb.candidates.map((c) => c.externalId).sort()).toEqual(["311", "312"]); expect(amb.mediaEntityId).toBeNull(); const pool2 = await h.db .selectFrom("identity_candidate") .select((eb) => eb.fn.countAll().as("n")) .executeTakeFirstOrThrow(); expect(pool2.n).toBe(pool.n); // 默认 observed 触发:不相关资源不查 TMDB(惰性识别不变) const before = tmdbCalls(); const lazy = await identifyParsed(h.rt, parseReleasePath("Zqxv.Kxlmn.2011.720p.mkv"), { trigger: "observed" }); expect(lazy.outcome).toBe("no_match"); expect(tmdbCalls()).toBe(before); }); }); describe.skipIf(!pg)("迁移 0047:存量 identity_unresolved 清理", () => { let h: Harness; beforeAll(async () => { h = await createHarness("dec1mig"); }); afterAll(async () => { await h?.close(); }); it("无关联的 open identity_unresolved → dismissed(moved_to_pool),池条目改 unmatched;相关的保留", async () => { const siteId = (await seedSite(h, { baseUrl: "https://hhan.example/" })).site.id; await down(h.db); const mk = async (title: string, parsedTitle: string) => { const r = await h.db .insertInto("source_release") .values({ site_id: siteId, site_release_id: title, title, resolution_status: "unidentifiable" }) .returningAll() .executeTakeFirstOrThrow(); const d = await h.db .insertInto("decision") .values({ kind: "identity_unresolved", severity: "info", title: `未识别:${title}`, subject_type: "source_release", subject_id: r.id, dedupe_key: `identity:${r.id}`, }) .returning("id") .executeTakeFirstOrThrow(); await sql`insert into identity_candidate (source_release_id, reason, query, evidence_version, attempts, decision_id, next_review_at) values (${r.id}, 'no_match', ${JSON.stringify({ title: parsedTitle, normalized: normalizeTitle(parsedTitle) })}::jsonb, 'v1', 1, ${d.id}, now() + interval '1 hour')`.execute(h.db); return { releaseId: r.id, decisionId: d.id }; }; const noise = await mk("真仙觉醒,我透视万物 1080p WEB-DL H265 AAC-HHWEB", "真仙觉醒,我透视万物"); const related = await mk("龙兄鼠弟.1988.1080p.WEB-DL", "龙兄鼠弟"); const e = await h.db .insertInto("media_entity") .values({ kind: "movie", title: "龙兄鼠弟" }) .returning("id") .executeTakeFirstOrThrow(); await sql`insert into title_alias (media_entity_id, alias, normalized, kind, source) values (${e.id}, '龙兄鼠弟', ${normalizeTitle("龙兄鼠弟")}, 'translated', 'user')`.execute(h.db); await up(h.db); const d1 = await h.db .selectFrom("decision") .selectAll() .where("id", "=", noise.decisionId) .executeTakeFirstOrThrow(); expect(d1.status).toBe("dismissed"); expect(d1.resolution).toMatchObject({ resolution: "moved_to_pool" }); const p1 = await h.db .selectFrom("identity_candidate") .selectAll() .where("source_release_id", "=", noise.releaseId) .executeTakeFirstOrThrow(); expect(p1).toMatchObject({ reason: "unmatched", decision_id: null, next_review_at: null, status: "pending" }); expect(p1.normalized_title).toBe(normalizeTitle("真仙觉醒,我透视万物")); const d2 = await h.db .selectFrom("decision") .select(["status"]) .where("id", "=", related.decisionId) .executeTakeFirstOrThrow(); expect(d2.status).toBe("open"); const p2 = await h.db .selectFrom("identity_candidate") .select(["reason", "decision_id"]) .where("source_release_id", "=", related.releaseId) .executeTakeFirstOrThrow(); expect(p2).toMatchObject({ reason: "no_match", decision_id: related.decisionId }); }); });