feat(identity): lazy TMDB lookup and bounded identity pool (DEC-1)

RSS/list releases that match no subscription or local work no longer hit
TMDB or open a pending item; they go to the identity pool as `unmatched`.
TMDB is queried only on a local alias hit, a published new-title rule whose
prefilter passes, manual/Agent retry, search results, or when the new
setting identity.eagerTmdbLookup (default off) is on. Mirrors MoviePilot:
torrents are recognised only when matched to a subscription or searched,
and recognition failures are logged, not alerted.

- Pending items only for related releases (ambiguous after lookup, or
  related to a local work but unresolved); same source + normalized title
  merge into one decision ("来源「X」的 N 条资源待识别"), resolve/dismiss
  applies to the whole group, members detach as they get resolved.
- Pool governance: unmatched reviews capped (daily, 3x), title_alias
  insert trigger wakes similar entries, stale unmatched entries archived
  after identity.poolArchiveDays (30); review idempotency keys include the
  due time.
- Migration 0047_identity_pool: reason/outcome `unmatched`,
  identity_candidate.normalized_title (+trgm index), wake trigger, and
  cleanup of open identity_unresolved items with no related work
  (dismissed, resolution=moved_to_pool).
- Inbox: group by type + work/source with collapse, hide info-only items
  by default (minSeverity=warning), badge counts warning+ only; pool sheet
  gets manual TMDB/local-work assign and dismiss; settings card for the
  new identity settings.
- Runbook docs/runbook/identity.md.

Requires rebuild/restart (migration runs on start).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 15:05:39 +08:00
1 parent 524af64bec
commit 94fd48bff2
24 files changed
+1326 -153

No files matched your search

@@ -31,6 +31,11 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
tmdb = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, twin(111), twin(112)] });
h = await createHarness("resolve", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://audiences.example/" })).site.id;
// 本组覆盖 TMDB 召回本身:打开"每条新资源都查 TMDB"(旧行为);惰性识别见 lazy-identity.test.ts
await h.db
.insertInto("settings")
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
.execute();
});
afterAll(async () => {
await h?.close();
@@ -120,7 +125,7 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
});
let twinsRelease: string;
it("ambiguous:进待识别池 + 待处理事项,不建正式作品", async () => {
it("ambiguous:进待识别池 + 待处理事项(合并事项,warning),不建正式作品", async () => {
const before = await h.db.selectFrom("media_entity").select("id").execute();
const r = await release("Twins.1988.1080p.BluRay.x264-GRP");
twinsRelease = r.id;
@@ -136,7 +141,8 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
expect((pool.candidates as unknown[]).length).toBe(2);
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 });
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.options as { key: string }[]).map((o) => o.key)).toEqual([
"c0",
"c1",
@@ -164,7 +170,11 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
expect(pool2.attempts).toBe(2);
expect(pool2.next_review_at!.getTime()).toBeGreaterThan(pool.next_review_at!.getTime());
expect(
await h.db.selectFrom("decision").select("id").where("dedupe_key", "=", `identity:${r.id}`).execute(),
await h.db
.selectFrom("decision")
.select("id")
.where("dedupe_key", "=", `identity-group:${siteId}:twins`)
.execute(),
).toHaveLength(1);
});
@@ -214,38 +224,32 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
expect(after.media_entity_id).toBe(rel.media_entity_id);
});
it("no_match 进池并开未识别事项;手动指定 TMDB id / 选本地作品即人工指定加锁;TMDB 失败记为 lookup_failed", async () => {
it("no_match(与本地作品无关)只进池不开事项;池条目上手动指定 TMDB id / 选本地作品即人工指定加锁;TMDB 失败记为 lookup_failed", async () => {
const r1 = await release("Zzqxv.Nonexistent.2019.1080p.WEB-DL-NOPE");
const a = await resolveRelease(h.rt, { sourceReleaseId: r1.id });
expect(a.outcome).toBe("no_match");
expect(a.decisionId).not.toBeNull();
expect(a.decisionId).toBeNull();
const rel = await h.db
.selectFrom("source_release")
.select(["resolution_status"])
.where("id", "=", r1.id)
.executeTakeFirstOrThrow();
expect(rel.resolution_status).toBe("unidentifiable");
const d = await h.db.selectFrom("decision").selectAll().where("id", "=", a.decisionId!).executeTakeFirstOrThrow();
expect(d).toMatchObject({ kind: "identity_unresolved", status: "open", subject_id: r1.id });
expect((d.options as { key: string }[]).map((o) => o.key)).toEqual(["manual_tmdb", "local_entity", "dismiss"]);
const pool1 = await h.db
.selectFrom("identity_candidate")
.select(["decision_id"])
.select(["id", "decision_id", "reason", "normalized_title"])
.where("source_release_id", "=", r1.id)
.executeTakeFirstOrThrow();
expect(pool1.decision_id).toBe(d.id);
// 缺少输入 → 400;给 TMDB id → 人工指定加锁,事项关闭
expect((await json(await h.req(`/decisions/${d.id}/resolve`, { body: { optionKey: "manual_tmdb" } }))).status).toBe(
400,
);
expect(pool1).toMatchObject({ decision_id: null, reason: "no_match", normalized_title: "zzqxvnonexistent" });
// 缺少输入 → 422(校验失败);给 TMDB id → 人工指定加锁
expect((await json(await h.req(`/identity-pool/${pool1.id}/assign`, { body: {} }))).status).toBe(422);
const fx = TMDB_FIXTURES.find((f) => f.type === "movie")!;
const ok = await json(
await h.req(`/decisions/${d.id}/resolve`, {
body: { optionKey: "manual_tmdb", identity: { tmdb: { type: "movie", id: String(fx.id) } } },
await h.req(`/identity-pool/${pool1.id}/assign`, {
body: { external: { provider: "tmdb", entityType: "movie", externalId: String(fx.id) } },
}),
);
expect(ok.status).toBe(200);
expect(ok.body.data.status).toBe("resolved");
const rel1 = await h.db.selectFrom("source_release").selectAll().where("id", "=", r1.id).executeTakeFirstOrThrow();
expect(rel1).toMatchObject({ resolution_status: "resolved", resolution_locked: true });
const ident = await h.db
@@ -260,11 +264,12 @@ describe.skipIf(!up)("resolveObservation / 身份与元数据", () => {
const r3 = await release("Xyzzy.Plugh.2018.1080p.WEB-DL-NOPE");
const c = await resolveRelease(h.rt, { sourceReleaseId: r3.id });
expect(c.outcome).toBe("no_match");
const local = await json(
await h.req(`/decisions/${c.decisionId}/resolve`, {
body: { optionKey: "local_entity", identity: { mediaEntityId: oppId } },
}),
);
const pool3 = await h.db
.selectFrom("identity_candidate")
.select(["id"])
.where("source_release_id", "=", r3.id)
.executeTakeFirstOrThrow();
const local = await json(await h.req(`/identity-pool/${pool3.id}/assign`, { body: { mediaEntityId: oppId } }));
expect(local.status).toBe(200);
const rel3 = await h.db.selectFrom("source_release").selectAll().where("id", "=", r3.id).executeTakeFirstOrThrow();
expect(rel3).toMatchObject({ media_entity_id: oppId, resolution_locked: true });