import { z } from "zod"; import { EntityBase, Id, JsonObject, NonNegInt, SERVER_MANAGED, Timestamp } from "./common.ts"; import { Segment } from "./content.ts"; // 文件域:资产、资产-内容单元覆盖、资产位置、路径绑定(REQ §7.1、§8.2、§12.2) // 资产与位置分离;path_binding 一旦建立不因元数据刷新改变,只有显式整理可替换(旧绑定标 superseded)。 export const AssetKind = z.enum(["video", "bluray_dir", "subtitle", "audio", "nfo", "image", "other"]); export type AssetKind = z.infer; export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]); export type AssetStatus = z.infer; /** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 contentEdition 分开(REQ §6.1) */ export const DeliveryTraits = z.object({ resolution: z.string().optional(), videoCodec: z.string().optional(), audioCodecs: z.array(z.string()).optional(), hdr: z.array(z.string()).optional(), sourceType: z.string().optional(), releaseGroup: z.string().optional(), subtitles: z.array(z.string()).optional(), }); export type DeliveryTraits = z.infer; export const MediaAsset = EntityBase.extend({ mediaEntityId: Id.nullable(), kind: AssetKind, /** 附属文件(字幕/音轨)指向主视频资产 */ parentAssetId: Id.nullable(), sizeBytes: NonNegInt.nullable(), contentHash: z.string().nullable(), hashAlgorithm: z.string().nullable(), sampleDigest: z.string().nullable(), /** ffprobe/MediaInfo 结果 */ mediaInfo: JsonObject, delivery: DeliveryTraits, /** 内容版本:院线版/导演剪辑版等 */ contentEdition: z.string().nullable(), sourceReleaseId: Id.nullable(), downloadTaskId: Id.nullable(), status: AssetStatus, verifiedAt: Timestamp.nullable(), }); export type MediaAsset = z.infer; export const MediaAssetCreate = MediaAsset.omit(SERVER_MANAGED).partial({ mediaEntityId: true, parentAssetId: true, sizeBytes: true, contentHash: true, hashAlgorithm: true, sampleDigest: true, mediaInfo: true, delivery: true, contentEdition: true, sourceReleaseId: true, downloadTaskId: true, status: true, verifiedAt: true, }); export type MediaAssetCreate = z.infer; export const MediaAssetUpdate = MediaAssetCreate.partial(); export type MediaAssetUpdate = z.infer; export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]); export type CoverageKind = z.infer; /** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */ export const AssetUnitCoverage = EntityBase.extend({ mediaAssetId: Id, contentUnitId: Id, coverage: CoverageKind, segment: Segment.nullable(), evidence: JsonObject, }); export type AssetUnitCoverage = z.infer; export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({ coverage: true, segment: true, evidence: true, }); export type AssetUnitCoverageCreate = z.infer; export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({ mediaAssetId: true, contentUnitId: true, }).partial(); export type AssetUnitCoverageUpdate = z.infer; export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]); export type LocationKind = z.infer; /** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */ export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]); export type LocationStatus = z.infer; export const AssetLocation = EntityBase.extend({ mediaAssetId: Id, locationKind: LocationKind, libraryRootId: Id.nullable(), downloaderInstanceId: Id.nullable(), /** 相对库根(或下载器保存根)的路径 */ relativePath: z.string().min(1), status: LocationStatus, isPrimary: z.boolean(), sizeBytes: NonNegInt.nullable(), inode: z.number().int().nullable(), device: z.number().int().nullable(), mtime: Timestamp.nullable(), sidecarOfLocationId: Id.nullable(), lastSeenAt: Timestamp.nullable(), lastCheckedAt: Timestamp.nullable(), checkDetail: JsonObject, }); export type AssetLocation = z.infer; export const AssetLocationCreate = AssetLocation.omit(SERVER_MANAGED) .partial({ locationKind: true, libraryRootId: true, downloaderInstanceId: true, status: true, isPrimary: true, sizeBytes: true, inode: true, device: true, mtime: true, sidecarOfLocationId: true, lastSeenAt: true, lastCheckedAt: true, checkDetail: true, }) .refine((v) => (v.locationKind ?? "library") !== "library" || !!v.libraryRootId, { message: "library 位置必须指定 libraryRootId", }); export type AssetLocationCreate = z.infer; export const AssetLocationUpdate = AssetLocation.pick({ status: true, isPrimary: true, sizeBytes: true, inode: true, device: true, mtime: true, lastSeenAt: true, lastCheckedAt: true, checkDetail: true, }).partial(); export type AssetLocationUpdate = z.infer; export const BindingKind = z.enum(["entity", "season"]); export type BindingKind = z.infer; export const BindingStatus = z.enum(["active", "conflict", "superseded"]); export type BindingStatus = z.infer; export const PathBinding = EntityBase.extend({ mediaEntityId: Id, contentUnitId: Id.nullable(), seasonNumber: z.number().int().nonnegative().nullable(), bindingKind: BindingKind, libraryRootId: Id, relativePath: z.string().min(1), templateVersion: z.string().nullable(), status: BindingStatus, boundAt: Timestamp, boundBy: z.string().nullable(), supersededById: Id.nullable(), note: z.string().nullable(), }); export type PathBinding = z.infer; export const PathBindingCreate = PathBinding.pick({ mediaEntityId: true, contentUnitId: true, seasonNumber: true, bindingKind: true, libraryRootId: true, relativePath: true, templateVersion: true, status: true, note: true, }) .partial({ contentUnitId: true, seasonNumber: true, templateVersion: true, status: true, note: true }) .refine((v) => (v.bindingKind === "season") === (v.seasonNumber !== undefined && v.seasonNumber !== null), { message: "season 绑定必须给 seasonNumber,entity 绑定不能给", }); export type PathBindingCreate = z.infer; /** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */ export const PathBindingUpdate = z.object({ status: BindingStatus.optional(), supersededById: Id.nullable().optional(), note: z.string().nullable().optional(), }); export type PathBindingUpdate = z.infer;