Files
UHD_Media_Tool/apps/server/src/modules/sources-identity/defs.ts
T
rootandClaude Opus 5.5 94fd48bff2 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]>
2026-09-29 15:05:39 +08:00

322 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ContentUnit,
ContentUnitUpdate,
CursorQuery,
cursorPage,
Decision,
DecisionKind,
DecisionResolveInput,
DecisionStatus,
ExternalIdentity,
ExternalIdentityCreate,
ExternalIdentityUpdate,
FeedHealth,
FeedSource,
FeedSourceCreate,
FeedSourceUpdate,
Id,
IdentityRevision,
IdentityStatus,
IdParam,
MediaEntity,
MediaEntityCreate,
MediaEntityMerge,
MediaEntityStatus,
MediaEntityUpdate,
MediaKind,
Observation,
ObservationStatus,
OkResult,
ResolutionStatus,
Site,
SiteAccount,
SiteAccountCreate,
SiteAccountUpdate,
SiteCreate,
SiteListItem,
SiteListQuery,
SiteUpdate,
SourceRelease,
TitleAlias,
TitleAliasCreate,
} from "@umt/contracts";
import { z } from "zod";
// 与 contracts Api(crud())同路径、同 scope 的端点定义,但保留具体 schema 类型供路由推断入参。
// scope:读 = read;写 = admin(站点/Feed 配置)或 organize.execute(身份纠错、待处理)。
const list = <R extends z.ZodType, F extends z.ZodRawShape>(
path: string,
summary: string,
row: R,
filters: F,
tags: string[],
) => ({
method: "get" as const,
path,
scope: "read" as const,
summary,
tags,
query: CursorQuery.extend(filters),
response: cursorPage(row),
});
const get = <R extends z.ZodType>(path: string, summary: string, row: R, tags: string[]) => ({
method: "get" as const,
path,
scope: "read" as const,
summary,
tags,
params: IdParam,
response: row,
});
const write = <M extends "post" | "patch" | "delete", B extends z.ZodType | undefined, R extends z.ZodType>(
method: M,
path: string,
scope: "admin" | "organize.execute",
summary: string,
body: B,
response: R,
tags: string[],
withId = true,
) => ({
method,
path,
scope,
summary,
tags,
...(withId ? { params: IdParam } : {}),
body: body as B,
response,
});
const TS = ["sites"];
const TF = ["feed-sources"];
const TR = ["source-releases"];
const TE = ["media-entities"];
const TI = ["identity"];
const TD = ["decisions"];
export const DecisionMinSeverity = z.enum(["info", "warning", "critical"]);
export const D = {
sites: {
list: {
method: "get" as const,
path: "/sites",
scope: "read" as const,
summary: "站点列表(include=accounts 附带账号与整体账号状态)",
tags: TS,
query: SiteListQuery,
response: cursorPage(SiteListItem),
},
get: get("/sites/{id}", "站点详情", Site, TS),
create: {
method: "post" as const,
path: "/sites",
scope: "admin" as const,
summary: "登记站点",
tags: TS,
body: SiteCreate,
response: Site,
},
update: write("patch", "/sites/{id}", "admin", "修改站点(启停、快照周期、限流、代理)", SiteUpdate, Site, TS),
},
siteAccounts: {
list: list("/site-accounts", "站点账号列表", SiteAccount, { siteId: Id.optional() }, TS),
get: get("/site-accounts/{id}", "站点账号详情", SiteAccount, TS),
create: {
method: "post" as const,
path: "/site-accounts",
scope: "admin" as const,
summary: "新建站点账号(凭证只写,存为 secret)",
tags: TS,
body: SiteAccountCreate,
response: SiteAccount,
},
update: write(
"patch",
"/site-accounts/{id}",
"admin",
"修改站点账号 / 轮换凭证",
SiteAccountUpdate,
SiteAccount,
TS,
),
},
feedSources: {
list: list("/feed-sources", "Feed 列表", FeedSource, { siteId: Id.optional(), health: FeedHealth.optional() }, TF),
get: get("/feed-sources/{id}", "Feed 详情", FeedSource, TF),
create: {
method: "post" as const,
path: "/feed-sources",
scope: "admin" as const,
summary: "新建 Feed(url 只写,存为 secret;对外只给脱敏 URL)",
tags: TF,
body: FeedSourceCreate,
response: FeedSource,
},
update: write("patch", "/feed-sources/{id}", "admin", "修改 Feed", FeedSourceUpdate, FeedSource, TF),
fetchNow: write(
"post",
"/feed-sources/{id}/fetch",
"admin",
"立即抓取(仍受站点限流)",
undefined,
z.object({ taskId: Id }),
TF,
),
},
sourceReleases: {
list: list(
"/source-releases",
"资源列表",
SourceRelease,
{
siteId: Id.optional(),
mediaEntityId: Id.optional(),
resolutionStatus: ResolutionStatus.optional(),
q: z.string().max(200).optional(),
},
TR,
),
get: get("/source-releases/{id}", "资源详情", SourceRelease, TR),
},
observations: {
list: list(
"/observations",
"观察列表",
Observation,
{ feedId: Id.optional(), status: ObservationStatus.optional() },
TR,
),
get: get("/observations/{id}", "观察详情", Observation, TR),
},
mediaEntities: {
list: list(
"/media-entities",
"作品列表",
MediaEntity,
{ kind: MediaKind.optional(), status: MediaEntityStatus.optional(), q: z.string().max(200).optional() },
TE,
),
get: get("/media-entities/{id}", "作品(旧 ID 自动跳转)", MediaEntity, TE),
create: {
method: "post" as const,
path: "/media-entities",
scope: "organize.execute" as const,
summary: "手动建本地作品(TMDB 无记录时可先建档)",
tags: TE,
body: MediaEntityCreate,
response: MediaEntity,
},
update: write(
"patch",
"/media-entities/{id}",
"organize.execute",
"修改作品(标题/原名/年份按字段覆盖并锁定)",
MediaEntityUpdate,
MediaEntity,
TE,
),
merge: write(
"post",
"/media-entities/{id}/merge",
"organize.execute",
"合并重复作品(保留旧 ID 跳转)",
MediaEntityMerge,
MediaEntity,
TE,
),
},
contentUnits: {
list: list(
"/content-units",
"内容单元列表",
ContentUnit,
{ mediaEntityId: Id.optional(), seasonNumber: z.coerce.number().int().optional() },
TE,
),
get: get("/content-units/{id}", "内容单元详情", ContentUnit, TE),
update: write(
"patch",
"/content-units/{id}",
"organize.execute",
"修改内容单元(改编号即确认并锁定)",
ContentUnitUpdate,
ContentUnit,
TE,
),
},
titleAliases: {
list: list(
"/title-aliases",
"别名列表",
TitleAlias,
{ mediaEntityId: Id.optional(), q: z.string().max(200).optional() },
TI,
),
get: get("/title-aliases/{id}", "别名详情", TitleAlias, TI),
create: write("post", "/title-aliases", "organize.execute", "新增别名", TitleAliasCreate, TitleAlias, TI, false),
remove: write("delete", "/title-aliases/{id}", "organize.execute", "删除别名", undefined, OkResult, TI),
},
externalIdentities: {
list: list(
"/external-identities",
"外部身份列表",
ExternalIdentity,
{ mediaEntityId: Id.optional(), provider: z.string().optional(), status: IdentityStatus.optional() },
TI,
),
get: get("/external-identities/{id}", "外部身份详情", ExternalIdentity, TI),
create: write(
"post",
"/external-identities",
"organize.execute",
"新增外部身份(accepted 等同人工纠错)",
ExternalIdentityCreate,
ExternalIdentity,
TI,
false,
),
update: write(
"patch",
"/external-identities/{id}",
"organize.execute",
"修订外部身份(乐观并发)",
ExternalIdentityUpdate,
ExternalIdentity,
TI,
),
revisions: get(
"/external-identities/{id}/revisions",
"身份修订历史",
z.object({ items: z.array(IdentityRevision) }),
TI,
),
},
decisions: {
list: list(
"/decisions",
"待处理事项(缺省只列未关闭)",
Decision,
{
status: DecisionStatus.optional(),
kind: DecisionKind.optional(),
/** 只看不低于此级别的事项;收件箱缺省传 warning 隐藏"仅信息"类 */
minSeverity: DecisionMinSeverity.optional(),
},
TD,
),
get: get("/decisions/{id}", "待处理事项详情", Decision, TD),
resolve: write(
"post",
"/decisions/{id}/resolve",
"organize.execute",
"处理待处理事项",
DecisionResolveInput,
Decision,
TD,
),
},
};