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
@@ -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:/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user