feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download
- /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]>
This commit is contained in:
1 parent
3641ab9573
commit
36aa99b8aa
25 files changed
+1766
-271
No files matched your search
@@ -1177,6 +1177,22 @@ export const Api = {
|
||||
body: files.FilesProbeRequest,
|
||||
response: files.FilesProbeResult,
|
||||
}),
|
||||
download: ep({
|
||||
method: "get",
|
||||
path: "/files/download",
|
||||
scope: "admin",
|
||||
summary: "下载小文件(nfo/字幕/图片,≤20 MiB;流式;服务端另要求 admin 控制台会话;审计)",
|
||||
query: files.FilesDownloadQuery,
|
||||
response: files.FilesDownloadResponse,
|
||||
}),
|
||||
records: ep({
|
||||
method: "get",
|
||||
path: "/files/records",
|
||||
scope: "organize.read",
|
||||
summary: "按整理记录 id 或 planId 回查整理记录(整理页预填)",
|
||||
query: files.FilesRecordsQuery,
|
||||
response: files.FilesRecordsResult,
|
||||
}),
|
||||
},
|
||||
|
||||
// ── 能力 ──
|
||||
|
||||
@@ -3,6 +3,7 @@ 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)。
|
||||
@@ -285,6 +286,10 @@ export const FilePlanPair = z.object({
|
||||
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>;
|
||||
|
||||
@@ -310,11 +315,20 @@ export const FilesTransferResult = z.object({
|
||||
/** 执行结果(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>;
|
||||
|
||||
@@ -326,6 +340,10 @@ export const FilesOrganizeItem = FileRef.extend({
|
||||
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>;
|
||||
|
||||
@@ -350,8 +368,14 @@ export const FilesOrganizeGroup = z.object({
|
||||
truncated: z.boolean(),
|
||||
recordId: Id.nullable(),
|
||||
taskId: Id.nullable(),
|
||||
/** 暂未生效的逐项覆写(season/episodes/slotId 等待 planImport 扩 fileOverrides) */
|
||||
/** 无法应用的逐项覆写(如电影项的季/集、非连续集号、不属于该作品的 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(),
|
||||
});
|
||||
@@ -469,6 +493,8 @@ 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>;
|
||||
|
||||
@@ -583,6 +609,45 @@ 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>;
|
||||
|
||||
@@ -14,8 +14,20 @@ export const OrganizationTrigger = z.enum(["auto", "manual", "agent"]);
|
||||
export type OrganizationTrigger = z.infer<typeof OrganizationTrigger>;
|
||||
export const OrganizationStatus = z.enum(["queued", "running", "succeeded", "partial", "failed", "cancelled"]);
|
||||
export type OrganizationStatus = z.infer<typeof OrganizationStatus>;
|
||||
/** Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有) */
|
||||
export const OrganizationAction = z.enum(["copy", "move", "rename", "relocate", "hardlink"]);
|
||||
/**
|
||||
* Copy / Move / 重命名 / 目录调整 / 硬链接(仅兼容既有)/ 移入回收站 / 永久删除 / 从回收站还原
|
||||
* (后三者只由文件管理的显式删除/还原产生,记录已结束,不经 planImport)
|
||||
*/
|
||||
export const OrganizationAction = z.enum([
|
||||
"copy",
|
||||
"move",
|
||||
"rename",
|
||||
"relocate",
|
||||
"hardlink",
|
||||
"trash",
|
||||
"delete",
|
||||
"restore",
|
||||
]);
|
||||
export type OrganizationAction = z.infer<typeof OrganizationAction>;
|
||||
/** 源路径所在视图:下载器视角 / 执行进程本地 / 库根相对 */
|
||||
export const SourcePathView = z.enum(["downloader", "local", "library"]);
|
||||
|
||||
Reference in new issue
Block a user