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:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:39 +08:00
1 parent c2bf8a071a
commit 9d95076a0e
24 files changed
+901 -72

No files matched your search

+11
View File
@@ -74,6 +74,8 @@ export interface FmHarness {
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>;
/** 带原始请求体(如 PUT /files/upload) */
rawBody(method: string, path: string, body: BodyInit, key?: string): Promise<Response>;
close(): Promise<void>;
}
@@ -264,6 +266,15 @@ export async function createFmHarness(
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) }));
},
rawBody(method, path, body, key = DEV_KEY) {
return Promise.resolve(
app.request(`/api/v1${path}`, {
method,
headers: { ...authHeaders(key), "content-type": "application/octet-stream" },
body,
}),
);
},
async close() {
await ts.drop();
await rm(base, { recursive: true, force: true });
@@ -0,0 +1,240 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createFmHarness, type FmHarness, key, pgAvailable, SESSION } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 文件管理接入 Alist(对照 MP 存储切换 + Alist 浏览 / 新建目录 / 改名 / 删除 / 移动 / 复制 / 上传 / 下载):
// 根列表多出 st: 根、列目录一次 list(refresh 只在用户刷新时带)、删除进 Alist 上的 .umt-trash 并可还原、
// 存储内移动 / 复制走 Alist API、上传同名不覆盖、连不上返回 503 而不是「空目录」。
const skip = !(await pgAvailable());
describe.skipIf(skip)("文件管理:Alist 存储", () => {
let h: FmHarness;
let a: TestAlist;
beforeAll(async () => {
h = await createFmHarness("alist_files");
a = await addAlist(h.db, {
name: "OpenList",
mockOptions: { token: "t", mountDetails: { "/s1": { total_space: 100, free_space: 40, used_space: 60 } } },
rootPath: "/s1",
});
a.mock.put("/s1/movies/A.2020.1080p.mkv", "video-a");
a.mock.put("/s1/movies/A.2020.1080p.chs.srt", "sub-a");
a.mock.put("/s1/notes/readme.nfo", "<movie/>");
});
afterAll(async () => h?.close());
it("根列表:每个启用的 Alist 实例一个 st: 根,带存储名与容量", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const st = r.body.data.items.find((x: { id: string }) => x.id === a.root);
expect(st).toMatchObject({
kind: "storage",
name: "OpenList",
health: "healthy",
writable: true,
storage: { id: a.id, name: "OpenList", path: "/s1" },
usage: { totalBytes: 100, freeBytes: 40 },
});
});
it("列目录:一次 list,不带 refresh;用户刷新才带", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(200);
expect(r.body.data.entries.map((e: { name: string }) => e.name)).toEqual([
"A.2020.1080p.chs.srt",
"A.2020.1080p.mkv",
]);
expect(r.body.data.entries.find((e: { name: string }) => e.name.endsWith(".mkv"))).toMatchObject({
type: "file",
size: 7,
fileKind: "video",
status: "unidentified",
});
const lists = a.mock.requests.filter((x) => x.path === "/api/fs/list");
expect(lists).toHaveLength(1);
expect(lists[0]!.body!.refresh).toBe(false);
await h.api("GET", `/files?root=${a.root}&path=movies&refresh=true`);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list").at(-1)!.body!.refresh).toBe(true);
});
it("新建目录 / 改名(带字幕)/ 同名不覆盖", async () => {
const mk = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(mk.status).toBe(200);
expect(a.mock.exists("/s1/新目录")).toBe(true);
const dup = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(dup.status).toBe(409);
const rn = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/A.2020.1080p.mkv",
newName: "A (2020).mkv",
withSidecars: true,
});
expect(rn.status).toBe(200);
expect(a.mock.listNames("/s1/movies")).toEqual(["A (2020).chs.srt", "A (2020).mkv"]);
a.mock.put("/s1/movies/B.mkv", "b");
const clash = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/B.mkv",
newName: "A (2020).mkv",
});
expect(clash.status).toBe(409);
expect(text(a.mock.read("/s1/movies/A (2020).mkv"))).toBe("video-a");
});
it("上传:流式转传到 Alist,同名不覆盖", async () => {
const r = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=${encodeURIComponent("新 文件.txt")}&idempotencyKey=${key()}`,
"hello alist",
);
expect(r.status).toBe(200);
expect((await r.json()).data).toEqual({ path: "notes/新 文件.txt", size: 11 });
expect(text(a.mock.read("/s1/notes/新 文件.txt"))).toBe("hello alist");
const again = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=readme.nfo&idempotencyKey=${key()}`,
"overwrite?",
);
expect(again.status).toBe(409);
expect(text(a.mock.read("/s1/notes/readme.nfo"))).toBe("<movie/>");
// 本地根同样可以上传
const local = await h.rawBody(
"PUT",
`/files/upload?root=${h.libRoot}&path=&name=up.txt&idempotencyKey=${key()}`,
"local",
);
expect(local.status).toBe(200);
expect(await readFile(join(h.lib, "up.txt"), "utf8")).toBe("local");
});
it("下载与预览(管理员会话):流式转发 Alist 直链", async () => {
const r = await h.raw("GET", `/files/download?root=${a.root}&path=notes/readme.nfo`, SESSION);
expect(r.status).toBe(200);
expect(await r.text()).toBe("<movie/>");
const p = await h.api("GET", `/files/preview?root=${a.root}&path=notes/readme.nfo`, undefined, SESSION);
expect(p.status).toBe(200);
expect(JSON.stringify(p.body.data)).toContain("movie");
});
it("删除进 Alist 上的回收站,可还原", async () => {
const del = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
dryRun: false,
});
expect(del.status).toBe(200);
expect(del.body.data.trashed).toHaveLength(1);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
const trashPath = del.body.data.trashed[0].trashPath as string;
expect(trashPath.startsWith(a.v("/s1/.umt-trash/"))).toBe(true);
const restore = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [del.body.data.trashed[0].id],
});
expect(restore.status).toBe(200);
expect(restore.body.data.restored).toHaveLength(1);
expect(text(a.mock.read("/s1/movies/B.mkv"))).toBe("b");
});
it("存储内复制 / 移动走 Alist API(复制源保留,移动源消失)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: true,
});
expect(dry.status).toBe(200);
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.status).toBe(200);
expect(exec.body.data.executed).toMatchObject({ succeeded: ["notes/readme.nfo"], failed: [] });
expect(text(a.mock.read("/s1/新目录/readme.nfo"))).toBe("<movie/>");
expect(a.mock.exists("/s1/notes/readme.nfo")).toBe(true);
expect(a.mock.requests.some((x) => x.path === "/api/fs/copy")).toBe(true);
const mdry = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: true,
});
const mexec = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: false,
planId: mdry.body.data.planId,
});
expect(mexec.status).toBe(200);
expect(mexec.body.data.executed.failed).toEqual([]);
expect(a.mock.exists("/s1/新目录/B.mkv")).toBe(true);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
});
it("复制到本地媒体库(下载到本地)与从本地复制到 Alist 媒体库(上传)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: true,
});
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.body.data.executed.failed).toEqual([]);
expect(await readFile(join(h.lib, "A (2020).mkv"), "utf8")).toBe("video-a");
const alib = await addAlistLibrary(h.db, a, "/s1/library");
await writeFile(join(h.lib, "local.mkv"), "local-video");
const d2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: true,
});
expect(d2.status).toBe(200);
const e2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: false,
planId: d2.body.data.planId,
});
expect(e2.body.data.executed.failed).toEqual([]);
expect(text(a.mock.read("/s1/library/local.mkv"))).toBe("local-video");
});
it("目录树:Alist 只列当前层,不逐个子目录再请求", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files/tree?root=${a.root}&path=`);
expect(r.status).toBe(200);
expect(r.body.data.dirs.map((x: { name: string }) => x.name)).toEqual(
expect.arrayContaining(["movies", "notes", "新目录"]),
);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list")).toHaveLength(1);
});
it("Alist 连不上:列目录 503,不当成空目录", async () => {
a.mock.failNext(5);
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { AlistClient, type AlistClientOptions } from "@umt/adapters";
import { createMockAlist, type MockAlist, type MockAlistOptions } from "@umt/adapters/alist-mock";
import type { DB, Kysely } from "@umt/db";
import { overrideStorage } from "../../src/storage/registry.ts";
import { toVPath } from "../../src/storage/vpath.ts";
// Alist 测试装配:登记一个存储实例(storage_instance 行)并把注册表里的客户端换成进程内模拟 Alist。
export interface TestAlist {
id: string;
mock: MockAlist;
client: AlistClient;
/** 文件管理根 id(st:<id>) */
root: string;
/** Alist 内路径 → 虚拟路径 */
v(p: string): string;
}
export async function addAlist(
db: Kysely<DB>,
o: {
name?: string;
rootPath?: string;
mock?: MockAlist;
mockOptions?: MockAlistOptions;
client?: Partial<AlistClientOptions>;
} = {},
): Promise<TestAlist> {
const mock = o.mock ?? createMockAlist(o.mockOptions ?? { token: "t" });
const rootPath = o.rootPath ?? "/";
const row = await db
.insertInto("storage_instance")
.values({ name: o.name ?? `alist-${crypto.randomUUID().slice(0, 6)}`, base_url: mock.base, root_path: rootPath })
.returning("id")
.executeTakeFirstOrThrow();
// 假时钟:等待 / 退避不真睡,超时按累计的「睡眠」计
let clock = 0;
const client = new AlistClient({
baseUrl: mock.base,
auth: { kind: "token", token: "t" },
fetch: mock.fetch,
qps: 0,
concurrency: 0,
retries: 0,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
...o.client,
});
overrideStorage(row.id, { client, name: o.name ?? "Alist", rootPath });
return { id: row.id, mock, client, root: `st:${row.id}`, v: (p) => toVPath(row.id, p) };
}
/** 在 Alist 上登记一个纳管媒体库目录 */
export async function addAlistLibrary(db: Kysely<DB>, a: TestAlist, path: string, name = "Alist 媒体库") {
a.mock.mkdirp(path);
const r = await db
.insertInto("library_root")
.values({ name, media_kind: "mixed", local_path: a.v(path), management_mode: "managed", sentinel_file: null })
.returning("id")
.executeTakeFirstOrThrow();
return { libraryRootId: r.id, root: `lib:${r.id}` };
}
export const text = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null);