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(), /** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */ pendingCount: NonNegInt.nullable(), /** G6:递归超过扫描上限时为 true,pendingCount 是下限 */ pendingPartial: z.boolean(), }); 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(), /** G7:待识别视频的轻量解析(文件名 + 父/祖父目录,纯字符串,不查库),如 `The Last of Us · S01E01 · 1080p`;其它行为 null */ parsedHint: z.string().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 同实现) /** W9-H(G1):整理项的外部候选(识别结果里只有 TMDB 候选、本地还没有作品时);与 mediaEntityId 二选一 */ export const FilesExternalCandidate = z.object({ provider: z.literal("tmdb"), externalId: z.string().regex(/^\d{1,12}$/, { message: "TMDB id 必须是数字" }), /** TMDB 条目类型;识别候选的 kind=movie → movie,其余 → tv */ kind: z.enum(["movie", "tv"]), }); export type FilesExternalCandidate = z.infer; /** W9-H(G3):目标已存在策略。skip = 跳过该文件;conflict = 交给待处理(默认,整组不执行); * replace_with_verify = 替换(新文件核验通过后旧文件进回收站) */ export const FilesOnExisting = z.enum(["skip", "conflict", "replace_with_verify"]); export type FilesOnExisting = z.infer; /** W9-H(G4):命名方式。template = 按命名模板(默认);keep = 保留原文件名(目录仍按模板/绑定) */ export const FilesNamingMode = z.enum(["template", "keep"]); export type FilesNamingMode = z.infer; /** 命名模板来源:entity 作品覆盖 / root 库根 / global 全局 / override 本次覆写(如保留原文件名) */ export const NamingTemplateSource = z.enum(["entity", "root", "global", "override"]); export type NamingTemplateSource = z.infer; export const FilesOrganizeItem = FileRef.extend({ /** 本地作品;与 external 二选一(两者都给或都不给 → 400) */ mediaEntityId: Id.optional(), /** W9-H(G1):TMDB 候选;预览时就地建本地作品 + 外部映射(幂等,同 TMDB id 不重复建),组里标 entity.created */ external: FilesExternalCandidate.optional(), 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(), /** W9-H(G2):主视频最小体积(字节),低于的文件排除并标 below_min_size;缺省取设置(未设置时 50 MiB) */ minSizeBytes: NonNegInt.optional(), /** W9-H(G3):目标已存在策略,缺省 conflict(交给待处理) */ onExisting: FilesOnExisting.optional(), /** W9-H(G4):命名方式,缺省 template */ naming: FilesNamingMode.optional(), /** W9-H(G5):执行成功后对每个作品排一次元数据刷新(字段级,锁定字段不变) */ refreshMetadata: z.boolean().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, /** W9-H:计划动作 transfer / replace / skip / hold(hold = 被冲突拦下) */ action: z.enum(["transfer", "replace", "skip", "hold"]).optional(), /** W9-H(G3):目标已存在时的处理:same 同一文件已在库 / skip / replace / conflict;目标不存在为 null */ existing: z.enum(["same", "skip", "replace", "conflict"]).nullable().optional(), /** skip 的原因(already_in_library / target_exists / main_not_transferred) */ skipReason: z.string().nullable().optional(), }), ), /** W9-H(G2):被排除的文件(below_min_size 低于最小体积 / sample / no_episode 等),不参与整理 */ excluded: z .array(z.object({ source: z.string(), kind: z.string(), size: NonNegInt.nullable(), reason: z.string() })) .optional(), 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(), /** W9-H(G1):该组作品来自 TMDB 候选;created = 本次新建(否则复用已有映射的作品) */ entity: z .object({ provider: z.literal("tmdb"), externalId: z.string(), created: z.boolean(), title: z.string() }) .nullable() .optional(), /** W9-H(G3):本组生效的目标已存在策略 */ onExisting: FilesOnExisting.optional(), /** W9-H(G2):本组生效的最小体积 */ minSizeBytes: NonNegInt.optional(), /** W9-H(G4):命名方式与模板来源(同时冻结进 organization_record.plan.naming) */ naming: z .object({ mode: FilesNamingMode, templateSource: NamingTemplateSource, templateVersion: z.number().int().nullable(), }) .optional(), /** W9-H(G4):目标目录分段(相对库根):role = root 作品目录 / season 季目录 / dir 其它;bound = 沿用已有路径绑定 */ targetSegments: z .array(z.object({ text: z.string(), role: z.enum(["root", "season", "dir"]), bound: z.boolean() })) .optional(), /** W9-H(G5):执行后排的元数据刷新 run id(refreshMetadata=true 且该组已提交) */ refreshTaskId: z.string().nullable().optional(), }); 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), /** W9-H(G2):低于该体积(字节)的视频不识别,标 skipped=below_min_size;缺省取设置(未设置则不过滤) */ minSizeBytes: NonNegInt.optional(), 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()), /** G8:海报缩略图 URL(TMDB w154);没有海报为 null */ posterUrl: z.string().url().nullable(), }); 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()), /** W9-H(G2):文件大小 */ size: NonNegInt.optional(), /** W9-H(G2):below_min_size = 低于最小体积,未识别(candidates 为空) */ skipped: z.literal("below_min_size").nullable().optional(), /** W9-H(G5):该源文件已有的整理记录(向导标"有 n 条整理记录") */ records: z .object({ count: NonNegInt, lastRecordId: Id.nullable(), lastStatus: OrganizationStatus.nullable() }) .optional(), }); export type FileIdentifyItem = z.infer; export const FilesIdentifyResult = z.object({ items: z.array(FileIdentifyItem), /** 目录展开后视频数超过上限 */ truncated: z.boolean(), /** W9-H(G2):因低于最小体积被跳过的项数 */ excludedBySize: NonNegInt.optional(), /** * S-7:远端(TMDB)查询任务 id(= 引擎 runId)。缺省请求只做本地召回并入队远端任务,查完发 SSE * `identify.completed`(同 taskId),前端按 root + path 替换条目;null = 无需远端或 `?wait=true` 已同步查完 */ remoteTaskId: z.string().nullable().optional(), /** S-7:待远端查询的去重标题数(0 = 无) */ remotePending: NonNegInt.optional(), }); export type FilesIdentifyResult = z.infer; /** SSE `identify.completed`:远端识别任务结束(失败也发,status=failed、items 为空) */ export const IdentifyCompletedPayload = z.object({ taskId: z.string(), status: z.enum(["succeeded", "failed"]), /** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */ items: z.array(FileIdentifyItem), truncated: z.boolean(), excludedBySize: NonNegInt, error: z.string().nullable(), }); export type IdentifyCompletedPayload = 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 等)"); // 预览(FM-2):GET /files/preview 返回结构化预览,仅管理员会话,路径守卫同下载。 // 文本/字幕最多读前 1 MiB 并探测编码(BOM / UTF-8 / UTF-16 / GB18030 / Big5);字幕解析前 200 条; // .torrent ≤ 4 MiB 解析文件列表、tracker 与 infohash;图片只回尺寸类型信息,内容走 /files/download?inline=true。 export const FilesPreviewQuery = z.object({ root: FileRootId, path: FileRelPath }); export type FilesPreviewQuery = z.infer; export const SubtitleCue = z.object({ /** 毫秒 */ start: NonNegInt, end: NonNegInt, /** 去掉样式标签后的纯文本;图形字幕(sup)为空串 */ text: z.string(), }); export type SubtitleCue = z.infer; const PreviewBase = z.object({ size: NonNegInt }); export const FilePreview = z.discriminatedUnion("type", [ PreviewBase.extend({ type: z.literal("text"), /** 小写扩展名(nfo/txt/log/md/json/xml/yaml…) */ format: z.string(), /** 探测出的编码名(utf-8 / utf-16le / utf-16be / gb18030 / big5 / windows-1252) */ encoding: z.string(), text: z.string(), truncated: z.boolean(), }), PreviewBase.extend({ type: z.literal("subtitle"), format: z.enum(["srt", "vtt", "ass", "ssa", "sup"]), encoding: z.string().nullable(), cues: z.array(SubtitleCue), /** 读到的范围内共解析出多少条(可能大于 cues.length) */ parsed: NonNegInt, /** 只读了文件前一部分 */ truncated: z.boolean(), /** ASS/SSA 的 [Script Info] Title */ title: z.string().nullable(), }), PreviewBase.extend({ type: z.literal("torrent"), name: z.string(), hash: z.string(), infohashV1: z.string().nullable(), infohashV2: z.string().nullable(), private: z.boolean(), totalSize: NonNegInt, fileCount: NonNegInt, /** 最多 500 个 */ files: z.array(z.object({ path: z.string(), length: NonNegInt })), trackers: z.array(z.string()), comment: z.string().nullable(), createdBy: z.string().nullable(), createdAt: Timestamp.nullable(), pieceLength: NonNegInt.nullable(), }), PreviewBase.extend({ type: z.literal("image"), mime: z.string(), width: NonNegInt.nullable(), height: NonNegInt.nullable(), }), ]); export type FilePreview = z.infer; // 整理记录回查(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; // ── W9-H 文件管理 v2 ── /** G1:按 TMDB 候选就地建本地作品(幂等:同 TMDB id 已有映射则复用);剧集同时建季/集单元 */ export const FilesEntityFromCandidateRequest = FilesExternalCandidate; export type FilesEntityFromCandidateRequest = z.infer; export const FilesEntityFromCandidateResult = z.object({ mediaEntityId: Id, created: z.boolean(), title: z.string(), year: z.number().int().nullable(), kind: MediaKind, /** 本次确保存在的季/集单元数 */ contentUnits: NonNegInt, }); export type FilesEntityFromCandidateResult = z.infer; /** G5:批量刷新元数据(刮削):按作品去重;items 里的文件/目录归到其已登记资产的作品 */ export const FilesScrapeBatchRequest = z.object({ idempotencyKey: IdempotencyKey, items: z.array(FileRef).max(500).optional(), mediaEntityIds: z.array(Id).max(200).optional(), }); export type FilesScrapeBatchRequest = z.infer; export const FilesScrapeBatchResult = z.object({ submitted: z.array( z.object({ mediaEntityId: Id, title: z.string(), taskId: z.string(), lockedFields: z.array(z.string()) }), ), /** 未提交:not_registered(作品没有已登记资产)/ not_found / no_entity(文件不属于任何已登记作品) */ skipped: z.array(z.object({ ref: z.string(), reason: z.string() })), }); export type FilesScrapeBatchResult = z.infer; /** G8:目录摘要(上一级 / 树折叠):父路径 + 子目录(是否还有子目录)+ 本目录视频/已登记计数 */ export const FilesTreeQuery = z.object({ root: FileRootId, path: FileRelPath }); export type FilesTreeQuery = z.infer; export const FilesTreeResult = z.object({ root: FileRootId, path: z.string(), name: z.string(), /** 上一级相对路径;已在根时为 null */ parent: z.string().nullable(), dirs: z.array(z.object({ name: z.string(), path: z.string(), hasChildren: z.boolean() })), /** 子目录超过上限(FILES_LIST_MAX_ENTRIES)只列了前 N 个 */ truncated: z.boolean(), summary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }), }); export type FilesTreeResult = z.infer; /** G5:整理记录 → 源 FileRef(整理记录"重新整理"打开向导用) */ export const FilesRecordSourcesQuery = z.object({ recordId: Id }); export type FilesRecordSourcesQuery = z.infer; export const FilesRecordSourcesResult = z.object({ recordId: Id, mediaEntityId: Id.nullable(), action: z.string(), status: OrganizationStatus, /** 仍存在且落在登记目录里的源(主视频;目录源给目录本身) */ items: z.array(FileRef), /** 不在任何登记目录或已不存在的源路径(本机路径) */ unresolved: z.array(z.string()), }); export type FilesRecordSourcesResult = z.infer; /** G9:按命名模板给选中文件生成推荐名 */ export const FilesSuggestNameRequest = z.object({ root: FileRootId, path: FileRelPath, /** 指定作品;缺省用已登记资产的作品,再缺省用识别的高置信候选 */ mediaEntityId: Id.optional(), }); export type FilesSuggestNameRequest = z.infer; export const FilesSuggestNameResult = z.object({ /** 推荐的文件名(文件)或目录名(目录);不可信时 null */ name: z.string().nullable(), /** 相对库根的完整推荐路径 */ relativePath: z.string().nullable(), /** name 为 null 的原因(identity_uncertain / naming_failed 等) */ reason: z.string().nullable(), mediaEntityId: Id.nullable(), templateSource: NamingTemplateSource.nullable(), segments: z.array(z.object({ text: z.string(), role: z.string() })), }); export type FilesSuggestNameResult = 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", /** W9-H:依赖尚未就绪(如命名引擎),HTTP 501 */ "not_implemented", ]); export type FilesErrorReason = z.infer;