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]>
103 lines
5.2 KiB
TypeScript
103 lines
5.2 KiB
TypeScript
import type { Kysely } from "kysely";
|
||
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||
|
||
// DEC-1 惰性识别 + 待识别池治理(REQ §4.2–4.3、§7.5、§13;对照 MP:RSS 资源只在匹配订阅 / 用户搜索时识别,识别失败不告警):
|
||
// - identity_candidate.reason 新增 unmatched:本地召回无结果、也不属于任何需要 TMDB 查询的触发(订阅别名 / 新作品收录规则 /
|
||
// 手动 / 搜索),只进池、不查 TMDB、不开待处理;
|
||
// - resolution_attempt.outcome 同步新增 unmatched;
|
||
// - identity_candidate.normalized_title:归一化标题(与 title_alias.normalized 同一口径),也是待处理合并分组键
|
||
// (来源 + 归一化标题)的一部分;title_alias 插入触发器按它唤醒相近的池条目复查(新证据到来即重试);
|
||
// - 清理存量:未关闭的 identity_unresolved 事项里,资源未关联作品、池内无候选、标题也不命中任何本地别名的,
|
||
// 事项关闭为 dismissed(resolution.resolution = 'moved_to_pool'),池条目改为 unmatched 并解除事项关联。
|
||
|
||
const OLD_REASONS = ["ambiguous", "no_match", "proposed", "lookup_failed"] as const;
|
||
const OLD_OUTCOMES = [
|
||
"auto",
|
||
"proposed",
|
||
"ambiguous",
|
||
"no_match",
|
||
"lookup_failed",
|
||
"manual",
|
||
"skipped_locked",
|
||
] as const;
|
||
|
||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||
export async function up(db: Kysely<any>): Promise<void> {
|
||
await execDdl(
|
||
db,
|
||
`
|
||
alter table identity_candidate drop constraint identity_candidate_reason_check;
|
||
alter table identity_candidate add constraint identity_candidate_reason_check
|
||
${oneOf("reason", [...OLD_REASONS, "unmatched"])};
|
||
alter table resolution_attempt drop constraint resolution_attempt_outcome_check;
|
||
alter table resolution_attempt add constraint resolution_attempt_outcome_check
|
||
${oneOf("outcome", [...OLD_OUTCOMES, "unmatched"])};
|
||
|
||
alter table identity_candidate add column normalized_title text;
|
||
update identity_candidate set normalized_title = nullif(query->>'normalized', '');
|
||
create index identity_candidate_normalized_trgm_idx on identity_candidate
|
||
using gin (normalized_title gin_trgm_ops) where status = 'pending';
|
||
|
||
-- 新证据唤醒:任何路径新增别名(建档 / 手动加别名 / 合并)后,归一化标题相同或足够相近(与本地召回同阈值 0.45)的
|
||
-- unmatched / no_match 池条目立即到期复查(复查仍是本地召回优先,命中别名才查 TMDB)
|
||
create function identity_pool_wake_on_alias() returns trigger language plpgsql as $$
|
||
begin
|
||
if new.normalized is null or new.normalized = '' then return null; end if;
|
||
update identity_candidate set next_review_at = now()
|
||
where status = 'pending' and reason in ('unmatched', 'no_match') and normalized_title is not null
|
||
and (next_review_at is null or next_review_at > now())
|
||
and (normalized_title = new.normalized
|
||
or (length(new.normalized) >= 3 and normalized_title % new.normalized
|
||
and similarity(normalized_title, new.normalized) >= 0.45));
|
||
return null;
|
||
end $$;
|
||
create trigger title_alias_wake_identity_pool after insert on title_alias
|
||
for each row execute function identity_pool_wake_on_alias();
|
||
|
||
with moved as (
|
||
select p.id as pool_id, d.id as decision_id
|
||
from identity_candidate p
|
||
join source_release r on r.id = p.source_release_id
|
||
join decision d on d.id = p.decision_id or d.dedupe_key = 'identity:' || p.source_release_id::text
|
||
where d.kind = 'identity_unresolved'
|
||
and d.status in ('open', 'investigating', 'awaiting_user')
|
||
and p.status = 'pending'
|
||
and r.media_entity_id is null
|
||
and jsonb_array_length(p.candidates) = 0
|
||
and not exists (
|
||
select 1 from title_alias a
|
||
where p.normalized_title is not null
|
||
and (a.normalized = p.normalized_title
|
||
or (length(p.normalized_title) >= 3 and similarity(a.normalized, p.normalized_title) >= 0.45)))
|
||
), closed as (
|
||
update decision set status = 'dismissed',
|
||
resolution = jsonb_build_object('resolution', 'moved_to_pool', 'by', 'migration:0047_identity_pool'),
|
||
resolved_by = 'system:identity-pool', resolved_at = now()
|
||
where id in (select decision_id from moved)
|
||
returning id
|
||
)
|
||
update identity_candidate set reason = 'unmatched', decision_id = null, next_review_at = null
|
||
where id in (select pool_id from moved);
|
||
`,
|
||
);
|
||
}
|
||
|
||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||
export async function down(db: Kysely<any>): Promise<void> {
|
||
await execDdl(
|
||
db,
|
||
`
|
||
update identity_candidate set reason = 'no_match' where reason = 'unmatched';
|
||
update resolution_attempt set outcome = 'no_match' where outcome = 'unmatched';
|
||
drop trigger if exists title_alias_wake_identity_pool on title_alias;
|
||
drop function if exists identity_pool_wake_on_alias();
|
||
drop index if exists identity_candidate_normalized_trgm_idx;
|
||
alter table identity_candidate drop column normalized_title;
|
||
alter table identity_candidate drop constraint identity_candidate_reason_check;
|
||
alter table identity_candidate add constraint identity_candidate_reason_check ${oneOf("reason", OLD_REASONS)};
|
||
alter table resolution_attempt drop constraint resolution_attempt_outcome_check;
|
||
alter table resolution_attempt add constraint resolution_attempt_outcome_check ${oneOf("outcome", OLD_OUTCOMES)};
|
||
`,
|
||
);
|
||
}
|