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:
rootandClaude Opus 5.5 committed 2026-09-27 22:37:04 +08:00
1 parent 3665daaf07
commit b86f75c011
2 files changed
+702

No files matched your search

+126
View File
@@ -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";
+576
View File
@@ -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>;