Files
UHD_Media_Tool/packages/contracts/src/organization.ts
T
rootandClaude Opus 5.5 36aa99b8aa feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download
- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

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

613 lines
21 KiB
TypeScript

import { z } from "zod";
import { AgentRunStatus, DecisionKind } from "./agent.ts";
import { AssetLocation, MediaAsset, PathBinding } from "./assets.ts";
import { ActorType, EntityBase, Id, JsonObject, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntity, MediaKind } from "./content.ts";
import { AcquisitionPlan, DownloadStatus, DownloadTask } from "./execution.ts";
import { ExternalIdentity } from "./identity.ts";
import { AcquisitionSlot, CandidateEvaluation, SlotStatus } from "./policy.ts";
// 整理域:整理记录(批次)+ 逐文件结果,以及对外开放的整理记录 API 合同(REQ §12.4)
// 自动与手动整理产生同格式记录;读记录需 organize.read,发起整理需 organize.execute。
export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
/**
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
*/
export const OrganizationAction = z.enum([
"copy",
"move",
"rename",
"relocate",
"hardlink",
"trash",
"delete",
"restore",
]);
export type OrganizationAction = z.infer<typeof OrganizationAction>;
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
export const SourcePathView = z.enum(["downloader", "local", "library"]);
export type SourcePathView = z.infer<typeof SourcePathView>;
export const FileRole = z.enum(["main", "subtitle", "audio", "bluray_dir", "nfo", "image", "other"]);
export type FileRole = z.infer<typeof FileRole>;
export const FileResultStatus = z.enum(["queued", "running", "succeeded", "failed", "skipped", "cancelled"]);
export type FileResultStatus = z.infer<typeof FileResultStatus>;
/** 稳定变更游标:bigint 序号的十进制字符串 */
export const ChangeSeq = z.number().int().nonnegative();
export const ChangeCursor = z.string().regex(/^\d{1,16}$/);
export type ChangeCursor = z.infer<typeof ChangeCursor>;
// ── 内部行表示(与 organization_record / organization_file_result 列一一对应,不含 change_xid) ──
export const OrganizationRecord = EntityBase.extend({
taskId: Id.nullable(),
trigger: OrganizationTrigger,
actorType: ActorType,
actorId: z.string().nullable(),
mediaEntityId: Id.nullable(),
contentUnitId: Id.nullable(),
downloadTaskId: Id.nullable(),
sourceDownloaderInstanceId: Id.nullable(),
sourcePathView: SourcePathView,
sourcePath: z.string().nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
reason: z.string().nullable(),
error: JsonObject.nullable(),
/** 冻结的整理计划 */
plan: JsonObject,
planFingerprint: z.string().nullable(),
fileTotal: NonNegInt,
fileSucceeded: NonNegInt,
fileFailed: NonNegInt,
fileSkipped: NonNegInt,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
changeSeq: ChangeSeq,
});
export type OrganizationRecord = z.infer<typeof OrganizationRecord>;
export const OrganizationRecordCreate = OrganizationRecord.pick({
taskId: true,
trigger: true,
actorType: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
action: true,
plan: true,
planFingerprint: true,
}).partial({
taskId: true,
actorId: true,
mediaEntityId: true,
contentUnitId: true,
downloadTaskId: true,
sourceDownloaderInstanceId: true,
sourcePathView: true,
sourcePath: true,
targetLibraryRootId: true,
targetPath: true,
plan: true,
planFingerprint: true,
});
export type OrganizationRecordCreate = z.infer<typeof OrganizationRecordCreate>;
export const OrganizationRecordUpdate = OrganizationRecord.pick({
status: true,
reason: true,
error: true,
targetPath: true,
fileTotal: true,
fileSucceeded: true,
fileFailed: true,
fileSkipped: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationRecordUpdate = z.infer<typeof OrganizationRecordUpdate>;
export const OrganizationFileResult = EntityBase.extend({
organizationRecordId: Id,
ordinal: z.number().int().nonnegative(),
assetOperationId: Id.nullable(),
mediaAssetId: Id.nullable(),
contentUnitId: Id.nullable(),
role: FileRole,
mode: OrganizationAction,
sourcePath: z.string().min(1),
sourceSizeBytes: NonNegInt.nullable(),
targetLibraryRootId: Id.nullable(),
targetPath: z.string().nullable(),
status: FileResultStatus,
skipReason: z.string().nullable(),
error: JsonObject.nullable(),
checksum: z.string().nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type OrganizationFileResult = z.infer<typeof OrganizationFileResult>;
export const OrganizationFileResultCreate = OrganizationFileResult.pick({
organizationRecordId: true,
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
mode: true,
sourcePath: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
}).partial({
ordinal: true,
assetOperationId: true,
contentUnitId: true,
role: true,
sourceSizeBytes: true,
targetLibraryRootId: true,
targetPath: true,
});
export type OrganizationFileResultCreate = z.infer<typeof OrganizationFileResultCreate>;
export const OrganizationFileResultUpdate = OrganizationFileResult.pick({
assetOperationId: true,
mediaAssetId: true,
targetPath: true,
status: true,
skipReason: true,
error: true,
checksum: true,
startedAt: true,
finishedAt: true,
}).partial();
export type OrganizationFileResultUpdate = z.infer<typeof OrganizationFileResultUpdate>;
// ── 对外开放的整理记录合同(外部程序据此适配媒体服务器) ──
/** 失败/跳过原因:稳定 code + 可读说明;不含凭据与私有 URL */
export const PublicReason = z.object({
code: z.string().min(1),
message: z.string(),
});
export type PublicReason = z.infer<typeof PublicReason>;
/** 路径端点:路径 + 所在视图标识,避免跨实例同路径歧义 */
export const PublicPathRef = z.object({
path: z.string().nullable(),
view: z.enum(["downloader", "local", "library"]),
downloaderInstanceId: Id.nullable(),
libraryRootId: Id.nullable(),
});
export type PublicPathRef = z.infer<typeof PublicPathRef>;
export const PublicOrganizationFile = z.object({
id: Id,
ordinal: z.number().int().nonnegative(),
role: FileRole,
mode: OrganizationAction,
status: FileResultStatus,
source: PublicPathRef,
target: PublicPathRef,
sizeBytes: NonNegInt.nullable(),
contentUnitId: Id.nullable(),
mediaAssetId: Id.nullable(),
operationId: Id.nullable(),
skipReason: PublicReason.nullable(),
failure: PublicReason.nullable(),
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
});
export type PublicOrganizationFile = z.infer<typeof PublicOrganizationFile>;
export const PublicOrganizationRecord = z.object({
/** 稳定记录 ID */
id: Id,
taskId: Id.nullable(),
/** 来源下载任务(手动整理本地路径时为 null) */
downloadTaskId: Id.nullable(),
/** 相关资产操作账本 ID */
operationIds: z.array(Id),
media: z
.object({
entityId: Id,
kind: MediaKind,
title: z.string(),
year: z.number().int().nullable(),
contentUnitId: Id.nullable(),
seasonNumber: z.number().int().nullable(),
episodeNumber: z.number().int().nullable(),
/** 已确认的外部身份,如 tmdb:movie:693134 */
externalIds: z.array(z.object({ provider: z.string(), entityType: z.string(), externalId: z.string() })),
})
.nullable(),
trigger: OrganizationTrigger,
/** 手动 / 自动来源(agent 视为自动) */
origin: z.enum(["manual", "auto"]),
actor: z.object({ type: ActorType, id: z.string().nullable() }),
action: OrganizationAction,
status: OrganizationStatus,
source: PublicPathRef,
target: PublicPathRef,
reason: PublicReason.nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
/** includeFiles=false 时为 null */
files: z.array(PublicOrganizationFile).nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
startedAt: Timestamp.nullable(),
finishedAt: Timestamp.nullable(),
/** 该记录最近一次变更的游标值;客户端按 id 去重、以最大 changeSeq 续读 */
changeSeq: ChangeCursor,
});
export type PublicOrganizationRecord = z.infer<typeof PublicOrganizationRecord>;
/** 增量读取请求:after 为上次响应的 nextCursor(首读省略即从头) */
export const OrganizationChangesQuery = z.object({
after: ChangeCursor.optional(),
limit: z.coerce.number().int().min(1).max(500).default(100),
includeFiles: z.union([z.boolean(), z.enum(["true", "false"]).transform((v) => v === "true")]).default(true),
status: OrganizationStatus.optional(),
mediaEntityId: Id.optional(),
});
export type OrganizationChangesQuery = z.infer<typeof OrganizationChangesQuery>;
/**
* 增量读取响应:items 按 changeSeq 升序;nextCursor 总是返回(无新数据时等于请求的 after),
* hasMore=true 表示应立即继续拉取。同一记录状态更新会以更大的 changeSeq 再次出现。
*/
export const OrganizationChangesResponse = z.object({
items: z.array(PublicOrganizationRecord),
nextCursor: ChangeCursor,
hasMore: z.boolean(),
});
export type OrganizationChangesResponse = z.infer<typeof OrganizationChangesResponse>;
/** 列表查询(控制台):按创建时间倒序的不透明游标分页 */
export const OrganizationListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
status: OrganizationStatus.optional(),
trigger: OrganizationTrigger.optional(),
mediaEntityId: Id.optional(),
downloadTaskId: Id.optional(),
q: z.string().max(200).optional(),
});
export type OrganizationListQuery = z.infer<typeof OrganizationListQuery>;
/** 手动发起整理(organize.execute):返回 record/task ID,由任务页追踪 */
export const OrganizeRequest = z
.object({
downloadTaskId: Id.optional(),
/** 执行进程可访问的源路径(与 downloadTaskId 二选一) */
sourcePaths: z.array(z.string().startsWith("/")).min(1).max(1000).optional(),
mediaEntityId: Id.optional(),
contentUnitId: Id.optional(),
action: z.enum(["copy", "move"]),
targetLibraryRootId: Id.optional(),
/** true 只生成计划(organize.plan),不执行 */
dryRun: z.boolean().default(false),
})
.refine((v) => !!v.downloadTaskId !== !!v.sourcePaths, { message: "downloadTaskId 与 sourcePaths 必须二选一" });
export type OrganizeRequest = z.infer<typeof OrganizeRequest>;
export const OrganizeAccepted = z.object({
recordId: Id.nullable(),
taskId: Id,
planId: Id.nullable(),
/** dryRun 按路径遍历时文件数超过上限(ORGANIZE_WALK_MAX_FILES),计划只含前 N 个文件 */
truncated: z.boolean().optional(),
});
export type OrganizeAccepted = z.infer<typeof OrganizeAccepted>;
// ── 媒体库 / 库存叠加 / 首页聚合(W2-C 读模型,均为只读投影) ──
/** 各 Slot 状态计数(不用 record:键集固定,缺省 0) */
export const SlotStatusCounts = z.object({
empty: NonNegInt,
reserved: NonNegInt,
downloading: NonNegInt,
verifying: NonNegInt,
satisfied: NonNegInt,
upgrade_pending: NonNegInt,
attention: NonNegInt,
});
export type SlotStatusCounts = z.infer<typeof SlotStatusCounts>;
export const LibraryListQuery = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
kind: MediaKind.optional(),
/** 标题/原名/别名模糊搜索 */
q: z.string().max(200).optional(),
/** gap = 有未满足 Slot;downloading = 有下载中任务;attention = 有 Slot 需处理 */
filter: z.enum(["gap", "downloading", "attention", "satisfied"]).optional(),
});
export type LibraryListQuery = z.infer<typeof LibraryListQuery>;
export const LibraryItem = z.object({
entity: MediaEntity.pick({ id: true, kind: true, title: true, originalTitle: true, year: true, status: true }),
assetCount: NonNegInt,
slotCounts: SlotStatusCounts,
activeDownloads: NonNegInt,
lastOrganizedAt: Timestamp.nullable(),
});
export type LibraryItem = z.infer<typeof LibraryItem>;
/** 资产位置 + 执行进程可访问的实际路径(库根离线时仍返回拼接路径,状态看 location.status) */
export const LibraryAssetView = z.object({
asset: MediaAsset,
locations: z.array(AssetLocation.extend({ absolutePath: z.string().nullable() })),
/** 覆盖的内容单元 */
contentUnitIds: z.array(Id),
});
export type LibraryAssetView = z.infer<typeof LibraryAssetView>;
export const LibraryDownloadView = z.object({
downloadTask: DownloadTask,
plan: AcquisitionPlan.nullable(),
/** 该下载任务的整理记录(含文件级核验结果) */
organizationRecordIds: z.array(Id),
lastOrganizationStatus: OrganizationStatus.nullable(),
});
export type LibraryDownloadView = z.infer<typeof LibraryDownloadView>;
export const LibraryDetail = z.object({
entity: MediaEntity,
/** 身份映射状态(含 proposed/conflicted,前端区分展示) */
identities: z.array(ExternalIdentity),
/** 元数据来源:metadata 中各字段的来源标注 + 人工锁 */
metadataSources: z.object({ fields: JsonObject, fieldLocks: z.array(z.string()) }),
pathBindings: z.array(PathBinding),
assets: z.array(LibraryAssetView),
slots: z.array(AcquisitionSlot),
candidates: z.array(CandidateEvaluation),
downloads: z.array(LibraryDownloadView),
});
export type LibraryDetail = z.infer<typeof LibraryDetail>;
/** 站点工作台叠加:按作品返回已有版本 / Slot 缺口 / 下载中 */
export const InventoryQuery = z.object({
/** 逗号分隔的作品 UUID,最多 200 个 */
entityIds: z.string().min(1).max(8000),
});
export type InventoryQuery = z.infer<typeof InventoryQuery>;
export const InventoryEntry = z.object({
entityId: Id,
/** 已入库版本(主视频资产的交付特征) */
versions: z.array(
z.object({
assetId: Id,
resolution: z.string().nullable(),
sourceType: z.string().nullable(),
releaseGroup: z.string().nullable(),
contentEdition: z.string().nullable(),
sizeBytes: NonNegInt.nullable(),
available: z.boolean(),
}),
),
/** 未满足的 Slot(empty/attention/upgrade_pending) */
gaps: z.array(z.object({ slotId: Id, name: z.string(), status: SlotStatus })),
downloading: z.array(
z.object({ downloadTaskId: Id, name: z.string(), status: DownloadStatus, progress: z.number().min(0).max(1) }),
),
/** 有生效中的订阅(active) */
subscribed: z.boolean(),
/**
* 候选理由:该作品各 Slot 对来源资源的最近一次评估(每个 (Slot, 资源) 一条,新→旧,最多 50 条)。
* 工作台按 sourceReleaseId 取当前资源"为什么选 / 不选"。
*/
candidateReasons: z.array(
z.object({
slotId: Id,
slotName: z.string(),
sourceReleaseId: Id,
decision: CandidateEvaluation.shape.decision,
/** 拒绝原因 message + 评估说明,已合并去重 */
reasons: z.array(z.string()),
planId: Id.nullable(),
evaluatedAt: Timestamp,
}),
),
});
export type InventoryEntry = z.infer<typeof InventoryEntry>;
export const InventoryResponse = z.object({ items: z.array(InventoryEntry) });
export type InventoryResponse = z.infer<typeof InventoryResponse>;
/** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */
export const MediaServerInventoryQuery = z.object({ mediaEntityId: Id });
export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>;
export const MediaServerInventory = z.object({
mediaEntityId: Id,
instances: z.array(
z.object({
instanceId: Id,
name: z.string(),
status: z.enum(["present", "not_found", "unknown"]),
reason: z.string().nullable(),
items: z.array(
z.object({
itemId: z.string(),
type: z.string(),
name: z.string(),
year: z.number().int().nullable(),
path: z.string().nullable(),
versions: z.array(JsonObject),
}),
),
observedAt: Timestamp,
}),
),
});
export type MediaServerInventory = z.infer<typeof MediaServerInventory>;
/** 首页六区块(REQ §13.4.3);统计未知时对应字段为 null 并在 notes 说明 */
export const Dashboard = z.object({
generatedAt: Timestamp,
automation: z.object({
enabled: z.boolean(),
readOnlyReconcile: z.boolean(),
today: z.object({
plansCreated: NonNegInt,
downloadsSubmitted: NonNegInt,
filesImported: NonNegInt,
recordsSucceeded: NonNegInt,
recordsFailed: NonNegInt,
upgrades: NonNegInt,
}),
activeSubscriptions: NonNegInt,
slotCounts: SlotStatusCounts,
}),
currentWork: z.object({
downloading: NonNegInt,
paused: NonNegInt,
stalled: NonNegInt,
importing: NonNegInt,
reservedPlans: NonNegInt,
runningRecords: NonNegInt,
agentRuns: z.array(z.object({ id: Id, goal: z.string(), status: AgentRunStatus })),
items: z.array(
z.object({
downloadTaskId: Id,
name: z.string(),
status: DownloadStatus,
progress: z.number().min(0).max(1),
waitReason: z.string().nullable(),
}),
),
}),
needsAttention: z.object({
total: NonNegInt,
byKind: z.array(z.object({ kind: DecisionKind, count: NonNegInt })),
}),
cache: z.object({
volumes: z.array(
z.object({
volumeKey: z.string(),
instanceIds: z.array(Id),
usedPercent: z.number().min(0).max(100).nullable(),
totalBytes: NonNegInt.nullable(),
usedBytes: NonNegInt.nullable(),
pendingBytes: NonNegInt,
reservedBytes: NonNegInt,
hardThresholdPercent: z.number(),
resumePercent: z.number(),
pausedByThreshold: NonNegInt,
cleanableBytes: NonNegInt,
retentionLockedBytes: NonNegInt,
notes: z.array(z.string()),
}),
),
}),
sources: z.object({
sites: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSnapshotAt: Timestamp.nullable(),
}),
),
feeds: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSuccessAt: Timestamp.nullable(),
consecutiveFailures: NonNegInt,
lastError: z.string().nullable(),
}),
),
downloaders: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSeenAt: Timestamp.nullable(),
}),
),
mediaServers: z.array(
z.object({
id: Id,
name: z.string(),
enabled: z.boolean(),
health: z.string(),
lastSeenAt: Timestamp.nullable(),
}),
),
}),
recentOutput: z.array(
z.object({
recordId: Id,
mediaEntityId: Id.nullable(),
title: z.string().nullable(),
action: OrganizationAction,
status: OrganizationStatus,
targetPath: z.string().nullable(),
counts: z.object({ total: NonNegInt, succeeded: NonNegInt, failed: NonNegInt, skipped: NonNegInt }),
finishedAt: Timestamp.nullable(),
}),
),
});
export type Dashboard = z.infer<typeof Dashboard>;
/**
* 手动整理 dryRun 冻结的计划 artifact(kind=plan,title=organize_plan)的 content:
* 原请求 + 冻结计划(与 domain OrganizePlan 同形,这里只约束展示字段)。GET /artifacts/{id} 读取。
*/
export const OrganizePlanArtifactContent = z.object({
request: z.record(z.string(), z.unknown()).optional(),
/** 源目录文件数超过上限,计划只含前 N 个文件 */
truncated: z.boolean().optional(),
status: z.string().optional(),
reason: z.string().nullable().optional(),
plan: z
.object({
targetDirs: z.array(z.string()).optional(),
organize: z
.object({
operation: z.string(),
crossDevice: z.boolean(),
requiresApproval: z.boolean(),
files: z.array(
z.object({
src: z.string(),
dst: z.string(),
size: z.number(),
kind: z.string(),
action: z.string(),
skipReason: z.string().optional(),
}),
),
excluded: z.array(z.object({ src: z.string(), reason: z.string(), detail: z.string().optional() })),
conflicts: z.array(
z.object({
kind: z.string(),
blocking: z.boolean(),
src: z.string().optional(),
detail: z.string().optional(),
}),
),
})
.optional(),
})
.nullable()
.optional(),
});
export type OrganizePlanArtifactContent = z.infer<typeof OrganizePlanArtifactContent>;