Files
UHD_Media_Tool/packages/contracts/src/assets.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock;
  remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports)
- tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions),
  test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types
- tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl
- server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting;
  drop @hono/node-server, tsx, esbuild build.mjs
- logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests
- relative imports get explicit extensions (sites/mp-sync paths left for follow-up)
- crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas
- single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml
- docs: README, runbook, ADR-001 superseded, plan §1 validation row

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-09-27 18:53:06 +08:00

189 lines
6.9 KiB
TypeScript

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<typeof AssetKind>;
export const AssetStatus = z.enum(["active", "replaced", "retired", "missing"]);
export type AssetStatus = z.infer<typeof AssetStatus>;
/** 交付特征(分辨率/编码/音轨/来源类型/发布组),与内容版本 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<typeof DeliveryTraits>;
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<typeof MediaAsset>;
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<typeof MediaAssetCreate>;
export const MediaAssetUpdate = MediaAssetCreate.partial();
export type MediaAssetUpdate = z.infer<typeof MediaAssetUpdate>;
export const CoverageKind = z.enum(["full", "partial", "segment", "unknown"]);
export type CoverageKind = z.infer<typeof CoverageKind>;
/** 一个资产覆盖哪些内容单元(E01-E03 合集、上/下分段) */
export const AssetUnitCoverage = EntityBase.extend({
mediaAssetId: Id,
contentUnitId: Id,
coverage: CoverageKind,
segment: Segment.nullable(),
evidence: JsonObject,
});
export type AssetUnitCoverage = z.infer<typeof AssetUnitCoverage>;
export const AssetUnitCoverageCreate = AssetUnitCoverage.omit(SERVER_MANAGED).partial({
coverage: true,
segment: true,
evidence: true,
});
export type AssetUnitCoverageCreate = z.infer<typeof AssetUnitCoverageCreate>;
export const AssetUnitCoverageUpdate = AssetUnitCoverageCreate.omit({
mediaAssetId: true,
contentUnitId: true,
}).partial();
export type AssetUnitCoverageUpdate = z.infer<typeof AssetUnitCoverageUpdate>;
export const LocationKind = z.enum(["library", "download_cache", "hardlink", "external"]);
export type LocationKind = z.infer<typeof LocationKind>;
/** 路径不存在时先区分根离线/权限/外部移动,再按 unavailable → suspected_missing → confirmed_missing 推进 */
export const LocationStatus = z.enum(["available", "unavailable", "suspected_missing", "confirmed_missing", "removed"]);
export type LocationStatus = z.infer<typeof LocationStatus>;
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<typeof AssetLocation>;
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<typeof AssetLocationCreate>;
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<typeof AssetLocationUpdate>;
export const BindingKind = z.enum(["entity", "season"]);
export type BindingKind = z.infer<typeof BindingKind>;
export const BindingStatus = z.enum(["active", "conflict", "superseded"]);
export type BindingStatus = z.infer<typeof BindingStatus>;
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<typeof PathBinding>;
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<typeof PathBindingCreate>;
/** 绑定路径不可就地修改:只能改备注或标记冲突/被替代;换目录走整理操作生成新绑定 */
export const PathBindingUpdate = z.object({
status: BindingStatus.optional(),
supersededById: Id.nullable().optional(),
note: z.string().nullable().optional(),
});
export type PathBindingUpdate = z.infer<typeof PathBindingUpdate>;