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"]);
@@ -0,0 +1,30 @@
import type { Kysely } from "kysely";
import { execDdl, oneOf } from "../src/migration-helpers.ts";
// W5-A2 文件管理:删除 / 回收站 / 还原也写整理记录(organization_record + 逐文件结果),整理记录页可追溯。
// action / mode 取值新增 trash(移入回收站)、delete(永久删除)、restore(从回收站还原)。
const OLD = ["copy", "move", "rename", "relocate", "hardlink"] as const;
const NEW = [...OLD, "trash", "delete", "restore"] as const;
const swap = (values: readonly string[]) => `
alter table organization_record drop constraint organization_record_action_check;
alter table organization_record add constraint organization_record_action_check ${oneOf("action", values)};
alter table organization_file_result drop constraint organization_file_result_mode_check;
alter table organization_file_result add constraint organization_file_result_mode_check ${oneOf("mode", values)};
`;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, swap(NEW));
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`delete from organization_file_result where mode in ('trash', 'delete', 'restore');
delete from organization_record where action in ('trash', 'delete', 'restore');
${swap(OLD)}`,
);
}
+18 -2
View File
@@ -154,12 +154,28 @@ export type MediaServerInstanceHealthStatus = "unknown" | "healthy" | "degraded"
export type MetadataFieldSource = "tmdb" | "user" | "agent" | "nfo" | "site" | "import";
export type ObservationStatus = "new" | "linked" | "ignored" | "error";
export type OrganizationFileResultRole = "main" | "subtitle" | "audio" | "bluray_dir" | "nfo" | "image" | "other";
export type OrganizationFileResultMode = "copy" | "move" | "rename" | "relocate" | "hardlink";
export type OrganizationFileResultMode =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationFileResultStatus = "queued" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
export type OrganizationRecordTrigger = "auto" | "manual" | "agent";
export type OrganizationRecordActorType = "user" | "agent" | "system" | "api_key";
export type OrganizationRecordSourcePathView = "downloader" | "local" | "library";
export type OrganizationRecordAction = "copy" | "move" | "rename" | "relocate" | "hardlink";
export type OrganizationRecordAction =
| "copy"
| "move"
| "rename"
| "relocate"
| "hardlink"
| "trash"
| "delete"
| "restore";
export type OrganizationRecordStatus = "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled";
export type OutboxEventStatus = "pending" | "dispatched" | "failed" | "dead";
export type PathBindingBindingKind = "entity" | "season";
+2
View File
@@ -2,11 +2,13 @@ export {
type ExcludedFile,
type ExistingPolicy,
type ExistingTarget,
type FileOverride,
type OrganizeConflict,
type OrganizeFileKind,
type OrganizeInput,
type OrganizeOperation,
type OrganizePlan,
overrideFor,
type PlannedAction,
type PlannedFile,
type Precondition,
+32
View File
@@ -54,6 +54,32 @@ export interface OrganizeInput {
parse?: ParseOptions;
/** 随行字幕里此语言加 .default 标记(对照 MP DEFAULT_SUB);不设不加 */
defaultSubtitleLang?: DefaultSubtitleLang;
/**
* 逐文件季/集覆写(人工确认,优先于文件名解析):键为源文件路径或其所在目录(最长前缀生效)。
* 只影响剧集的季目录选择与命名;电影忽略。
*/
overrides?: Readonly<Record<string, FileOverride>>;
}
export interface FileOverride {
season?: number;
episodes?: { start: number; end: number };
}
/** 源路径的覆写:精确命中优先,否则取最长的目录前缀 */
export function overrideFor<T extends FileOverride>(
overrides: Readonly<Record<string, T>> | undefined,
path: string,
): T | undefined {
if (!overrides) return undefined;
const exact = overrides[path];
if (exact) return exact;
let best: { len: number; o: T } | undefined;
for (const [k, o] of Object.entries(overrides)) {
const dir = k.replace(/\/+$/, "");
if (path.startsWith(`${dir}/`) && (!best || dir.length > best.len)) best = { len: dir.length, o };
}
return best?.o;
}
export type PlannedAction = "transfer" | "replace" | "skip" | "hold";
@@ -195,6 +221,12 @@ export function planOrganize(input: OrganizeInput): OrganizePlan {
continue;
}
const parsed = parseReleasePath(segmentsFor(f.path, input.sourceRoot), input.parse ?? {});
const ov = input.mediaType === "tv" ? overrideFor(input.overrides, f.path) : undefined;
if (ov?.season !== undefined) parsed.season = ov.season;
if (ov?.episodes) {
parsed.episodes = ov.episodes;
delete parsed.airDate;
}
if (parsed.contentType === "extra" && parsed.evidence.includes("dir:extras")) {
excluded.push({ src: f.path, kind, reason: "extra_content" });
continue;
+38
View File
@@ -270,6 +270,44 @@ describe("W4-E4:字幕 .default 与 latest 旧版本判定口径(对照 MP)", ()
expect(subs.some((n) => n?.endsWith(".eng.srt"))).toBe(true);
});
it("逐文件季/集覆写:优先于文件名解析,目录前缀对其下文件生效,电影忽略", () => {
const D = "/dl/Weird.Name";
const plan = planOrganize({
operation: "copy",
mediaType: "tv",
sourceRoot: D,
files: [
{ path: `${D}/weird-a.mkv`, size: GiB },
{ path: `${D}/weird-a.chs.srt`, size: 1000 },
{ path: `${D}/Show.S01E05.mkv`, size: GiB },
{ path: `${D}/sub/Show.S01E07.mkv`, size: GiB },
],
entity: { title: "剧", year: 2020 },
target: { seasonDirs: { 1: "/lib/剧/Season 1", 2: "/lib/剧/Season 2" } },
overrides: {
[`${D}/weird-a.mkv`]: { season: 2, episodes: { start: 3, end: 4 } },
[`${D}/Show.S01E05.mkv`]: { episodes: { start: 6, end: 6 } },
[`${D}/sub`]: { season: 2 },
},
});
expect(plan.files.map((f) => f.dst)).toEqual([
"/lib/剧/Season 2/剧 - S02E03-E04.mkv",
"/lib/剧/Season 1/剧 - S01E06.mkv",
"/lib/剧/Season 2/剧 - S02E07.mkv",
"/lib/剧/Season 2/剧 - S02E03-E04.chi.zh-cn.srt",
]);
expect(plan.files[0]).toMatchObject({ season: 2, episodes: { start: 3, end: 4 } });
const movie = planOrganize({
operation: "copy",
mediaType: "movie",
files: [{ path: "/dl/M.2020.mkv", size: GiB }],
entity: { title: "M", year: 2020 },
target: { movieDir: "/lib/M (2020)" },
overrides: { "/dl/M.2020.mkv": { season: 3 } },
});
expect(movie.files[0]!.dst).toBe("/lib/M (2020)/M (2020).mkv");
});
it("findOtherVersions:同季同集同 Part、只看视频扩展名", () => {
const sib = [
"/lib/S01/Show - S01E01 - 1080p.mkv",
+4
View File
@@ -29,6 +29,10 @@ const ACTION_BACK: Record<OrganizationAction, OrganizeOperation | null> = {
// domain 不生成目录调整/硬链接计划
relocate: null,
hardlink: null,
// 文件管理的删除/还原记录不对应整理计划
trash: null,
delete: null,
restore: null,
};
export const DOMAIN_ORGANIZE_OPERATIONS = Object.keys(OPERATION) as OrganizeOperation[];