diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 574ca768..9d68b95f 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -7,6 +7,7 @@ import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } fro import * as content from "./content.ts"; import * as events from "./events.ts"; import * as execution from "./execution.ts"; +import * as files from "./files.ts"; import * as identity from "./identity.ts"; import * as instances from "./instances.ts"; import * as ops from "./ops.ts"; @@ -1019,6 +1020,129 @@ export const Api = { }), }, + // ── 文件管理(docs/plan/06-file-manager-spec.md;只浏览已登记根,写操作两段式 dryRun → planId) ── + files: { + roots: ep({ + method: "get", + path: "/files/roots", + scope: "organize.read", + summary: "可浏览的根(库根 + 下载根),含实时健康与空间", + response: files.FileRootList, + }), + list: ep({ + method: "get", + path: "/files", + scope: "organize.read", + summary: "列目录(分页/排序/过滤/截断,行内业务状态);根离线 503", + query: files.FilesListQuery, + response: files.FilesListResponse, + }), + stat: ep({ + method: "get", + path: "/files/stat", + scope: "organize.read", + summary: "单项详情:realpath/inode/nlink/sidecars/路径绑定", + query: files.FileStatQuery, + response: files.FileStat, + }), + mkdir: ep({ + method: "post", + path: "/files/mkdir", + scope: "organize.execute", + summary: "新建目录(非递归,已存在 409,仅 writable 根)", + body: files.FilesMkdirRequest, + response: files.FilesMkdirResult, + }), + rename: ep({ + method: "post", + path: "/files/rename", + scope: "organize.execute", + summary: "同目录改名(不覆盖;同步 asset_location 与 path_binding 修订;做种中 409)", + body: files.FilesRenameRequest, + response: files.FilesRenameResult, + }), + move: ep({ + method: "post", + path: "/files/move", + scope: "organize.execute", + summary: "移动/复制到目录:dryRun 返回 planId,执行回传 planId(源变更 409 plan_stale)", + body: files.FilesMoveRequest, + response: files.FilesTransferResult, + }), + copy: ep({ + method: "post", + path: "/files/copy", + scope: "organize.execute", + summary: "复制到目录(同 /files/move mode=copy)", + body: files.FilesCopyRequest, + response: files.FilesTransferResult, + }), + organize: ep({ + method: "post", + path: "/files/organize", + scope: "organize.execute", + summary: "按作品分组整理入库(每组走 planImport,与 /organization-records 同实现)", + body: files.FilesOrganizeRequest, + response: files.FilesOrganizeResult, + }), + delete: ep({ + method: "post", + path: "/files/delete", + scope: "organize.execute", + summary: "删除(默认进回收站;引用检查:做种/资产/绑定;permanent 仅 admin 会话)", + body: files.FilesDeleteRequest, + response: files.FilesDeleteResult, + }), + trash: ep({ + method: "get", + path: "/files/trash", + scope: "organize.read", + summary: "回收站列表", + query: files.FilesTrashQuery, + response: files.FilesTrashList, + }), + trashRestore: ep({ + method: "post", + path: "/files/trash/restore", + scope: "organize.execute", + summary: "从回收站还原(原位被占的项失败 conflict)", + body: files.FilesTrashRestoreRequest, + response: files.FilesTrashRestoreResult, + }), + trashPurge: ep({ + method: "post", + path: "/files/trash/purge", + scope: "admin", + summary: "清空回收站(缺省只清过期项;服务端另要求 admin 控制台会话,API Key 403)", + body: files.FilesTrashPurgeRequest, + response: files.FilesTrashPurgeResult, + }), + identify: ep({ + method: "post", + path: "/files/identify", + scope: "organize.read", + summary: "识别(只读):解析 + 本地/TMDB 召回前 5 候选,不落库", + body: files.FilesIdentifyRequest, + response: files.FilesIdentifyResult, + }), + scrape: ep({ + method: "post", + path: "/files/scrape", + scope: "organize.execute", + summary: "刮削已登记作品(字段级刷新,遵守字段锁;NFO 投影 W5)", + body: files.FilesScrapeRequest, + response: files.FilesScrapeResult, + }), + probe: ep({ + method: "post", + path: "/files/probe", + scope: "organize.read", + summary: "ffprobe 摘要(≤20 项;已登记资产写回 media_info)", + body: files.FilesProbeRequest, + response: files.FilesProbeResult, + }), + }, + // ── 能力 ── capabilities: { list: ep({ @@ -1291,3 +1415,5 @@ export function listEndpoints(): EndpointDef[] { export type EndpointBody = E["body"] extends z.ZodType ? z.input : never; export type EndpointResponse = z.infer; + +export * from "./files.ts"; diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts new file mode 100644 index 00000000..14a3b558 --- /dev/null +++ b/packages/contracts/src/files.ts @@ -0,0 +1,576 @@ +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 { 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(), +}); +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({ + succeeded: z.array(z.string()), + failed: z.array(FileItemFailure), + recordId: Id.nullable(), + }) + .nullable(), +}); +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(), +}); +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(), + /** 该组失败原因(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(), +}); +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; + +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(), + 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() })), +}); +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, + changedFields: z.array(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; + +/** 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;