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:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

+16
View File
@@ -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,
}),
},
// ── 能力 ──
+66 -1
View File
@@ -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 -2
View File
@@ -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"]);