- /files/move|copy go through planImport's new `transfer` variant (keep names, directory expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then remove empty source dirs; shared plan store + plan_stale; asset_location follows per file, path_binding revised when a whole directory moved (finalizeTransfer); request waits up to FILES_TRANSFER_WAIT_SEC, else executed.pending - /files/organize per-item overrides take effect: season/episodes (domain planOrganize overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied after verify); warnings only for inapplicable overrides - delete/trash/restore write organization_record (action trash|delete|restore, file-level results; migration 0026) - GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited) - GET /files/records?ids|planIds for the organize page - fs race errors map to 503/404/403/400 instead of 500 - runbook updated Co-Authored-By: Claude Opus 5.5 <[email protected]>
670 lines
25 KiB
TypeScript
670 lines
25 KiB
TypeScript
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:<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(),
|
|
/** 该项包含的文件数(目录递归;文件为 1)。W5-A2 */
|
|
files: NonNegInt.optional(),
|
|
/** 与目标不在同一设备:move 逐文件 Copy→核验→删源(需 settings.organize.allowCrossVolumeMove)。W5-A2 */
|
|
crossDevice: z.boolean().optional(),
|
|
});
|
|
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({
|
|
/** 全部文件已完成的项(源相对路径);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<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(),
|
|
/** W5-A2:逐项操作方式(缺省取请求的 action);不同操作分到不同组 */
|
|
operation: FilesTransferMode.optional(),
|
|
/** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */
|
|
targetDir: FileRef.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(),
|
|
/** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 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<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(),
|
|
/** dryRun 返回的 planId;API Key 执行时必填(一次性审批绑定该计划),会话可省略 */
|
|
planId: Id.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>;
|
|
|
|
/** 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<typeof TrashItemRow>;
|
|
|
|
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(),
|
|
/** 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<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() })),
|
|
/** W5-A2:还原写的整理记录(action=restore);无成功项为 null */
|
|
recordId: Id.nullable().optional(),
|
|
});
|
|
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,
|
|
/** 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<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>;
|
|
|
|
// 下载(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<typeof FilesDownloadQuery>;
|
|
/** 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<typeof FilesRecordsQuery>;
|
|
export const FilesRecordsResult = z.object({
|
|
items: z.array(PublicOrganizationRecord),
|
|
/** planId → recordId(未执行或已过期的 planId 不在其中) */
|
|
planRecords: z.record(z.string(), z.string()),
|
|
});
|
|
export type FilesRecordsResult = z.infer<typeof FilesRecordsResult>;
|
|
|
|
/** 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>;
|