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"]);
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
Reference in new issue
Block a user