- 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]>
189 lines
6.9 KiB
TypeScript
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>;
|