Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/server/src/modules/acquisition-organize/services/organize.ts # apps/web/app/routes/console/sites.$siteId.tsx
393 lines
17 KiB
TypeScript
393 lines
17 KiB
TypeScript
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 });
|
|
});
|
|
});
|