import { z } from "zod"; import { Id, JsonObject, NonNegInt, Timestamp } from "./common.ts"; import { MediaKind } from "./content.ts"; import { DownloadStatus } from "./execution.ts"; import { InstanceHealth, LibraryMediaKind } from "./instances.ts"; import { OrganizationStatus, PublicOrganizationRecord } from "./organization.ts"; import { SlotStatus } from "./policy.ts"; // 文件管理(`/files`,docs/plan/06-file-manager-spec.md)。 // 只能浏览已登记的库根与下载根;路径一律用 {root, path}:root 为 FileRootId,path 为根内相对路径("" = 根)。 // 所有写请求带 idempotencyKey(前端生成 UUID),同键重放返回首次结果。 // 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId; // 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。 /** `lib:` 或 `dl::<路径映射序号>` */ export const FileRootId = z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i, { message: "非法根 id" }); export type FileRootId = z.infer; /** 根内相对路径:不以 / 开头,不含 `..` 段与 NUL;服务端仍会 realpath 复核 */ export const FileRelPath = z .string() .max(4096) .refine((p) => !p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), { message: "路径必须是根内相对路径,不能含 .. 或 .", }) .default(""); export type FileRelPath = z.infer; /** 单个文件名段:不含 /、NUL,不为 . / .. */ export const FileName = z .string() .min(1) .max(255) .refine((s) => !s.includes("/") && !s.includes("\0") && s !== "." && s !== "..", { message: "非法文件名" }); export const FileRef = z.object({ root: FileRootId, path: FileRelPath }); export type FileRef = z.infer; export const IdempotencyKey = z.string().min(8).max(200); // ── 根 ── export const FileRootKind = z.enum(["library", "download"]); export type FileRootKind = z.infer; export const FileRoot = z.object({ id: FileRootId, kind: FileRootKind, name: z.string(), /** 执行进程本机路径 */ hostPath: z.string(), /** 库根的媒体类型;下载根为 null */ mediaKind: LibraryMediaKind.nullable(), /** 库根 managed 且进程可写;register_only 与下载根恒 false(下载根不做写操作的目标,可作源) */ writable: z.boolean(), health: InstanceHealth, checkedAt: Timestamp, /** 健康探测失败原因(离线时) */ healthReason: z.string().nullable(), usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(), }); export type FileRoot = z.infer; export const FileRootList = z.object({ items: z.array(FileRoot) }); export type FileRootList = z.infer; // ── 列表 ── export const FileEntryType = z.enum(["file", "dir", "symlink", "other"]); export type FileEntryType = z.infer; export const FileKindTag = z.enum(["video", "subtitle", "audio", "nfo", "image", "other"]); export type FileKindTag = z.infer; /** 行主状态,按优先级取一个:temp > downloading > registered > seeding > unidentified > plain */ export const FileStatus = z.enum(["temp", "downloading", "registered", "seeding", "unidentified", "plain"]); export type FileStatus = z.infer; export const HnrState = z.enum(["satisfied", "unsatisfied", "unknown"]); export type HnrState = z.infer; export const FileAssetLink = z.object({ assetId: Id, locationId: Id, mediaEntityId: Id, title: z.string(), year: z.number().int().nullable(), /** S01E02 / S01 / null(电影) */ contentUnit: z.string().nullable(), slotId: Id.nullable(), slotStatus: SlotStatus.nullable(), isPrimary: z.boolean(), }); export type FileAssetLink = z.infer; export const FileDownloadLink = z.object({ taskId: Id, status: DownloadStatus, /** 0..1 */ progress: z.number().nullable(), hnr: HnrState, }); export type FileDownloadLink = z.infer; export const FileMediaSummary = z.object({ resolution: z.string().nullable(), hdr: z.string().nullable(), videoCodec: z.string().nullable(), audioTracks: NonNegInt, durationSec: z.number().nullable(), }); export type FileMediaSummary = z.infer; export const FileEntry = z.object({ name: z.string(), /** 根内相对路径 */ path: z.string(), type: FileEntryType, size: NonNegInt, mtime: Timestamp.nullable(), ext: z.string().nullable(), fileKind: FileKindTag.nullable(), status: FileStatus, /** 符号链接目标在根外(或无法解析):禁用所有操作 */ escape: z.boolean(), asset: FileAssetLink.nullable(), download: FileDownloadLink.nullable(), media: FileMediaSummary.nullable(), /** 仅目录且在当前页:{ videoCount, registeredCount, partial } */ dirSummary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }).nullable(), }); export type FileEntry = z.infer; export const FileSort = z.enum(["name", "mtime", "size", "status"]); export const FileFilter = z.enum([ "video", "subtitle", "registered", "unregistered", "downloading", "temp", "unidentified", ]); export type FileFilter = z.infer; const boolQuery = z .union([z.boolean(), z.enum(["true", "false", "1", "0"])]) .transform((v) => v === true || v === "true" || v === "1"); export const FilesListQuery = z.object({ root: FileRootId, path: FileRelPath, cursor: z.string().min(1).max(500).optional(), limit: z.coerce.number().int().min(1).max(1000).default(200), sort: FileSort.default("name"), order: z.enum(["asc", "desc"]).default("asc"), /** 子串或 glob(* ?),大小写不敏感 */ q: z.string().max(200).optional(), /** 逗号分隔多选,如 filter=video,unidentified */ filter: z .string() .max(200) .transform((s, ctx) => { const parts = s .split(",") .map((x) => x.trim()) .filter(Boolean); const out: FileFilter[] = []; for (const p of parts) { const r = FileFilter.safeParse(p); if (!r.success) { ctx.addIssue({ code: "custom", message: `未知 filter: ${p}` }); return z.NEVER; } out.push(r.data); } return out; }) .optional(), /** 默认 false:隐藏点文件、.uhd-partial、.umt-trash、哨兵 */ hidden: boolQuery.default(false), }); export type FilesListQuery = z.infer; export const FilesListResponse = z.object({ root: FileRootId, path: z.string(), entries: z.array(FileEntry), nextCursor: z.string().nullable(), /** 过滤后的总项数(≤ 读取上限) */ total: NonNegInt, /** 目录项数超过 FILES_LIST_MAX_ENTRIES,只读取了前 N 项 */ truncated: z.boolean(), /** cursor 对应的目录快照已变化(目录 mtime 不同),前端应从头重载 */ stale: z.boolean(), scannedAt: Timestamp, }); export type FilesListResponse = z.infer; export const FileStatQuery = z.object({ root: FileRootId, path: FileRelPath }); export type FileStatQuery = z.infer; export const FileBindingRef = z.object({ bindingId: Id, kind: z.enum(["entity", "season"]), mediaEntityId: Id, title: z.string(), seasonNumber: z.number().int().nullable(), }); export type FileBindingRef = z.infer; export const FileStat = z.object({ entry: FileEntry, realPath: z.string(), inode: NonNegInt.nullable(), device: NonNegInt.nullable(), nlink: NonNegInt.nullable(), /** nlink>1:共享 inode,禁止原地改写 */ sharedInode: z.boolean(), /** 同 stem 的字幕/NFO/图片(根内相对路径) */ sidecars: z.array(z.string()), /** 命中该目录的 active path_binding */ bindings: z.array(FileBindingRef), }); export type FileStat = z.infer; // ── 写操作 ── export const FilesMkdirRequest = z.object({ idempotencyKey: IdempotencyKey, root: FileRootId, /** 父目录(根内相对路径) */ path: FileRelPath, name: FileName, }); export type FilesMkdirRequest = z.infer; export const FilesMkdirResult = z.object({ root: FileRootId, path: z.string() }); export type FilesMkdirResult = z.infer; export const FilesRenameRequest = z.object({ idempotencyKey: IdempotencyKey, root: FileRootId, path: FileRelPath, newName: FileName, /** 视频改名时同 stem 的字幕/NFO/图片一起改 */ withSidecars: z.boolean().default(true), dryRun: z.boolean().default(false), }); export type FilesRenameRequest = z.infer; export const FileRenamePair = z.object({ from: z.string(), to: z.string(), role: z.enum(["main", "sidecar"]), }); export const FilesRenameResult = z.object({ dryRun: z.boolean(), renames: z.array(FileRenamePair), /** 将/已同步的 asset_location 数 */ assetLocations: NonNegInt, /** 将/已修订的 path_binding(目录改名命中绑定) */ bindings: z.array(z.object({ oldBindingId: Id, newBindingId: Id.nullable() })), recordId: Id.nullable(), }); export type FilesRenameResult = z.infer; export const FilesTransferMode = z.enum(["move", "copy"]); export type FilesTransferMode = z.infer; export const FilesMoveRequest = z.object({ idempotencyKey: IdempotencyKey, items: z.array(FileRef).min(1).max(500), /** 目标目录:writable 根下已存在目录 */ target: FileRef, mode: FilesTransferMode, dryRun: z.boolean().default(true), /** 执行时必填:dryRun 返回的 planId */ planId: Id.optional(), }); export type FilesMoveRequest = z.infer; /** `/files/copy` 与 `/files/move` 同形,mode 固定 copy */ export const FilesCopyRequest = FilesMoveRequest.omit({ mode: true }); export type FilesCopyRequest = z.infer; export const FilePlanPair = z.object({ source: FileRef, target: FileRef, size: NonNegInt, /** 目标已存在:不覆盖,执行时跳过并列入 failed */ conflict: z.boolean(), /** 已登记资产(执行后同步 asset_location) */ registered: z.boolean(), /** 该项包含的文件数(目录递归;文件为 1)。W5-A2 */ files: NonNegInt.optional(), /** 与目标不在同一设备:move 逐文件 Copy→核验→删源(需 settings.organize.allowCrossVolumeMove)。W5-A2 */ crossDevice: z.boolean().optional(), }); export type FilePlanPair = z.infer; export const FileHnrBlock = z.object({ taskId: Id, path: z.string(), hnr: HnrState, status: DownloadStatus }); export const FileItemFailure = z.object({ path: z.string(), code: z.string(), message: z.string() }); export type FileItemFailure = z.infer; export const FilesTransferResult = z.object({ dryRun: z.boolean(), planId: Id, planExpiresAt: Timestamp, mode: FilesTransferMode, pairs: z.array(FilePlanPair), totalBytes: NonNegInt, /** 目标卷可用空间;未知为 null */ freeBytes: NonNegInt.nullable(), spaceOk: z.boolean(), /** move 模式下命中纳管做种/未完成任务 */ hnrBlocks: z.array(FileHnrBlock), /** 源遍历超过上限,只含前 N 项 */ truncated: z.boolean(), /** 执行结果(dryRun=false) */ executed: z .object({ /** 全部文件已完成的项(源相对路径);pending=true 时为空 */ succeeded: z.array(z.string()), failed: z.array(FileItemFailure), recordId: Id.nullable(), /** W5-A2:搬运走 planImport → verifyAndImport 任务(逐文件可恢复);任务 id 供任务页追踪 */ taskId: Id.nullable().optional(), /** 整理记录状态(等待窗口内的最新值) */ status: OrganizationStatus.optional(), /** 等待窗口(FILES_TRANSFER_WAIT_SEC)内未完成:后台继续执行,按 recordId 查整理记录或等 SSE files.changed */ pending: z.boolean().optional(), }) .nullable(), /** W5-A2:预览提示(跨卷 Move 未开启、目标已存在的文件等) */ warnings: z.array(z.string()).optional(), }); export type FilesTransferResult = z.infer; // 整理:按 mediaEntityId 分组,每组走 planImport(与 /organization-records 同实现) export const FilesOrganizeItem = FileRef.extend({ mediaEntityId: Id, contentUnitId: Id.optional(), season: z.number().int().min(0).max(9999).optional(), episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(), slotId: Id.optional(), /** W5-A2:逐项操作方式(缺省取请求的 action);不同操作分到不同组 */ operation: FilesTransferMode.optional(), /** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */ targetDir: FileRef.optional(), }); export type FilesOrganizeItem = z.infer; export const FilesOrganizeRequest = z.object({ idempotencyKey: IdempotencyKey, items: z.array(FilesOrganizeItem).min(1).max(500), action: FilesTransferMode, targetLibraryRootId: Id.optional(), dryRun: z.boolean().default(true), /** 执行时必填:dryRun 返回的全部组 planId */ planIds: z.array(Id).max(200).optional(), }); export type FilesOrganizeRequest = z.infer; export const FilesOrganizeGroup = z.object({ /** 分组键:mediaEntityId + contentUnitId */ mediaEntityId: Id, contentUnitId: Id.nullable(), planId: Id.nullable(), /** 冻结计划的前后路径(绝对路径,来自 organize 计划) */ files: z.array(z.object({ source: z.string(), target: z.string().nullable(), kind: z.string(), size: NonNegInt })), truncated: z.boolean(), recordId: Id.nullable(), taskId: Id.nullable(), /** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 Slot/单元);能应用的覆写已反映在 files 里 */ warnings: z.array(z.string()), /** W5-A2:该组的操作方式(逐项 operation 覆写后) */ action: FilesTransferMode.optional(), /** W5-A2:该组的目标目录覆写(本机绝对路径) */ targetDir: z.string().nullable().optional(), /** W5-A2:该组核验通过后推进的 Slot */ slotId: Id.nullable().optional(), /** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */ error: z.object({ code: z.string(), message: z.string() }).nullable(), }); export type FilesOrganizeGroup = z.infer; export const FilesOrganizeResult = z.object({ dryRun: z.boolean(), groups: z.array(FilesOrganizeGroup), /** 执行成功的整理记录 id,前端跳 /console/organize?ids= */ recordIds: z.array(Id), }); export type FilesOrganizeResult = z.infer; // 删除 / 回收站 export const FilesDeleteRequest = z.object({ idempotencyKey: IdempotencyKey, items: z.array(FileRef).min(1).max(500), /** permanent 需设置 files.allowPermanentDelete 且仅 admin 会话 */ mode: z.enum(["trash", "permanent"]).default("trash"), dryRun: z.boolean().default(true), /** 已登记资产:同时退役(location→removed,asset→retired) */ retireAssets: z.boolean().default(false), /** 绑定目录:同时解除绑定(binding→superseded) */ unbind: z.boolean().default(false), /** 永久删除二次确认:必须等于每项文件名(单项时) */ confirmName: z.string().max(255).optional(), /** dryRun 返回的 planId;API Key 执行时必填(一次性审批绑定该计划),会话可省略 */ planId: Id.optional(), }); export type FilesDeleteRequest = z.infer; export const FileDeleteCheck = z.object({ root: FileRootId, path: z.string(), isDir: z.boolean(), size: NonNegInt, /** 阻断原因(null = 可删);needs_retire / needs_unbind 可通过勾选解除 */ blocked: z .enum(["download_active", "is_root", "needs_retire", "needs_unbind", "root_unavailable", "not_found", "escape"]) .nullable(), downloads: z.array(z.object({ taskId: Id, status: DownloadStatus, hnr: HnrState })), assets: z.array(z.object({ assetId: Id, locationId: Id, mediaEntityId: Id, relativePath: z.string() })), bindings: z.array(FileBindingRef), /** nlink>1:只删除这一处链接 */ sharedInode: z.boolean(), }); export type FileDeleteCheck = z.infer; /** trash_item 表行(db-sync 对照用;API 用下方 TrashItem 投影) */ export const TrashItemRow = z.object({ id: Id, rootId: z.string(), libraryRootId: Id.nullable(), rootHostPath: z.string(), originalPath: z.string(), trashPath: z.string(), isDir: z.boolean(), sizeBytes: NonNegInt, actor: z.string().nullable(), deletedAt: Timestamp, expiresAt: Timestamp, assetSnapshot: JsonObject.nullable(), createdAt: Timestamp, updatedAt: Timestamp, }); export type TrashItemRow = z.infer; export const TrashItem = z.object({ id: Id, rootId: FileRootId, originalPath: z.string(), /** 回收站内的本机路径 */ trashPath: z.string(), isDir: z.boolean(), size: NonNegInt, actor: z.string().nullable(), deletedAt: Timestamp, expiresAt: Timestamp, /** 删除时关联资产快照(还原不自动恢复资产状态) */ assetSnapshot: JsonObject.nullable(), }); export type TrashItem = z.infer; export const FilesDeleteResult = z.object({ dryRun: z.boolean(), /** dryRun 生成的删除计划(执行时回传;审批绑定) */ planId: Id.nullable(), planExpiresAt: Timestamp.nullable(), mode: z.enum(["trash", "permanent"]), checks: z.array(FileDeleteCheck), /** 执行后:进入回收站的项(permanent 为空) */ trashed: z.array(TrashItem), deleted: z.array(z.string()), failed: z.array(FileItemFailure), recordId: Id.nullable(), }); export type FilesDeleteResult = z.infer; export const FilesTrashQuery = z.object({ cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), root: FileRootId.optional(), }); export type FilesTrashQuery = z.infer; export const FilesTrashList = z.object({ items: z.array(TrashItem), nextCursor: z.string().nullable() }); export type FilesTrashList = z.infer; export const FilesTrashRestoreRequest = z.object({ idempotencyKey: IdempotencyKey, ids: z.array(Id).min(1).max(200), /** 还原到其他目录(可选;默认原位,原位被占 → 该项失败 conflict) */ target: FileRef.optional(), }); export type FilesTrashRestoreRequest = z.infer; export const FilesTrashRestoreResult = z.object({ restored: z.array(z.object({ id: Id, root: FileRootId, path: z.string() })), failed: z.array(z.object({ id: Id, code: z.string(), message: z.string() })), /** W5-A2:还原写的整理记录(action=restore);无成功项为 null */ recordId: Id.nullable().optional(), }); export type FilesTrashRestoreResult = z.infer; export const FilesTrashPurgeRequest = z.object({ idempotencyKey: IdempotencyKey, /** 指定项立即清除;缺省 = 只清过期项 */ ids: z.array(Id).min(1).max(500).optional(), }); export type FilesTrashPurgeRequest = z.infer; export const FilesTrashPurgeResult = z.object({ purged: z.array(Id), failed: z.array(z.object({ id: Id, code: z.string(), message: z.string() })), }); export type FilesTrashPurgeResult = z.infer; // 识别(只读,不落库) export const FilesIdentifyRequest = z.object({ items: z.array(FileRef).min(1).max(200), hints: z .object({ mediaKind: MediaKind.optional(), year: z.number().int().optional(), tmdbId: z.number().int().optional() }) .optional(), }); export type FilesIdentifyRequest = z.infer; export const FileParsed = z.object({ title: z.string().nullable(), year: z.number().int().nullable(), season: z.number().int().nullable(), episodes: z.array(z.number().int()), resolution: z.string().nullable(), source: z.string().nullable(), releaseGroup: z.string().nullable(), mediaKind: MediaKind.nullable(), }); export type FileParsed = z.infer; export const FileIdentifyCandidate = z.object({ mediaEntityId: Id.nullable(), provider: z.string(), externalId: z.string().nullable(), title: z.string(), year: z.number().int().nullable(), kind: MediaKind, /** high / medium / low */ level: z.enum(["high", "medium", "low"]), score: z.number(), reasons: z.array(z.string()), }); export type FileIdentifyCandidate = z.infer; export const FileIdentifyItem = z.object({ root: FileRootId, path: z.string(), parsed: FileParsed, candidates: z.array(FileIdentifyCandidate).max(5), suggestion: z.object({ mediaEntityId: Id.nullable(), contentUnitId: Id.nullable(), contentUnit: z.string().nullable(), }), existing: z.object({ assetId: Id.nullable() }), /** TMDB 不可用等降级提示 */ warnings: z.array(z.string()), }); export type FileIdentifyItem = z.infer; export const FilesIdentifyResult = z.object({ items: z.array(FileIdentifyItem), /** 目录展开后视频数超过上限 */ truncated: z.boolean(), }); export type FilesIdentifyResult = z.infer; // 刮削(仅已登记作品) export const FilesScrapeRequest = z.object({ idempotencyKey: IdempotencyKey, mediaEntityId: Id, writeNfo: z.boolean().default(false), images: z.boolean().default(false), }); export type FilesScrapeRequest = z.infer; export const FilesScrapeResult = z.object({ mediaEntityId: Id, /** refreshMetadata 引擎 run id(异步;字段级刷新,用户锁优先,不动路径) */ taskId: z.string(), /** 当前被用户锁定、刷新不会改动的字段 */ lockedFields: z.array(z.string()), /** NFO/图片投影:W5 实现前请求时为 not_implemented */ nfo: z.enum(["written", "skipped", "not_requested", "not_implemented"]), images: z.enum(["written", "skipped", "not_requested", "not_implemented"]), }); export type FilesScrapeResult = z.infer; // 探测 export const FilesProbeRequest = z.object({ items: z.array(FileRef).min(1).max(20) }); export type FilesProbeRequest = z.infer; export const FileProbeItem = z.object({ root: FileRootId, path: z.string(), status: z.enum(["ok", "unknown"]), reason: z.string().nullable(), summary: FileMediaSummary.nullable(), container: z.string().nullable(), subtitleTracks: NonNegInt, /** 已登记资产写回 media_asset.media_info */ savedToAsset: z.boolean(), cached: z.boolean(), }); export type FileProbeItem = z.infer; export const FilesProbeResult = z.object({ items: z.array(FileProbeItem) }); export type FilesProbeResult = z.infer; // 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话; // 仅 nfo/字幕/图片且 ≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB);inline=true 仅对图片生效(预览)。 export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) }); export type FilesDownloadQuery = z.infer; /** OpenAPI 描述用:实际响应为二进制流 */ export const FilesDownloadResponse = z.string().describe("文件内容(application/octet-stream 等)"); // 整理记录回查(W5-A2,只读):整理页 `/console/organize?ids=` 预填;planIds 为 /files/move|copy|organize 的 planId const csvIds = z .string() .max(8000) .transform((s, ctx) => { const parts = s .split(",") .map((x) => x.trim()) .filter(Boolean); for (const p of parts) if (!Id.safeParse(p).success) { ctx.addIssue({ code: "custom", message: `非法 id: ${p}` }); return z.NEVER; } return parts.slice(0, 200); }); export const FilesRecordsQuery = z.object({ /** 逗号分隔的整理记录 id */ ids: csvIds.optional(), /** 逗号分隔的 planId(执行后映射到其整理记录) */ planIds: csvIds.optional(), includeFiles: boolQuery.default(false), }); export type FilesRecordsQuery = z.infer; export const FilesRecordsResult = z.object({ items: z.array(PublicOrganizationRecord), /** planId → recordId(未执行或已过期的 planId 不在其中) */ planRecords: z.record(z.string(), z.string()), }); export type FilesRecordsResult = z.infer; /** SSE `files.changed` 负载 */ export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) }); export type FilesChangedEvent = z.infer; /** 文件管理专有错误码(放在 ApiError.message 前缀里,如 "root_unavailable: …") */ export const FilesErrorReason = z.enum([ "root_unavailable", "path_escape", "not_found", "exists", "not_writable", "plan_stale", "download_active", "needs_retire", "needs_unbind", "permanent_disabled", "approval_required", ]); export type FilesErrorReason = z.infer;