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
@@ -4,14 +4,17 @@ import type { Context } from "hono";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
import { errorBody } from "../../http/errors.ts";
|
||||
import { PathGuardError } from "../acquisition-organize/services/organize-guard.ts";
|
||||
import type { ModuleApp } from "../types.ts";
|
||||
import { FilesError, type FmDeps, isAdmin } from "./common.ts";
|
||||
import { FilesError, type FmDeps, fsErrorToFiles, isAdmin } from "./common.ts";
|
||||
import { downloadFile } from "./download.ts";
|
||||
import { identify } from "./identify.ts";
|
||||
import { idempotent } from "./ledger.ts";
|
||||
import { listDir, listRoots, statEntry } from "./list.ts";
|
||||
import { mkdirIn, renameEntry } from "./mutate.ts";
|
||||
import { organizeFiles } from "./organize.ts";
|
||||
import { probeFiles, scrape } from "./probe.ts";
|
||||
import { findRecords } from "./records.ts";
|
||||
import { transfer } from "./transfer.ts";
|
||||
import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
|
||||
|
||||
@@ -22,7 +25,7 @@ import { deleteFiles, listTrash, purgeTrash, restoreTrash } from "./trash.ts";
|
||||
type Input = { query: any; body: any };
|
||||
type Handler = (c: Context<AuthEnv>, input: Input) => Promise<unknown>;
|
||||
|
||||
function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
|
||||
function mount(app: ModuleApp, def: EndpointDef, handler: Handler, raw = false) {
|
||||
const route = createRoute({
|
||||
method: def.method,
|
||||
path: def.path,
|
||||
@@ -34,7 +37,9 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
|
||||
...(def.body ? { body: { content: { "application/json": { schema: def.body } }, required: true } } : {}),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
|
||||
200: raw
|
||||
? { description: "文件流", content: { "application/octet-stream": { schema: def.response } } }
|
||||
: { description: "成功", content: { "application/json": { schema: apiResponse(def.response) } } },
|
||||
},
|
||||
});
|
||||
app.openapi(route, (async (c: Context<AuthEnv>) => {
|
||||
@@ -44,10 +49,12 @@ function mount(app: ModuleApp, def: EndpointDef, handler: Handler) {
|
||||
query: def.query ? v.valid("query") : {},
|
||||
body: def.body ? v.valid("json") : undefined,
|
||||
});
|
||||
if (raw) return data as Response;
|
||||
return c.json({ ok: true as const, data }, 200);
|
||||
} catch (err) {
|
||||
if (err instanceof FilesError)
|
||||
return c.json(errorBody(err.code, `${err.reason}: ${err.message}`), err.status as 400);
|
||||
// 检查与使用之间的文件系统竞态(目录被删、挂载掉线)也按业务错误回,不给 500
|
||||
const fe = err instanceof PathGuardError ? new FilesError("path_escape", err.message) : fsErrorToFiles(err);
|
||||
if (fe) return c.json(errorBody(fe.code, `${fe.reason}: ${fe.message}`), fe.status as 400);
|
||||
throw err;
|
||||
}
|
||||
}) as never);
|
||||
@@ -98,4 +105,6 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
|
||||
mount(app, F.identify, async (c, { body }) => identify(d, c.get("principal"), body));
|
||||
mount(app, F.scrape, async (c, { body }) => idem(d, c, "scrape", body, () => scrape(d, c.get("principal"), body)));
|
||||
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
|
||||
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
|
||||
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
|
||||
}
|
||||
Reference in new issue
Block a user