Files
UHD_Media_Tool/packages/contracts/src/files.ts
T
rootandClaude Opus 5.5 abc69886ea feat(contracts,server,web): files G6–G8 fields and identify S-7 contract
- G6: FileRoot.pendingCount/pendingPartial for download roots (same rule as the
  unidentified filter, recursive, cached 5 min, invalidated by files.changed)
- G7: FileEntry.parsedHint from parseReleasePath (file + parent dirs); the web
  subline uses it and no longer parses names itself
- G8: FileIdentifyCandidate.posterUrl (TMDB poster_path via adapter and
  catalog.lookup, local entities from metadata.posterPath); thumbnails in the
  identify panel
- S-7 contract: FilesIdentifyResult.remoteTaskId/remotePending,
  MediaServerInventory.partial, EventType identify.completed with
  IdentifyCompletedPayload; identify panel (useIdentifyLive) and organize wizard
  merge remote results by root + path, buffering events that beat the response

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 20:09:29 +08:00

868 lines
35 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(),
/** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */
pendingCount: NonNegInt.nullable(),
/** G6:递归超过扫描上限时为 true,pendingCount 是下限 */
pendingPartial: z.boolean(),
});
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(),
/** G7:待识别视频的轻量解析(文件名 + 父/祖父目录,纯字符串,不查库),如 `The Last of Us · S01E01 · 1080p`;其它行为 null */
parsedHint: z.string().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 同实现)
/** W9-H(G1):整理项的外部候选(识别结果里只有 TMDB 候选、本地还没有作品时);与 mediaEntityId 二选一 */
export const FilesExternalCandidate = z.object({
provider: z.literal("tmdb"),
externalId: z.string().regex(/^\d{1,12}$/, { message: "TMDB id 必须是数字" }),
/** TMDB 条目类型;识别候选的 kind=movie → movie,其余 → tv */
kind: z.enum(["movie", "tv"]),
});
export type FilesExternalCandidate = z.infer<typeof FilesExternalCandidate>;
/** W9-H(G3):目标已存在策略。skip = 跳过该文件;conflict = 交给待处理(默认,整组不执行);
* replace_with_verify = 替换(新文件核验通过后旧文件进回收站) */
export const FilesOnExisting = z.enum(["skip", "conflict", "replace_with_verify"]);
export type FilesOnExisting = z.infer<typeof FilesOnExisting>;
/** W9-H(G4):命名方式。template = 按命名模板(默认);keep = 保留原文件名(目录仍按模板/绑定) */
export const FilesNamingMode = z.enum(["template", "keep"]);
export type FilesNamingMode = z.infer<typeof FilesNamingMode>;
/** 命名模板来源:entity 作品覆盖 / root 库根 / global 全局 / override 本次覆写(如保留原文件名) */
export const NamingTemplateSource = z.enum(["entity", "root", "global", "override"]);
export type NamingTemplateSource = z.infer<typeof NamingTemplateSource>;
export const FilesOrganizeItem = FileRef.extend({
/** 本地作品;与 external 二选一(两者都给或都不给 → 400) */
mediaEntityId: Id.optional(),
/** W9-H(G1):TMDB 候选;预览时就地建本地作品 + 外部映射(幂等,同 TMDB id 不重复建),组里标 entity.created */
external: FilesExternalCandidate.optional(),
contentUnitId: Id.optional(),
season: z.number().int().min(0).max(9999).optional(),
episodes: z.array(z.number().int().min(0).max(99999)).max(50).optional(),
slotId: Id.optional(),
/** W5-A2:逐项操作方式(缺省取请求的 action);不同操作分到不同组 */
operation: FilesTransferMode.optional(),
/** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */
targetDir: FileRef.optional(),
});
export type FilesOrganizeItem = z.infer<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(),
/** W9-H(G2):主视频最小体积(字节),低于的文件排除并标 below_min_size;缺省取设置(未设置时 50 MiB) */
minSizeBytes: NonNegInt.optional(),
/** W9-H(G3):目标已存在策略,缺省 conflict(交给待处理) */
onExisting: FilesOnExisting.optional(),
/** W9-H(G4):命名方式,缺省 template */
naming: FilesNamingMode.optional(),
/** W9-H(G5):执行成功后对每个作品排一次元数据刷新(字段级,锁定字段不变) */
refreshMetadata: z.boolean().optional(),
});
export type FilesOrganizeRequest = z.infer<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,
/** W9-H:计划动作 transfer / replace / skip / hold(hold = 被冲突拦下) */
action: z.enum(["transfer", "replace", "skip", "hold"]).optional(),
/** W9-H(G3):目标已存在时的处理:same 同一文件已在库 / skip / replace / conflict;目标不存在为 null */
existing: z.enum(["same", "skip", "replace", "conflict"]).nullable().optional(),
/** skip 的原因(already_in_library / target_exists / main_not_transferred) */
skipReason: z.string().nullable().optional(),
}),
),
/** W9-H(G2):被排除的文件(below_min_size 低于最小体积 / sample / no_episode 等),不参与整理 */
excluded: z
.array(z.object({ source: z.string(), kind: z.string(), size: NonNegInt.nullable(), reason: z.string() }))
.optional(),
truncated: z.boolean(),
recordId: Id.nullable(),
taskId: Id.nullable(),
/** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 Slot/单元);能应用的覆写已反映在 files 里 */
warnings: z.array(z.string()),
/** W5-A2:该组的操作方式(逐项 operation 覆写后) */
action: FilesTransferMode.optional(),
/** W5-A2:该组的目标目录覆写(本机绝对路径) */
targetDir: z.string().nullable().optional(),
/** W5-A2:该组核验通过后推进的 Slot */
slotId: Id.nullable().optional(),
/** 该组失败原因(plan_stale / 授权 / H&R 等),其他组不受影响 */
error: z.object({ code: z.string(), message: z.string() }).nullable(),
/** W9-H(G1):该组作品来自 TMDB 候选;created = 本次新建(否则复用已有映射的作品) */
entity: z
.object({ provider: z.literal("tmdb"), externalId: z.string(), created: z.boolean(), title: z.string() })
.nullable()
.optional(),
/** W9-H(G3):本组生效的目标已存在策略 */
onExisting: FilesOnExisting.optional(),
/** W9-H(G2):本组生效的最小体积 */
minSizeBytes: NonNegInt.optional(),
/** W9-H(G4):命名方式与模板来源(同时冻结进 organization_record.plan.naming) */
naming: z
.object({
mode: FilesNamingMode,
templateSource: NamingTemplateSource,
templateVersion: z.number().int().nullable(),
})
.optional(),
/** W9-H(G4):目标目录分段(相对库根):role = root 作品目录 / season 季目录 / dir 其它;bound = 沿用已有路径绑定 */
targetSegments: z
.array(z.object({ text: z.string(), role: z.enum(["root", "season", "dir"]), bound: z.boolean() }))
.optional(),
/** W9-H(G5):执行后排的元数据刷新 run id(refreshMetadata=true 且该组已提交) */
refreshTaskId: z.string().nullable().optional(),
});
export type FilesOrganizeGroup = z.infer<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),
/** W9-H(G2):低于该体积(字节)的视频不识别,标 skipped=below_min_size;缺省取设置(未设置则不过滤) */
minSizeBytes: NonNegInt.optional(),
hints: z
.object({ mediaKind: MediaKind.optional(), year: z.number().int().optional(), tmdbId: z.number().int().optional() })
.optional(),
});
export type FilesIdentifyRequest = z.infer<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()),
/** G8:海报缩略图 URL(TMDB w154);没有海报为 null */
posterUrl: z.string().url().nullable(),
});
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()),
/** W9-H(G2):文件大小 */
size: NonNegInt.optional(),
/** W9-H(G2):below_min_size = 低于最小体积,未识别(candidates 为空) */
skipped: z.literal("below_min_size").nullable().optional(),
/** W9-H(G5):该源文件已有的整理记录(向导标"有 n 条整理记录") */
records: z
.object({ count: NonNegInt, lastRecordId: Id.nullable(), lastStatus: OrganizationStatus.nullable() })
.optional(),
});
export type FileIdentifyItem = z.infer<typeof FileIdentifyItem>;
export const FilesIdentifyResult = z.object({
items: z.array(FileIdentifyItem),
/** 目录展开后视频数超过上限 */
truncated: z.boolean(),
/** W9-H(G2):因低于最小体积被跳过的项数 */
excludedBySize: NonNegInt.optional(),
/**
* S-7:远端(TMDB)查询任务 id(= 引擎 runId)。缺省请求只做本地召回并入队远端任务,查完发 SSE
* `identify.completed`(同 taskId),前端按 root + path 替换条目;null = 无需远端或 `?wait=true` 已同步查完
*/
remoteTaskId: z.string().nullable().optional(),
/** S-7:待远端查询的去重标题数(0 = 无) */
remotePending: NonNegInt.optional(),
});
export type FilesIdentifyResult = z.infer<typeof FilesIdentifyResult>;
/** SSE `identify.completed`:远端识别任务结束(失败也发,status=failed、items 为空) */
export const IdentifyCompletedPayload = z.object({
taskId: z.string(),
status: z.enum(["succeeded", "failed"]),
/** 完整识别结果(本地 + 远端),按 root + path 替换 HTTP 响应里的同名项 */
items: z.array(FileIdentifyItem),
truncated: z.boolean(),
excludedBySize: NonNegInt,
error: z.string().nullable(),
});
export type IdentifyCompletedPayload = z.infer<typeof IdentifyCompletedPayload>;
// 刮削(仅已登记作品)
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>;
// ── W9-H 文件管理 v2 ──
/** G1:按 TMDB 候选就地建本地作品(幂等:同 TMDB id 已有映射则复用);剧集同时建季/集单元 */
export const FilesEntityFromCandidateRequest = FilesExternalCandidate;
export type FilesEntityFromCandidateRequest = z.infer<typeof FilesEntityFromCandidateRequest>;
export const FilesEntityFromCandidateResult = z.object({
mediaEntityId: Id,
created: z.boolean(),
title: z.string(),
year: z.number().int().nullable(),
kind: MediaKind,
/** 本次确保存在的季/集单元数 */
contentUnits: NonNegInt,
});
export type FilesEntityFromCandidateResult = z.infer<typeof FilesEntityFromCandidateResult>;
/** G5:批量刷新元数据(刮削):按作品去重;items 里的文件/目录归到其已登记资产的作品 */
export const FilesScrapeBatchRequest = z.object({
idempotencyKey: IdempotencyKey,
items: z.array(FileRef).max(500).optional(),
mediaEntityIds: z.array(Id).max(200).optional(),
});
export type FilesScrapeBatchRequest = z.infer<typeof FilesScrapeBatchRequest>;
export const FilesScrapeBatchResult = z.object({
submitted: z.array(
z.object({ mediaEntityId: Id, title: z.string(), taskId: z.string(), lockedFields: z.array(z.string()) }),
),
/** 未提交:not_registered(作品没有已登记资产)/ not_found / no_entity(文件不属于任何已登记作品) */
skipped: z.array(z.object({ ref: z.string(), reason: z.string() })),
});
export type FilesScrapeBatchResult = z.infer<typeof FilesScrapeBatchResult>;
/** G8:目录摘要(上一级 / 树折叠):父路径 + 子目录(是否还有子目录)+ 本目录视频/已登记计数 */
export const FilesTreeQuery = z.object({ root: FileRootId, path: FileRelPath });
export type FilesTreeQuery = z.infer<typeof FilesTreeQuery>;
export const FilesTreeResult = z.object({
root: FileRootId,
path: z.string(),
name: z.string(),
/** 上一级相对路径;已在根时为 null */
parent: z.string().nullable(),
dirs: z.array(z.object({ name: z.string(), path: z.string(), hasChildren: z.boolean() })),
/** 子目录超过上限(FILES_LIST_MAX_ENTRIES)只列了前 N 个 */
truncated: z.boolean(),
summary: z.object({ videoCount: NonNegInt, registeredCount: NonNegInt, partial: z.boolean() }),
});
export type FilesTreeResult = z.infer<typeof FilesTreeResult>;
/** G5:整理记录 → 源 FileRef(整理记录"重新整理"打开向导用) */
export const FilesRecordSourcesQuery = z.object({ recordId: Id });
export type FilesRecordSourcesQuery = z.infer<typeof FilesRecordSourcesQuery>;
export const FilesRecordSourcesResult = z.object({
recordId: Id,
mediaEntityId: Id.nullable(),
action: z.string(),
status: OrganizationStatus,
/** 仍存在且落在登记目录里的源(主视频;目录源给目录本身) */
items: z.array(FileRef),
/** 不在任何登记目录或已不存在的源路径(本机路径) */
unresolved: z.array(z.string()),
});
export type FilesRecordSourcesResult = z.infer<typeof FilesRecordSourcesResult>;
/** G9:按命名模板给选中文件生成推荐名 */
export const FilesSuggestNameRequest = z.object({
root: FileRootId,
path: FileRelPath,
/** 指定作品;缺省用已登记资产的作品,再缺省用识别的高置信候选 */
mediaEntityId: Id.optional(),
});
export type FilesSuggestNameRequest = z.infer<typeof FilesSuggestNameRequest>;
export const FilesSuggestNameResult = z.object({
/** 推荐的文件名(文件)或目录名(目录);不可信时 null */
name: z.string().nullable(),
/** 相对库根的完整推荐路径 */
relativePath: z.string().nullable(),
/** name 为 null 的原因(identity_uncertain / naming_failed 等) */
reason: z.string().nullable(),
mediaEntityId: Id.nullable(),
templateSource: NamingTemplateSource.nullable(),
segments: z.array(z.object({ text: z.string(), role: z.string() })),
});
export type FilesSuggestNameResult = z.infer<typeof FilesSuggestNameResult>;
/** 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",
/** W9-H:依赖尚未就绪(如命名引擎),HTTP 501 */
"not_implemented",
]);
export type FilesErrorReason = z.infer<typeof FilesErrorReason>;