Files
UHD_Media_Tool/packages/contracts/src/content.ts
T
rootandClaude Opus 5.5 1dd460d359 feat(db,contracts): W5-C subscription units migration 0025, contracts and mapping
- subscription: status pending (TMDB not ready: searchable/evaluable, not completable),
  last_searched_at / next_search_at
- content_unit (season): episode_total ledger with source / lock / timestamp
- acquisition_plan: unit_keys (Slot × unit reservation) and file_selection; drop the
  one-in-flight-per-slot unique index (checked under slot row lock per unit instead)
- new tables slot_deferral, slot_unit (per-unit quality ledger), subscription_history
- contracts: SubscriptionParams, SubscriptionHistory, SlotDeferral, SlotUnit, SubscriptionProgress,
  EpisodeTotalSet, CandidateEvaluation pri/trace/unitKeys and decision deferred;
  endpoints subscriptions.search/progress/setEpisodeTotal and subscription-history
- mapping: evaluation messages via describeViolation, pri/trace passthrough

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:33:56 +08:00

226 lines
8.4 KiB
TypeScript

import { z } from "zod";
import { ActorType, DateOnly, EntityBase, Id, JsonObject, SERVER_MANAGED, Timestamp } from "./common.ts";
import { ExternalIdentity } from "./identity.ts";
// 内容域:本地作品、内容单元、别名(REQ §3.1、§5.1、§5.4、§7.5)
// 本地 UUID 是主身份;季/集编号、外部 ID 都可修订,不是唯一身份。
export const MediaKind = z.enum(["movie", "series", "variety", "collection", "other"]);
export type MediaKind = z.infer<typeof MediaKind>;
export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]);
export type MediaEntityStatus = z.infer<typeof MediaEntityStatus>;
export const MediaEntity = EntityBase.extend({
kind: MediaKind,
title: z.string().min(1),
originalTitle: z.string().nullable(),
year: z.number().int().min(1870).max(2200).nullable(),
status: MediaEntityStatus,
/** status=merged 时指向合并后的实体(旧 ID 跳转) */
mergedIntoId: Id.nullable(),
/** 最终展示字段(每个字段带来源/语言/时间) */
metadata: JsonObject,
/** 人工锁定的字段名;刷新不得覆盖 */
fieldLocks: z.array(z.string()),
lastMetadataRefreshAt: Timestamp.nullable(),
});
export type MediaEntity = z.infer<typeof MediaEntity>;
export const MediaEntityCreate = MediaEntity.pick({
kind: true,
title: true,
originalTitle: true,
year: true,
status: true,
metadata: true,
fieldLocks: true,
}).partial({ originalTitle: true, year: true, status: true, metadata: true, fieldLocks: true });
export type MediaEntityCreate = z.infer<typeof MediaEntityCreate>;
/** 合并通过专门动作完成,Update 不接受 status=merged */
export const MediaEntityUpdate = MediaEntityCreate.partial().extend({
status: MediaEntityStatus.exclude(["merged"]).optional(),
});
export type MediaEntityUpdate = z.infer<typeof MediaEntityUpdate>;
export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]);
export type ContentUnitKind = z.infer<typeof ContentUnitKind>;
/** 综艺内容类型(REQ §5.1) */
export const ContentType = z.enum([
"main",
"extra",
"behind_the_scenes",
"performance_only",
"preview",
"reaction",
"unknown",
]);
export type ContentType = z.infer<typeof ContentType>;
export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]);
export type Segment = z.infer<typeof Segment>;
export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]);
export type ContentUnitStatus = z.infer<typeof ContentUnitStatus>;
/** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */
export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]);
export type EpisodeTotalSource = z.infer<typeof EpisodeTotalSource>;
export const ContentUnit = EntityBase.extend({
mediaEntityId: Id,
parentUnitId: Id.nullable(),
kind: ContentUnitKind,
contentType: ContentType,
segment: Segment,
seasonNumber: z.number().int().nonnegative().nullable(),
episodeNumber: z.number().int().nonnegative().nullable(),
/** 来源期号原文,如 "第十期"、"20260927" */
sourceIssue: z.string().nullable(),
airDate: DateOnly.nullable(),
displayOrder: z.number().nullable(),
title: z.string().nullable(),
status: ContentUnitStatus,
/** 人工确认的编号锁定;刷新不得改编号 */
numberingLocked: z.boolean(),
metadata: JsonObject,
fieldLocks: z.array(z.string()),
/** 季单元的总集数账本(只升;回退不低于已覆盖最高集);null = 未知,追更不结束 */
episodeTotal: z.number().int().nonnegative().nullable(),
episodeTotalSource: EpisodeTotalSource.nullable(),
/** 人工锁定总集数:元数据刷新与注入不再改写 */
episodeTotalLocked: z.boolean(),
episodeTotalAt: Timestamp.nullable(),
});
export type ContentUnit = z.infer<typeof ContentUnit>;
export const ContentUnitCreate = ContentUnit.omit(SERVER_MANAGED).partial({
parentUnitId: true,
contentType: true,
segment: true,
seasonNumber: true,
episodeNumber: true,
sourceIssue: true,
airDate: true,
displayOrder: true,
title: true,
status: true,
numberingLocked: true,
metadata: true,
fieldLocks: true,
episodeTotal: true,
episodeTotalSource: true,
episodeTotalLocked: true,
episodeTotalAt: true,
});
export type ContentUnitCreate = z.infer<typeof ContentUnitCreate>;
export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial();
export type ContentUnitUpdate = z.infer<typeof ContentUnitUpdate>;
export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]);
export type AliasKind = z.infer<typeof AliasKind>;
export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]);
export type AliasSource = z.infer<typeof AliasSource>;
export const TitleAlias = EntityBase.extend({
mediaEntityId: Id,
contentUnitId: Id.nullable(),
alias: z.string().min(1),
/** 归一化后的检索键(小写、去标点、全半角统一),建 trgm 索引 */
normalized: z.string().min(1),
language: z.string().nullable(),
kind: AliasKind,
source: AliasSource,
locked: z.boolean(),
/** 派生字段(列表返回):作品标题 */
mediaEntityTitle: z.string().nullable().optional(),
});
export type TitleAlias = z.infer<typeof TitleAlias>;
/** normalized 由服务端计算,不接受客户端传入 */
export const TitleAliasCreate = TitleAlias.pick({
mediaEntityId: true,
contentUnitId: true,
alias: true,
language: true,
kind: true,
source: true,
locked: true,
}).partial({ contentUnitId: true, language: true, kind: true, locked: true });
export type TitleAliasCreate = z.infer<typeof TitleAliasCreate>;
export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial();
export type TitleAliasUpdate = z.infer<typeof TitleAliasUpdate>;
// ── W2-B:字段级元数据(REQ §7.3)与作品详情 ──
export const MetadataSource = z.enum(["tmdb", "user", "agent", "nfo", "site", "import"]);
export type MetadataSource = z.infer<typeof MetadataSource>;
/** 某来源给出的一个候选字段值(刷新只写这里,不直接改最终展示值) */
export const MetadataField = EntityBase.extend({
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
field: z.string().min(1),
value: z.unknown(),
language: z.string().nullable(),
source: MetadataSource,
sourceRevision: z.string().nullable(),
fetchedAt: Timestamp,
});
export type MetadataField = z.infer<typeof MetadataField>;
/** 字段覆盖:用户锁定优先于任何自动刷新 */
export const FieldOverride = EntityBase.extend({
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
field: z.string().min(1),
value: z.unknown(),
locked: z.boolean(),
source: MetadataSource,
reason: z.string().nullable(),
actorType: ActorType,
actorId: z.string().nullable(),
});
export type FieldOverride = z.infer<typeof FieldOverride>;
/** 可覆盖/锁定的作品字段;title/originalTitle/year 同时投影到 media_entity 列 */
export const EntityMetadataField = z.enum([
"title",
"originalTitle",
"year",
"overview",
"releaseDate",
"originalLanguage",
"originCountry",
"posterPath",
]);
export type EntityMetadataField = z.infer<typeof EntityMetadataField>;
export const FieldOverrideInput = z.object({
value: z.unknown(),
lock: z.boolean().default(true),
reason: z.string().max(2000).optional(),
});
export type FieldOverrideInput = z.infer<typeof FieldOverrideInput>;
/** media_entity.metadata 中每个最终字段的形状 */
export const FinalFieldValue = z.object({
value: z.unknown(),
source: MetadataSource,
language: z.string().nullable(),
at: Timestamp,
locked: z.boolean(),
});
export type FinalFieldValue = z.infer<typeof FinalFieldValue>;
/** 作品详情:外部身份、别名、内容单元、字段来源与覆盖 */
export const MediaEntityDetail = z.object({
entity: MediaEntity,
/** 请求的是已合并的旧 ID 时,指向跳转后的实体 */
redirectedFrom: Id.nullable(),
identities: z.array(ExternalIdentity),
aliases: z.array(TitleAlias),
contentUnits: z.array(ContentUnit),
metadataFields: z.array(MetadataField),
overrides: z.array(FieldOverride),
});
export type MediaEntityDetail = z.infer<typeof MediaEntityDetail>;
/** 合并重复作品(保留旧 ID 跳转) */
export const MediaEntityMerge = z.object({ intoId: Id, reason: z.string().max(2000).optional() });
export type MediaEntityMerge = z.infer<typeof MediaEntityMerge>;