feat(files): 文件管理接入 Alist 存储(浏览、增删改、移动复制、上传下载、回收站)
- 文件管理根多出 st:<存储 id>(对照 MP 存储切换),Alist 媒体库目录照常出现在媒体库组 - 列目录一次 list,refresh 只在用户刷新时带;连不上返回 503 不当成空目录 - 新建目录 / 改名(同名不覆盖)/ 删除进 Alist 上的 .umt-trash 并可还原 - 存储内移动复制走 Alist API,本地 ↔ Alist 走整理同一套执行与账本 - 新增 PUT /files/upload(同名不覆盖,Alist 经服务端转传);下载 / 预览流式转发直链 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
c2bf8a071a
commit
9d95076a0e
24 files changed
+901
-72
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { lstat, readdir, stat } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import type { FilePlanPair, FilesMoveRequest, FilesTransferResult, OrganizationStatus } from "@umt/contracts";
|
||||
import type { Principal } from "../../http/auth.ts";
|
||||
import { isRemotePath, vlstat as lstat, vreaddir as readdir } from "../../storage/vfs.ts";
|
||||
import { ensureTaskRow, markTask } from "../acquisition-organize/services/common.ts";
|
||||
import { planImport } from "../acquisition-organize/services/organize.ts";
|
||||
import { authorizeMoveEntities, PathGuardError, sourceGuard } from "../acquisition-organize/services/organize-guard.ts";
|
||||
@@ -94,8 +94,10 @@ export async function transfer(
|
||||
const mode = body.mode;
|
||||
const t = await resolveIn(d, body.target.root, body.target.path);
|
||||
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
|
||||
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录`);
|
||||
// 目标:可写的媒体库目录(本地或 Alist),或 Alist 存储根(文件管理的 st: 根,对照 MP 存储内移动 / 复制)
|
||||
const storageTarget = t.def.kind === "storage";
|
||||
if ((!t.def.libraryRootId && !storageTarget) || !(await isWritableTarget(t.def, t.rootReal)))
|
||||
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 可写的媒体库目录或 Alist 存储`);
|
||||
|
||||
const sources: Resolved[] = [];
|
||||
for (const it of body.items) {
|
||||
@@ -118,7 +120,7 @@ export async function transfer(
|
||||
for (const h of downloadsFor(downloads, s.abs))
|
||||
hnrBlocks.push({ taskId: h.taskId, path: s.rel, hnr: h.hnr, status: h.status });
|
||||
|
||||
const targetDev = Number((await stat(t.abs)).dev);
|
||||
const targetDev = Number((await lstat(t.abs)).dev);
|
||||
const spec: TransferSpec = {
|
||||
items: sources.map((s, i) => {
|
||||
const dst = join(t.abs, names[i]!);
|
||||
@@ -164,6 +166,11 @@ export async function transfer(
|
||||
? (await relocatePreview(d.ctx.db, s.def.libraryRootId, s.rel, snaps[i]!.isDir)).locations > 0
|
||||
: false;
|
||||
const crossDevice = Number((await lstat(s.abs)).dev) !== targetDev;
|
||||
if (storageTarget && mode === "move" && registered)
|
||||
throw new FilesError(
|
||||
"needs_retire",
|
||||
`${s.rel} 含已整理的文件,只能移动到媒体库目录;要放到 Alist 存储请用复制或先从媒体库移除`,
|
||||
);
|
||||
pairs.push({
|
||||
source: { root: body.items[i]!.root, path: s.rel },
|
||||
target: { root: body.target.root, path: relOf(t.rootReal, dst) },
|
||||
@@ -175,7 +182,7 @@ export async function transfer(
|
||||
});
|
||||
if (!(mode === "move" && !crossDevice)) needBytes += snaps[i]!.size;
|
||||
}
|
||||
const du = await d.ao.ports.diskUsage(t.abs).catch(() => null);
|
||||
const du = isRemotePath(t.abs) ? null : await d.ao.ports.diskUsage(t.abs).catch(() => null);
|
||||
const freeBytes = du?.status === "ok" ? du.availableBytes : null;
|
||||
const spaceOk = freeBytes === null ? true : needBytes <= freeBytes;
|
||||
const truncated = snaps.some((s) => s.truncated) || planned.truncated === true;
|
||||
@@ -184,10 +191,14 @@ export async function transfer(
|
||||
const request = {
|
||||
sourcePaths: sources.map((s) => s.abs),
|
||||
action: mode,
|
||||
targetLibraryRootId: t.def.libraryRootId,
|
||||
...(t.def.libraryRootId ? { targetLibraryRootId: t.def.libraryRootId } : {}),
|
||||
};
|
||||
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
|
||||
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
|
||||
// 跨卷开关只管本地 ↔ 本地;涉及 Alist 的移动一律「传输成功 → 删源」(同 MP)
|
||||
const crossBlocked =
|
||||
mode === "move" &&
|
||||
!allowCross &&
|
||||
pairs.some((p, i) => p.crossDevice && !isRemotePath(sources[i]!.abs) && !isRemotePath(t.abs));
|
||||
const warnings: string[] = [];
|
||||
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
|
||||
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
|
||||
|
||||
Reference in new issue
Block a user