- 行上新增 episodeTouched / mappedFor:只有行内改季集、批量季号、集号偏移、弹窗指定季号的行才发季集; 未改动的预填只作展示,交给服务端按最终作品映射 - 改选作品后,按旧作品映射的预填清空(未映射的解析值保留);补识别刷新未改动行的预填 - 识别结果新增 episodeUnresolved(集数待确认),向导里该行未决,需填季集或排除 - pg 测试:向导整理 `[ANi] X - 25` 不带季集 → S02E13,显式季集按用户值 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
985 lines
40 KiB
TypeScript
985 lines
40 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";
|
|
import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
|
|
|
|
// 文件管理(`/files`,docs/runbook/files.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"]);
|
|
/** 原地入库保留路径与名称;仅用于整理,不扩展文件复制/移动 API。 */
|
|
export const FilesOrganizeMode = z.enum(["copy", "move", "in_place"]);
|
|
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: FilesOrganizeMode.optional(),
|
|
/** W5-A2:目标目录覆写(writable 库根下已存在目录;不建路径绑定,文件按模板命名放入该目录) */
|
|
targetDir: FileRef.optional(),
|
|
});
|
|
export type FilesOrganizeItem = z.infer<typeof FilesOrganizeItem>;
|
|
|
|
export const FilesOrganizeRequest = z.object({
|
|
sourceConfigId: Id.optional(),
|
|
idempotencyKey: IdempotencyKey,
|
|
items: z.array(FilesOrganizeItem).min(1).max(500),
|
|
action: FilesOrganizeMode,
|
|
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(),
|
|
/** 跳过已有真正成功整理记录的文件;目标已存在策略独立生效。 */
|
|
skipSuccessfulRecords: z.boolean().optional(),
|
|
/** 本次入库后写 NFO/图片;缺省沿用已有设置,Local 保留自动补全行为。 */
|
|
scrapeAfterImport: 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: FilesOrganizeMode.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()),
|
|
/** 按首个本地候选做集数映射时映射不出季集(集数待确认):parsed 是未映射的解析值,需要人工指定季集 */
|
|
episodeUnresolved: z.boolean().optional(),
|
|
/** 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(),
|
|
successfulCount: NonNegInt.optional(),
|
|
})
|
|
.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>;
|
|
|
|
// 刮削(SCR-2,照 MP):任意目录 / 视频文件就地写 NFO 与图片;按路径识别作品(已登记直接用,未登记走 TMDB 匹配),
|
|
// 认不准时返回候选(needsChoice),选定后带 mediaEntityId / external 再提交一次。
|
|
|
|
export const FilesScrapeItem = FileRef.extend({
|
|
/** 指定本地作品(选定候选后回传);与 external 二选一 */
|
|
mediaEntityId: Id.optional(),
|
|
/** 指定 TMDB 条目(候选里只有 TMDB 的);不登记也能刮削 */
|
|
external: FilesExternalCandidate.optional(),
|
|
});
|
|
export type FilesScrapeItem = z.infer<typeof FilesScrapeItem>;
|
|
|
|
export const FilesScrapeRequest = z.object({
|
|
idempotencyKey: IdempotencyKey,
|
|
items: z.array(FilesScrapeItem).min(1).max(100),
|
|
/** 缺省按设置 organize.scrapeMode */
|
|
mode: ScrapeMode.optional(),
|
|
/** 指定的 TMDB 条目顺手加入媒体库(建作品);不影响刮削本身 */
|
|
addToLibrary: z.boolean().default(false),
|
|
});
|
|
export type FilesScrapeRequest = z.infer<typeof FilesScrapeRequest>;
|
|
|
|
export const FilesScrapeNeedsChoice = z.object({
|
|
root: FileRootId,
|
|
path: z.string(),
|
|
/** 从路径解析出的标题 */
|
|
title: z.string().nullable(),
|
|
/** 前 5 个候选(可能为空:让用户搜索) */
|
|
candidates: z.array(FileIdentifyCandidate).max(5),
|
|
warnings: z.array(z.string()),
|
|
});
|
|
export type FilesScrapeNeedsChoice = z.infer<typeof FilesScrapeNeedsChoice>;
|
|
|
|
export const FilesScrapeResult = z.object({
|
|
/** 已提交的刮削(每项一条记录);pending=true 时仍有排队 / 进行中的,按 id 查 /files/scrape-records */
|
|
records: z.array(ScrapeRecord),
|
|
/** 认不准作品的项:选一个候选后带 mediaEntityId / external 重新提交 */
|
|
needsChoice: z.array(FilesScrapeNeedsChoice),
|
|
/** 没提交的项:scrape_root_read_only(只登记目录)/ scrape_not_video(不是视频或目录) */
|
|
skipped: z.array(z.object({ root: FileRootId, path: z.string(), reason: z.string() })),
|
|
pending: z.boolean(),
|
|
/** addToLibrary 时加入媒体库的作品 */
|
|
entities: z.array(z.object({ mediaEntityId: Id, title: z.string(), created: z.boolean() })),
|
|
});
|
|
export type FilesScrapeResult = z.infer<typeof FilesScrapeResult>;
|
|
|
|
export const FilesScrapeRecordsQuery = z
|
|
.object({
|
|
/** 逗号分隔的刮削记录 id(≤100) */
|
|
ids: z.string().min(1).max(4000).optional(),
|
|
/** 查询本次整理关联的刮削记录;与 ids 同时提供时取交集。 */
|
|
organizationRecordId: Id.optional(),
|
|
})
|
|
.refine((q) => !!q.ids || !!q.organizationRecordId, { message: "需要 ids 或 organizationRecordId" });
|
|
export type FilesScrapeRecordsQuery = z.infer<typeof FilesScrapeRecordsQuery>;
|
|
export const FilesScrapeRecordsResult = z.object({ records: z.array(ScrapeRecord) });
|
|
export type FilesScrapeRecordsResult = z.infer<typeof FilesScrapeRecordsResult>;
|
|
|
|
// 探测
|
|
|
|
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 等)");
|
|
|
|
// 预览(FM-2):GET /files/preview 返回结构化预览,仅管理员会话,路径守卫同下载。
|
|
// 文本/字幕最多读前 1 MiB 并探测编码(BOM / UTF-8 / UTF-16 / GB18030 / Big5);字幕解析前 200 条;
|
|
// .torrent ≤ 4 MiB 解析文件列表、tracker 与 infohash;图片只回尺寸类型信息,内容走 /files/download?inline=true。
|
|
export const FilesPreviewQuery = z.object({ root: FileRootId, path: FileRelPath });
|
|
export type FilesPreviewQuery = z.infer<typeof FilesPreviewQuery>;
|
|
|
|
export const SubtitleCue = z.object({
|
|
/** 毫秒 */
|
|
start: NonNegInt,
|
|
end: NonNegInt,
|
|
/** 去掉样式标签后的纯文本;图形字幕(sup)为空串 */
|
|
text: z.string(),
|
|
});
|
|
export type SubtitleCue = z.infer<typeof SubtitleCue>;
|
|
|
|
const PreviewBase = z.object({ size: NonNegInt });
|
|
export const FilePreview = z.discriminatedUnion("type", [
|
|
PreviewBase.extend({
|
|
type: z.literal("text"),
|
|
/** 小写扩展名(nfo/txt/log/md/json/xml/yaml…) */
|
|
format: z.string(),
|
|
/** 探测出的编码名(utf-8 / utf-16le / utf-16be / gb18030 / big5 / windows-1252) */
|
|
encoding: z.string(),
|
|
text: z.string(),
|
|
truncated: z.boolean(),
|
|
}),
|
|
PreviewBase.extend({
|
|
type: z.literal("subtitle"),
|
|
format: z.enum(["srt", "vtt", "ass", "ssa", "sup"]),
|
|
encoding: z.string().nullable(),
|
|
cues: z.array(SubtitleCue),
|
|
/** 读到的范围内共解析出多少条(可能大于 cues.length) */
|
|
parsed: NonNegInt,
|
|
/** 只读了文件前一部分 */
|
|
truncated: z.boolean(),
|
|
/** ASS/SSA 的 [Script Info] Title */
|
|
title: z.string().nullable(),
|
|
}),
|
|
PreviewBase.extend({
|
|
type: z.literal("torrent"),
|
|
name: z.string(),
|
|
hash: z.string(),
|
|
infohashV1: z.string().nullable(),
|
|
infohashV2: z.string().nullable(),
|
|
private: z.boolean(),
|
|
totalSize: NonNegInt,
|
|
fileCount: NonNegInt,
|
|
/** 最多 500 个 */
|
|
files: z.array(z.object({ path: z.string(), length: NonNegInt })),
|
|
trackers: z.array(z.string()),
|
|
comment: z.string().nullable(),
|
|
createdBy: z.string().nullable(),
|
|
createdAt: Timestamp.nullable(),
|
|
pieceLength: NonNegInt.nullable(),
|
|
}),
|
|
PreviewBase.extend({
|
|
type: z.literal("image"),
|
|
mime: z.string(),
|
|
width: NonNegInt.nullable(),
|
|
height: NonNegInt.nullable(),
|
|
}),
|
|
]);
|
|
export type FilePreview = z.infer<typeof FilePreview>;
|
|
|
|
// 整理记录回查(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 里的文件/目录归到其已登记资产的作品(刮削见 /files/scrape) */
|
|
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_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({
|
|
/** 原整理计划保存的来源策略;不存在时由当前入口重新匹配。 */
|
|
sourceConfigId: Id.optional(),
|
|
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>;
|