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; export const MediaEntityStatus = z.enum(["provisional", "active", "merged", "archived"]); export type MediaEntityStatus = z.infer; 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; 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; /** 合并通过专门动作完成,Update 不接受 status=merged */ export const MediaEntityUpdate = MediaEntityCreate.partial().extend({ status: MediaEntityStatus.exclude(["merged"]).optional(), }); export type MediaEntityUpdate = z.infer; export const ContentUnitKind = z.enum(["season", "episode", "special", "part"]); export type ContentUnitKind = z.infer; /** 综艺内容类型(REQ §5.1) */ export const ContentType = z.enum([ "main", "extra", "behind_the_scenes", "performance_only", "preview", "reaction", "unknown", ]); export type ContentType = z.infer; export const Segment = z.enum(["full", "upper", "middle", "lower", "unknown"]); export type Segment = z.infer; export const ContentUnitStatus = z.enum(["provisional", "confirmed", "retired"]); export type ContentUnitStatus = z.infer; /** 季总集数来源:tmdb 元数据 / 插件注入(只升)/ 人工锁定 / 回退被已覆盖最高集兜住 / 沿用 */ export const EpisodeTotalSource = z.enum(["tmdb", "injected", "manual", "covered_floor", "previous"]); export type EpisodeTotalSource = z.infer; 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; 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; export const ContentUnitUpdate = ContentUnitCreate.omit({ mediaEntityId: true }).partial(); export type ContentUnitUpdate = z.infer; export const AliasKind = z.enum(["original", "translated", "short", "site_title", "romanized", "other"]); export type AliasKind = z.infer; export const AliasSource = z.enum(["tmdb", "user", "site", "agent", "nfo", "import"]); export type AliasSource = z.infer; 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; /** 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; export const TitleAliasUpdate = TitleAliasCreate.omit({ mediaEntityId: true }).partial(); export type TitleAliasUpdate = z.infer; // ── W2-B:字段级元数据(REQ §7.3)与作品详情 ── export const MetadataSource = z.enum(["tmdb", "user", "agent", "nfo", "site", "import"]); export type MetadataSource = z.infer; /** 某来源给出的一个候选字段值(刷新只写这里,不直接改最终展示值) */ 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; /** 字段覆盖:用户锁定优先于任何自动刷新 */ 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; /** 可覆盖/锁定的作品字段;title/originalTitle/year 同时投影到 media_entity 列 */ export const EntityMetadataField = z.enum([ "title", "originalTitle", "year", "overview", "releaseDate", "originalLanguage", "originCountry", "posterPath", ]); export type EntityMetadataField = z.infer; export const FieldOverrideInput = z.object({ value: z.unknown(), lock: z.boolean().default(true), reason: z.string().max(2000).optional(), }); export type FieldOverrideInput = z.infer; /** 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; /** 作品详情:外部身份、别名、内容单元、字段来源与覆盖 */ 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; /** 合并重复作品(保留旧 ID 跳转) */ export const MediaEntityMerge = z.object({ intoId: Id, reason: z.string().max(2000).optional() }); export type MediaEntityMerge = z.infer;