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:
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 });
|
||||
|
||||
Reference in new issue
Block a user