feat(contracts): file manager API schemas and /files endpoint registry
Adds packages/contracts/src/files.ts (roots/list/stat, mkdir/rename, move/copy/organize two-phase dryRun->planId, delete with reference checks and trash, identify/scrape/probe) and registers Api.files in api.ts. Implementation lands in a follow-up commit (W5-A). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3665daaf07
commit
b86f75c011
2 files changed
+702
No files matched your search
@@ -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 extends EndpointDef> = E["body"] extends z.ZodType ? z.input<E["body"]> : never;
|
||||
export type EndpointResponse<E extends EndpointDef> = z.infer<E["response"]>;
|
||||
|
||||
export * from "./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:<library_root.id>` 或 `dl:<downloader_instance.id>:<路径映射序号>` */
|
||||
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<typeof FileRootId>;
|
||||
|
||||
/** 根内相对路径:不以 / 开头,不含 `..` 段与 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<typeof FileRelPath>;
|
||||
|
||||
/** 单个文件名段:不含 /、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<typeof FileRef>;
|
||||
|
||||
export const IdempotencyKey = z.string().min(8).max(200);
|
||||
|
||||
// ── 根 ──
|
||||
|
||||
export const FileRootKind = z.enum(["library", "download"]);
|
||||
export type FileRootKind = z.infer<typeof FileRootKind>;
|
||||
|
||||
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<typeof FileRoot>;
|
||||
export const FileRootList = z.object({ items: z.array(FileRoot) });
|
||||
export type FileRootList = z.infer<typeof FileRootList>;
|
||||
|
||||
// ── 列表 ──
|
||||
|
||||
export const FileEntryType = z.enum(["file", "dir", "symlink", "other"]);
|
||||
export type FileEntryType = z.infer<typeof FileEntryType>;
|
||||
export const FileKindTag = z.enum(["video", "subtitle", "audio", "nfo", "image", "other"]);
|
||||
export type FileKindTag = z.infer<typeof FileKindTag>;
|
||||
/** 行主状态,按优先级取一个:temp > downloading > registered > seeding > unidentified > plain */
|
||||
export const FileStatus = z.enum(["temp", "downloading", "registered", "seeding", "unidentified", "plain"]);
|
||||
export type FileStatus = z.infer<typeof FileStatus>;
|
||||
export const HnrState = z.enum(["satisfied", "unsatisfied", "unknown"]);
|
||||
export type HnrState = z.infer<typeof HnrState>;
|
||||
|
||||
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<typeof FileAssetLink>;
|
||||
|
||||
export const FileDownloadLink = z.object({
|
||||
taskId: Id,
|
||||
status: DownloadStatus,
|
||||
/** 0..1 */
|
||||
progress: z.number().nullable(),
|
||||
hnr: HnrState,
|
||||
});
|
||||
export type FileDownloadLink = z.infer<typeof FileDownloadLink>;
|
||||
|
||||
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<typeof FileMediaSummary>;
|
||||
|
||||
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<typeof FileEntry>;
|
||||
|
||||
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<typeof FileFilter>;
|
||||
|
||||
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<typeof FilesListQuery>;
|
||||
|
||||
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<typeof FilesListResponse>;
|
||||
|
||||
export const FileStatQuery = z.object({ root: FileRootId, path: FileRelPath });
|
||||
export type FileStatQuery = z.infer<typeof FileStatQuery>;
|
||||
|
||||
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<typeof FileBindingRef>;
|
||||
|
||||
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<typeof FileStat>;
|
||||
|
||||
// ── 写操作 ──
|
||||
|
||||
export const FilesMkdirRequest = z.object({
|
||||
idempotencyKey: IdempotencyKey,
|
||||
root: FileRootId,
|
||||
/** 父目录(根内相对路径) */
|
||||
path: FileRelPath,
|
||||
name: FileName,
|
||||
});
|
||||
export type FilesMkdirRequest = z.infer<typeof FilesMkdirRequest>;
|
||||
export const FilesMkdirResult = z.object({ root: FileRootId, path: z.string() });
|
||||
export type FilesMkdirResult = z.infer<typeof FilesMkdirResult>;
|
||||
|
||||
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<typeof FilesRenameRequest>;
|
||||
|
||||
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<typeof FilesRenameResult>;
|
||||
|
||||
export const FilesTransferMode = z.enum(["move", "copy"]);
|
||||
export type FilesTransferMode = z.infer<typeof FilesTransferMode>;
|
||||
|
||||
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<typeof FilesMoveRequest>;
|
||||
/** `/files/copy` 与 `/files/move` 同形,mode 固定 copy */
|
||||
export const FilesCopyRequest = FilesMoveRequest.omit({ mode: true });
|
||||
export type FilesCopyRequest = z.infer<typeof FilesCopyRequest>;
|
||||
|
||||
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<typeof FilePlanPair>;
|
||||
|
||||
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<typeof FileItemFailure>;
|
||||
|
||||
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<typeof FilesTransferResult>;
|
||||
|
||||
// 整理:按 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<typeof FilesOrganizeItem>;
|
||||
|
||||
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<typeof FilesOrganizeRequest>;
|
||||
|
||||
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<typeof FilesOrganizeGroup>;
|
||||
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<typeof FilesOrganizeResult>;
|
||||
|
||||
// 删除 / 回收站
|
||||
|
||||
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<typeof FilesDeleteRequest>;
|
||||
|
||||
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<typeof FileDeleteCheck>;
|
||||
|
||||
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<typeof TrashItem>;
|
||||
|
||||
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<typeof FilesDeleteResult>;
|
||||
|
||||
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<typeof FilesTrashQuery>;
|
||||
export const FilesTrashList = z.object({ items: z.array(TrashItem), nextCursor: z.string().nullable() });
|
||||
export type FilesTrashList = z.infer<typeof FilesTrashList>;
|
||||
|
||||
export const FilesTrashRestoreRequest = z.object({
|
||||
idempotencyKey: IdempotencyKey,
|
||||
ids: z.array(Id).min(1).max(200),
|
||||
/** 还原到其他目录(可选;默认原位,原位被占 → 该项失败 conflict) */
|
||||
target: FileRef.optional(),
|
||||
});
|
||||
export type FilesTrashRestoreRequest = z.infer<typeof FilesTrashRestoreRequest>;
|
||||
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<typeof FilesTrashRestoreResult>;
|
||||
|
||||
export const FilesTrashPurgeRequest = z.object({
|
||||
idempotencyKey: IdempotencyKey,
|
||||
/** 指定项立即清除;缺省 = 只清过期项 */
|
||||
ids: z.array(Id).min(1).max(500).optional(),
|
||||
});
|
||||
export type FilesTrashPurgeRequest = z.infer<typeof FilesTrashPurgeRequest>;
|
||||
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<typeof FilesTrashPurgeResult>;
|
||||
|
||||
// 识别(只读,不落库)
|
||||
|
||||
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<typeof FilesIdentifyRequest>;
|
||||
|
||||
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<typeof FileParsed>;
|
||||
|
||||
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<typeof FileIdentifyCandidate>;
|
||||
|
||||
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<typeof FileIdentifyItem>;
|
||||
export const FilesIdentifyResult = z.object({
|
||||
items: z.array(FileIdentifyItem),
|
||||
/** 目录展开后视频数超过上限 */
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type FilesIdentifyResult = z.infer<typeof FilesIdentifyResult>;
|
||||
|
||||
// 刮削(仅已登记作品)
|
||||
|
||||
export const FilesScrapeRequest = z.object({
|
||||
idempotencyKey: IdempotencyKey,
|
||||
mediaEntityId: Id,
|
||||
writeNfo: z.boolean().default(false),
|
||||
images: z.boolean().default(false),
|
||||
});
|
||||
export type FilesScrapeRequest = z.infer<typeof FilesScrapeRequest>;
|
||||
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<typeof FilesScrapeResult>;
|
||||
|
||||
// 探测
|
||||
|
||||
export const FilesProbeRequest = z.object({ items: z.array(FileRef).min(1).max(20) });
|
||||
export type FilesProbeRequest = z.infer<typeof FilesProbeRequest>;
|
||||
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<typeof FileProbeItem>;
|
||||
export const FilesProbeResult = z.object({ items: z.array(FileProbeItem) });
|
||||
export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
|
||||
|
||||
/** SSE `files.changed` 负载 */
|
||||
export const FilesChangedEvent = z.object({ rootId: FileRootId, dirs: z.array(z.string()) });
|
||||
export type FilesChangedEvent = z.infer<typeof FilesChangedEvent>;
|
||||
|
||||
/** 文件管理专有错误码(放在 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<typeof FilesErrorReason>;
|
||||
Reference in new issue
Block a user