merge: feat/alist-storage
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # packages/adapters/deno.json
This commit is contained in:
commit
339105e151
108 files changed
+7255
-214
No files matched your search
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@umt/adapters",
|
||||
"version": "0.3.35",
|
||||
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts" },
|
||||
"exports": { ".": "./src/index.ts", "./fsworker": "./src/fs/fsworker.ts", "./alist-mock": "./src/alist/mock.ts" },
|
||||
"tasks": {
|
||||
"test": "deno run -A npm:vitest run",
|
||||
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./fsworker": "./src/fs/fsworker.ts"
|
||||
"./fsworker": "./src/fs/fsworker.ts",
|
||||
"./alist-mock": "./src/alist/mock.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { type AlistAuth, AlistClient } from "./client.ts";
|
||||
|
||||
// 真实 Alist / OpenList(环境变量开启,缺省跳过):
|
||||
// UMT_ALIST_REAL_URL 地址,如 http://127.0.0.1:55244
|
||||
// UMT_ALIST_REAL_TOKEN 永久令牌;或 UMT_ALIST_REAL_USER + UMT_ALIST_REAL_PASSWORD
|
||||
// UMT_ALIST_REAL_ROOT 可写测试目录(会在里面建 umt-it-<随机>/,结束删除),如 /s1
|
||||
// UMT_ALIST_REAL_ROOT2 可选:另一个挂载(驱动)下的可写目录,测跨驱动复制 / 移动(异步任务)
|
||||
// 跑法:docs/runbook/storage-alist.md「真机验收」。
|
||||
|
||||
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
|
||||
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
|
||||
const ROOT2 = Deno.env.get("UMT_ALIST_REAL_ROOT2");
|
||||
const auth: AlistAuth | null = Deno.env.get("UMT_ALIST_REAL_TOKEN")
|
||||
? { kind: "token", token: Deno.env.get("UMT_ALIST_REAL_TOKEN")! }
|
||||
: Deno.env.get("UMT_ALIST_REAL_USER")
|
||||
? {
|
||||
kind: "password",
|
||||
username: Deno.env.get("UMT_ALIST_REAL_USER")!,
|
||||
password: Deno.env.get("UMT_ALIST_REAL_PASSWORD") ?? "",
|
||||
}
|
||||
: null;
|
||||
const enabled = !!URL_ && !!ROOT && !!auth;
|
||||
const sha = (b: Uint8Array) => createHash("sha256").update(b).digest("hex");
|
||||
|
||||
describe.skipIf(!enabled)("真实 Alist / OpenList:本地 → Alist → 本地往返", () => {
|
||||
let c: AlistClient;
|
||||
let dir: string;
|
||||
let remote: string;
|
||||
let remote2: string | null = null;
|
||||
const data = randomBytes(3 * 1024 * 1024 + 123);
|
||||
beforeAll(async () => {
|
||||
c = new AlistClient({ baseUrl: URL_!, auth: auth!, qps: 10, concurrency: 4 });
|
||||
dir = await mkdtemp(join(tmpdir(), "umt-alist-real-"));
|
||||
remote = `${ROOT!.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
|
||||
if (ROOT2) remote2 = `${ROOT2.replace(/\/+$/, "")}/umt-it-${randomBytes(3).toString("hex")}`;
|
||||
await writeFile(join(dir, "source.bin"), data);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await c?.removePath(remote).catch(() => undefined);
|
||||
if (remote2) await c?.removePath(remote2).catch(() => undefined);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("连接:版本 / 用户 / 根目录", async () => {
|
||||
const v = await c.version();
|
||||
const me = await c.me();
|
||||
console.log(`[real] Alist 版本 ${v},用户 ${me.username}`);
|
||||
expect((await c.get(ROOT!)).isDir).toBe(true);
|
||||
});
|
||||
|
||||
it("上传(临时名 → 改名、带秒传头)→ 同名冲突不覆盖", async () => {
|
||||
let last = 0;
|
||||
const r = await c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`, { onProgress: (d) => (last = d) });
|
||||
console.log(`[real] 上传 ${r.bytes} 字节,sha256 ${r.hashes.sha256.slice(0, 12)}…,进度回调到 ${last}`);
|
||||
expect(r.obj.size).toBe(data.length);
|
||||
expect(last).toBe(data.length);
|
||||
const names = (await c.list(`${remote}/电影 A`, { refresh: true })).map((o) => o.name);
|
||||
expect(names).toEqual(["source.bin"]);
|
||||
await expect(c.upload(join(dir, "source.bin"), `${remote}/电影 A/source.bin`)).rejects.toMatchObject({
|
||||
kind: "conflict",
|
||||
});
|
||||
});
|
||||
|
||||
it("同驱动复制(改名走暂存目录)/ 移动 / 改名", async () => {
|
||||
const cp = await c.copyFile(`${remote}/电影 A/source.bin`, `${remote}/library/Movie (2020).bin`);
|
||||
expect(cp.size).toBe(data.length);
|
||||
const mv = await c.moveFile(`${remote}/library/Movie (2020).bin`, `${remote}/library2/Moved.bin`);
|
||||
expect(mv.path).toBe(`${remote}/library2/Moved.bin`);
|
||||
expect(await c.stat(`${remote}/library/Movie (2020).bin`)).toBeNull();
|
||||
const lib = (await c.list(`${remote}/library`, { refresh: true })).map((o) => o.name);
|
||||
expect(lib.filter((n) => n.startsWith(".umt-stage-"))).toEqual([]);
|
||||
console.log(`[real] Alist 内复制、移动完成,暂存目录已清理`);
|
||||
});
|
||||
|
||||
it.skipIf(!ROOT2)("跨驱动复制 / 移动:轮询 Alist 任务到完成", async () => {
|
||||
const cp = await c.copyFile(`${remote}/library2/Moved.bin`, `${remote2}/cross/Copied.bin`);
|
||||
expect(cp.size).toBe(data.length);
|
||||
const mv = await c.moveFile(`${remote2}/cross/Copied.bin`, `${remote}/back/Back.bin`);
|
||||
expect(mv.size).toBe(data.length);
|
||||
console.log(`[real] 跨驱动复制、移动完成(任务轮询)`);
|
||||
});
|
||||
|
||||
it("下载回本地:内容与源一致;本地已存在冲突", async () => {
|
||||
const out = join(dir, "back.bin");
|
||||
const r = await c.download(`${remote}/library2/Moved.bin`, out);
|
||||
const got = new Uint8Array(await readFile(out));
|
||||
console.log(`[real] 下载 ${r.bytes} 字节,与源 sha256 一致:${sha(got) === sha(data)}`);
|
||||
expect(sha(got)).toBe(sha(data));
|
||||
await expect(c.download(`${remote}/library2/Moved.bin`, out)).rejects.toMatchObject({ kind: "conflict" });
|
||||
});
|
||||
|
||||
it("不存在 ≠ 失败;容量拿不到返回 null 不报错", async () => {
|
||||
await expect(c.get(`${remote}/nope.bin`)).rejects.toMatchObject({ kind: "not_found" });
|
||||
const u = await c.usage(ROOT!);
|
||||
console.log(`[real] 容量:${u ? `${u.free}/${u.total}` : "未知"}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AlistClient, type AlistClientOptions, hashLocalFile, PARTIAL_SUFFIX } from "./client.ts";
|
||||
import { AlistError, classifyAlist } from "./errors.ts";
|
||||
import { AlistLimiter } from "./limiter.ts";
|
||||
import { createMockAlist, type MockAlistOptions } from "./mock.ts";
|
||||
import { redactAlistText, redactAlistUrl } from "./redact.ts";
|
||||
|
||||
// 适配器:mock Alist 覆盖全部接口、错误码、令牌失效重登、异步任务、上传下载进度与中断(docs/design/storage-alist.md §12)。
|
||||
|
||||
let dir: string;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "umt-alist-"));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const noSleep = async () => {};
|
||||
function setup(mo: MockAlistOptions = { token: "perm-token" }, co: Partial<AlistClientOptions> = {}) {
|
||||
const mock = createMockAlist(mo);
|
||||
const client = new AlistClient({
|
||||
baseUrl: mock.base,
|
||||
auth: mo.username
|
||||
? { kind: "password", username: mo.username, password: mo.password ?? "" }
|
||||
: { kind: "token", token: mo.token ?? "" },
|
||||
fetch: mock.fetch,
|
||||
sleep: noSleep,
|
||||
qps: 0,
|
||||
concurrency: 0,
|
||||
...co,
|
||||
});
|
||||
return { mock, client };
|
||||
}
|
||||
const bytes = (n: number, seed = 7) => {
|
||||
const b = new Uint8Array(n);
|
||||
for (let i = 0; i < n; i++) b[i] = (i * 31 + seed) & 0xff;
|
||||
return b;
|
||||
};
|
||||
const caught = async (p: Promise<unknown>) => {
|
||||
try {
|
||||
await p;
|
||||
} catch (e) {
|
||||
return e as AlistError;
|
||||
}
|
||||
throw new Error("expected rejection");
|
||||
};
|
||||
|
||||
describe("错误分类(不存在 ≠ 失败)", () => {
|
||||
it("真机返回形状 → 类型", () => {
|
||||
expect(classifyAlist(200, 500, "failed to get obj: object not found").kind).toBe("not_found");
|
||||
expect(classifyAlist(200, 401, "token is invalidated").kind).toBe("auth");
|
||||
expect(classifyAlist(200, 403, "file [x.bin] exists").kind).toBe("conflict");
|
||||
expect(classifyAlist(429, null, "Too Many Requests").kind).toBe("rate_limited");
|
||||
expect(classifyAlist(502, null, "bad gateway").kind).toBe("unavailable");
|
||||
expect(classifyAlist(200, 500, "failed get storage: storage not found; please add a storage first").kind).toBe(
|
||||
"not_found",
|
||||
);
|
||||
expect(classifyAlist(200, 500, "some driver error").kind).toBe("error");
|
||||
expect(new AlistError("unavailable", "x").retryable).toBe(true);
|
||||
expect(new AlistError("not_found", "x").fsCode).toBe("ENOENT");
|
||||
expect(new AlistError("conflict", "x").fsCode).toBe("EEXIST");
|
||||
expect(new AlistError("auth", "x").fsCode).toBe("EACCES");
|
||||
});
|
||||
|
||||
it("get:不存在抛 not_found,stat 返回 null;连不上抛 unavailable(不当成不存在)", async () => {
|
||||
const { mock, client } = setup({ token: "t" }, { retries: 0 });
|
||||
expect((await caught(client.get("/s1/nope"))).kind).toBe("not_found");
|
||||
expect(await client.stat("/s1/nope")).toBeNull();
|
||||
mock.failNext(1);
|
||||
expect((await caught(client.stat("/s1/nope"))).kind).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("可重试错误退避重试;次数用尽才失败", async () => {
|
||||
const { mock, client } = setup({ token: "t" }, { retries: 2 });
|
||||
mock.put("/s1/a.txt", "hi");
|
||||
mock.failNext(2);
|
||||
expect((await client.get("/s1/a.txt")).size).toBe(2);
|
||||
expect(client.stats.retries).toBe(2);
|
||||
mock.failNext(3);
|
||||
expect((await caught(client.get("/s1/a.txt"))).kind).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("限流 / 风控(429)退避后成功", async () => {
|
||||
const slept: number[] = [];
|
||||
const { mock, client } = setup({ token: "t" }, { sleep: async (ms) => void slept.push(ms) });
|
||||
mock.put("/s1/a.txt", "hi");
|
||||
mock.rateLimitNext(2);
|
||||
expect((await client.get("/s1/a.txt")).name).toBe("a.txt");
|
||||
expect(slept.length).toBe(2);
|
||||
expect(slept[0]).toBeGreaterThanOrEqual(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("令牌", () => {
|
||||
it("永久令牌原样放 Authorization", async () => {
|
||||
const { mock, client } = setup({ token: "perm" });
|
||||
await client.me();
|
||||
expect(mock.requests.at(-1)!.headers.authorization).toBe("perm");
|
||||
});
|
||||
|
||||
it("永久令牌失效:直接报鉴权错误,不重试", async () => {
|
||||
const mock = createMockAlist({ token: "right" });
|
||||
const client = new AlistClient({
|
||||
baseUrl: mock.base,
|
||||
auth: { kind: "token", token: "wrong" },
|
||||
fetch: mock.fetch,
|
||||
qps: 0,
|
||||
});
|
||||
const e = await caught(client.me());
|
||||
expect(e.kind).toBe("auth");
|
||||
expect(mock.requests.length).toBe(1);
|
||||
});
|
||||
|
||||
it("账号密码:登录换令牌并缓存;令牌失效自动重登一次再试", async () => {
|
||||
const { mock, client } = setup({ username: "u", password: "p" });
|
||||
await client.me();
|
||||
await client.me();
|
||||
expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(1);
|
||||
mock.invalidateSessions();
|
||||
expect((await client.me()).username).toBe("u");
|
||||
expect(client.stats.relogins).toBe(1);
|
||||
expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(2);
|
||||
});
|
||||
|
||||
it("密码错误:鉴权错误", async () => {
|
||||
const mock = createMockAlist({ username: "u", password: "p" });
|
||||
const client = new AlistClient({
|
||||
baseUrl: mock.base,
|
||||
auth: { kind: "password", username: "u", password: "bad" },
|
||||
fetch: mock.fetch,
|
||||
qps: 0,
|
||||
});
|
||||
expect((await caught(client.me())).kind).toBe("auth");
|
||||
});
|
||||
|
||||
it("令牌缓存到期(约 2 天)后重新登录", async () => {
|
||||
let now = 1_000_000;
|
||||
const { mock, client } = setup({ username: "u", password: "p" }, { now: () => now });
|
||||
await client.me();
|
||||
now += 2 * 86_400_000;
|
||||
await client.me();
|
||||
expect(mock.requests.filter((r) => r.path === "/api/auth/login").length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("读接口", () => {
|
||||
it("list 自动翻页(每页 500),不带 refresh;用户刷新才带", async () => {
|
||||
const { mock, client } = setup();
|
||||
for (let i = 0; i < 1203; i++) mock.put(`/s1/big/f${String(i).padStart(4, "0")}.txt`, "x");
|
||||
const all = await client.list("/s1/big");
|
||||
expect(all.length).toBe(1203);
|
||||
const lists = mock.requests.filter((r) => r.path === "/api/fs/list");
|
||||
expect(lists.map((r) => r.body!.page)).toEqual([1, 2, 3]);
|
||||
expect(lists.every((r) => r.body!.per_page === 500 && r.body!.refresh === false)).toBe(true);
|
||||
await client.list("/s1/big", { refresh: true });
|
||||
expect(mock.requests.filter((r) => r.path === "/api/fs/list").at(-3)!.body!.refresh).toBe(true);
|
||||
expect(client.stats.refreshes).toBe(1);
|
||||
});
|
||||
|
||||
it("路径密码:list / get 都带上", async () => {
|
||||
const { mock, client } = setup({ token: "t", pathPassword: "pw1" }, { pathPassword: "pw1" });
|
||||
mock.put("/s1/a.txt", "hi");
|
||||
expect((await client.list("/s1")).map((o) => o.name)).toEqual(["a.txt"]);
|
||||
expect(mock.requests.at(-1)!.body!.password).toBe("pw1");
|
||||
const noPw = new AlistClient({
|
||||
baseUrl: mock.base,
|
||||
auth: { kind: "token", token: "t" },
|
||||
fetch: mock.fetch,
|
||||
qps: 0,
|
||||
});
|
||||
expect((await caught(noPw.list("/s1"))).kind).toBe("auth");
|
||||
});
|
||||
|
||||
it("get 带 raw_url / sign / hash_info;rawUrl 优先 raw_url", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/a b.mkv", "data", { hashInfo: { md5: "abc" } });
|
||||
const o = await client.get("/s1/a b.mkv");
|
||||
expect(o.size).toBe(4);
|
||||
expect(o.hashInfo).toEqual({ md5: "abc" });
|
||||
expect(o.sign).not.toBe("");
|
||||
expect(await client.rawUrl("/s1/a b.mkv")).toContain("/d/s1/a%20b.mkv?sign=");
|
||||
});
|
||||
|
||||
it("容量:有 mount_details 显示,没有返回 null 不报错", async () => {
|
||||
const { mock, client } = setup({
|
||||
token: "t",
|
||||
mountDetails: { "/s1": { total_space: 1000, free_space: 400, used_space: 600 } },
|
||||
});
|
||||
mock.mkdirp("/s1/x");
|
||||
mock.mkdirp("/s2/y");
|
||||
expect(await client.usage("/s1/x")).toEqual({ total: 1000, free: 400, used: 600 });
|
||||
expect(await client.usage("/s2/y")).toBeNull();
|
||||
mock.failNext(1);
|
||||
expect(await client.usage("/s1")).toBeNull();
|
||||
});
|
||||
|
||||
it("readFile 读小文件", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/movie.nfo", "<movie/>");
|
||||
expect(new TextDecoder().decode(await client.readFile("/s1/movie.nfo"))).toBe("<movie/>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("写接口与异步任务", () => {
|
||||
it("mkdir / rename / remove;改名到已存在 → conflict", async () => {
|
||||
const { mock, client } = setup();
|
||||
await client.mkdir("/s1/a/b");
|
||||
expect(mock.exists("/s1/a/b")).toBe(true);
|
||||
mock.put("/s1/a/x.txt", "1");
|
||||
mock.put("/s1/a/y.txt", "2");
|
||||
expect((await caught(client.rename("/s1/a/x.txt", "y.txt"))).kind).toBe("conflict");
|
||||
await client.rename("/s1/a/x.txt", "z.txt");
|
||||
expect(mock.exists("/s1/a/z.txt")).toBe(true);
|
||||
await client.removePath("/s1/a/z.txt");
|
||||
expect(mock.exists("/s1/a/z.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("复制:轮询任务到完成(不是固定 sleep),成功 = 任务成功 + 目标存在", async () => {
|
||||
const { mock, client } = setup({ token: "t", taskPolls: 3 });
|
||||
mock.put("/s1/src/a.mkv", bytes(1000));
|
||||
const o = await client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv");
|
||||
expect(o.size).toBe(1000);
|
||||
expect(mock.exists("/s1/src/a.mkv")).toBe(true);
|
||||
const polls = mock.requests.filter((r) => r.path.startsWith("/api/task/copy/info"));
|
||||
expect(polls.length).toBe(4);
|
||||
});
|
||||
|
||||
it("任务失败:拿到任务的错误信息", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/src/a.mkv", "x");
|
||||
mock.failNextTask("upstream quota exceeded");
|
||||
const e = await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"));
|
||||
expect(e.kind).toBe("task_failed");
|
||||
expect(e.message).toContain("upstream quota exceeded");
|
||||
});
|
||||
|
||||
it("任务超时可配", async () => {
|
||||
let now = 0;
|
||||
const { mock, client } = setup(
|
||||
{ token: "t", taskPolls: 1000 },
|
||||
{
|
||||
now: () => now,
|
||||
sleep: async (ms) => {
|
||||
now += ms;
|
||||
},
|
||||
taskTimeoutMs: 5000,
|
||||
},
|
||||
);
|
||||
mock.put("/s1/src/a.mkv", "x");
|
||||
expect((await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"))).kind).toBe("timeout");
|
||||
});
|
||||
|
||||
it("老版本没有任务接口:改为等目标出现", async () => {
|
||||
const { mock, client } = setup({ token: "t", noTaskApi: true, noTaskIds: true, taskPolls: 2 });
|
||||
mock.put("/s1/src/a.mkv", bytes(10));
|
||||
const o = await client.copyFile("/s1/src/a.mkv", "/s1/dst/a.mkv");
|
||||
expect(o.size).toBe(10);
|
||||
});
|
||||
|
||||
it("目标已存在:冲突,不覆盖", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/src/a.mkv", "new");
|
||||
mock.put("/s2/dst/a.mkv", "old");
|
||||
expect((await caught(client.copyFile("/s1/src/a.mkv", "/s2/dst/a.mkv"))).kind).toBe("conflict");
|
||||
expect(new TextDecoder().decode(mock.read("/s2/dst/a.mkv")!)).toBe("old");
|
||||
});
|
||||
|
||||
it("改名复制走暂存目录:目标目录里已有与源同名的文件也不受影响,暂存清理干净", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/src/raw.mkv", "new");
|
||||
mock.put("/s1/lib/raw.mkv", "unrelated");
|
||||
await client.copyFile("/s1/src/raw.mkv", "/s1/lib/Movie (2020).mkv");
|
||||
expect(new TextDecoder().decode(mock.read("/s1/lib/Movie (2020).mkv")!)).toBe("new");
|
||||
expect(new TextDecoder().decode(mock.read("/s1/lib/raw.mkv")!)).toBe("unrelated");
|
||||
expect(mock.listNames("/s1/lib").some((n) => n.startsWith(".umt-stage-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("改名复制失败:清理暂存", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/src/raw.mkv", "new");
|
||||
mock.failNextTask("disk full");
|
||||
await caught(client.copyFile("/s1/src/raw.mkv", "/s2/lib/Movie.mkv"));
|
||||
expect(mock.listNames("/s2/lib")).toEqual([]);
|
||||
});
|
||||
|
||||
it("移动:同目录只改名;同驱动立即完成;跨驱动等任务;改名移动经暂存", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/a/x.mkv", "1");
|
||||
await client.moveFile("/s1/a/x.mkv", "/s1/a/y.mkv");
|
||||
expect(mock.exists("/s1/a/y.mkv")).toBe(true);
|
||||
await client.moveFile("/s1/a/y.mkv", "/s1/b/y.mkv");
|
||||
expect(mock.exists("/s1/b/y.mkv") && !mock.exists("/s1/a/y.mkv")).toBe(true);
|
||||
await client.moveFile("/s1/b/y.mkv", "/s2/c/Final.mkv");
|
||||
expect(mock.exists("/s2/c/Final.mkv")).toBe(true);
|
||||
expect(mock.exists("/s1/b/y.mkv")).toBe(false);
|
||||
expect(mock.listNames("/s2/c")).toEqual(["Final.mkv"]);
|
||||
});
|
||||
|
||||
it("移动失败:源搬回原处", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/a/x.mkv", "1");
|
||||
mock.failNextTask("network");
|
||||
await caught(client.moveFile("/s1/a/x.mkv", "/s2/c/Final.mkv"));
|
||||
expect(mock.exists("/s1/a/x.mkv")).toBe(true);
|
||||
expect(mock.listNames("/s2/c")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("上传", () => {
|
||||
it("三种哈希一遍读出,作秒传头;先传临时名再改名;进度回调", async () => {
|
||||
const { mock, client } = setup();
|
||||
const f = join(dir, "a.mkv");
|
||||
const data = bytes(3 * 1024 * 1024 + 5);
|
||||
await writeFile(f, data);
|
||||
const progress: number[] = [];
|
||||
const r = await client.upload(f, "/s1/lib/A (2020)/a.mkv", { onProgress: (d) => progress.push(d) });
|
||||
expect(r.obj.size).toBe(data.length);
|
||||
expect(progress.at(-1)).toBe(data.length);
|
||||
expect(progress.length).toBeGreaterThan(2);
|
||||
const put = mock.requests.find((r) => r.path === "/api/fs/put")!;
|
||||
expect(put.headers["x-file-sha256"]).toBe(r.hashes.sha256);
|
||||
expect(put.headers["x-file-md5"]).toBe(r.hashes.md5);
|
||||
expect(put.headers["x-file-sha1"]).toBe(r.hashes.sha1);
|
||||
expect(put.headers["file-path"]).toBe(`/s1/lib/A%20(2020)/a.mkv${PARTIAL_SUFFIX}`);
|
||||
expect(put.headers["as-task"]).toBe("false");
|
||||
expect(put.headers["content-length"]).toBe(String(data.length));
|
||||
expect(mock.listNames("/s1/lib/A (2020)")).toEqual(["a.mkv"]);
|
||||
expect(await hashLocalFile(f)).toEqual(r.hashes);
|
||||
});
|
||||
|
||||
it("目标已存在:冲突,不靠 put 覆盖", async () => {
|
||||
const { mock, client } = setup();
|
||||
const f = join(dir, "a.mkv");
|
||||
await writeFile(f, "new");
|
||||
mock.put("/s1/lib/a.mkv", "old");
|
||||
expect((await caught(client.upload(f, "/s1/lib/a.mkv"))).kind).toBe("conflict");
|
||||
expect(new TextDecoder().decode(mock.read("/s1/lib/a.mkv")!)).toBe("old");
|
||||
expect(mock.requests.some((r) => r.path === "/api/fs/put")).toBe(false);
|
||||
});
|
||||
|
||||
it("中断:退避重传;次数用尽时失败且远端不留临时文件", async () => {
|
||||
const { mock, client } = setup({ token: "t" }, { retries: 1 });
|
||||
const f = join(dir, "a.mkv");
|
||||
await writeFile(f, bytes(2 * 1024 * 1024));
|
||||
mock.breakUploadAfter(1024 * 1024);
|
||||
const ok = await client.upload(f, "/s1/lib/a.mkv");
|
||||
expect(ok.obj.size).toBe(2 * 1024 * 1024);
|
||||
expect(client.stats.retries).toBe(1);
|
||||
|
||||
const { mock: m2, client: c2 } = setup({ token: "t" }, { retries: 0 });
|
||||
m2.breakUploadAfter(1024 * 1024);
|
||||
const e = await caught(c2.upload(f, "/s1/lib/a.mkv"));
|
||||
expect(e.kind).toBe("unavailable");
|
||||
expect(m2.exists("/s1/lib/a.mkv")).toBe(false);
|
||||
expect(m2.exists(`/s1/lib/a.mkv${PARTIAL_SUFFIX}`)).toBe(false);
|
||||
});
|
||||
|
||||
it("接口成功但目标取不到:失败(调用方据此不删源)", async () => {
|
||||
let now = 0;
|
||||
const { mock, client } = setup(
|
||||
{ token: "t" },
|
||||
{
|
||||
now: () => now,
|
||||
sleep: async (ms) => {
|
||||
now += ms;
|
||||
},
|
||||
},
|
||||
);
|
||||
const f = join(dir, "a.mkv");
|
||||
await writeFile(f, "x");
|
||||
mock.dropNextUpload();
|
||||
expect((await caught(client.upload(f, "/s1/lib/a.mkv"))).kind).toBe("not_found");
|
||||
});
|
||||
|
||||
it("As-Task:等上传任务完成", async () => {
|
||||
const { mock, client } = setup({ token: "t", taskPolls: 2 }, { uploadAsTask: true });
|
||||
const f = join(dir, "a.mkv");
|
||||
await writeFile(f, "hello");
|
||||
const r = await client.upload(f, "/s1/lib/a.mkv");
|
||||
expect(r.obj.size).toBe(5);
|
||||
expect(mock.requests.find((x) => x.path === "/api/fs/put")!.headers["as-task"]).toBe("true");
|
||||
expect(mock.requests.filter((x) => x.path.startsWith("/api/task/upload/info")).length).toBe(3);
|
||||
});
|
||||
|
||||
it("空闲超时:服务端不读数据时中止", async () => {
|
||||
const mock = createMockAlist({ token: "t" });
|
||||
const stuck: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(String(input), init);
|
||||
if (new URL(req.url).pathname === "/api/fs/put")
|
||||
return new Promise<Response>((_, reject) =>
|
||||
init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)),
|
||||
);
|
||||
return mock.fetch(input, init);
|
||||
}) as typeof fetch;
|
||||
const client = new AlistClient({
|
||||
baseUrl: mock.base,
|
||||
auth: { kind: "token", token: "t" },
|
||||
fetch: stuck,
|
||||
qps: 0,
|
||||
idleTimeoutMs: 50,
|
||||
retries: 0,
|
||||
});
|
||||
const f = join(dir, "a.mkv");
|
||||
await writeFile(f, "x");
|
||||
expect((await caught(client.upload(f, "/s1/a.mkv"))).kind).toBe("timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("下载", () => {
|
||||
it("写临时文件成功后改名;带进度;sha256 记事实", async () => {
|
||||
const { mock, client } = setup();
|
||||
const data = bytes(1_500_000);
|
||||
mock.put("/s1/a.mkv", data);
|
||||
const out = join(dir, "a.mkv");
|
||||
const seen: number[] = [];
|
||||
const r = await client.download("/s1/a.mkv", out, { onProgress: (d) => seen.push(d) });
|
||||
expect(r.bytes).toBe(data.length);
|
||||
expect(seen.at(-1)).toBe(data.length);
|
||||
expect(Buffer.compare(await readFile(out), data)).toBe(0);
|
||||
await expect(stat(`${out}${PARTIAL_SUFFIX}`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("中断后 Range 续传", async () => {
|
||||
const { mock, client } = setup({ token: "t" }, { retries: 2 });
|
||||
const data = bytes(1_000_000);
|
||||
mock.put("/s1/a.mkv", data);
|
||||
mock.breakDownloadAfter(400_000);
|
||||
const out = join(dir, "a.mkv");
|
||||
await client.download("/s1/a.mkv", out);
|
||||
expect(Buffer.compare(await readFile(out), data)).toBe(0);
|
||||
expect(client.stats.retries).toBe(1);
|
||||
});
|
||||
|
||||
it("中断且次数用尽:不改名,保留 partial;下次续传完成", async () => {
|
||||
const { mock, client } = setup({ token: "t" }, { retries: 0 });
|
||||
const data = bytes(800_000);
|
||||
mock.put("/s1/a.mkv", data);
|
||||
mock.breakDownloadAfter(300_000);
|
||||
const out = join(dir, "a.mkv");
|
||||
expect((await caught(client.download("/s1/a.mkv", out))).kind).toBe("unavailable");
|
||||
await expect(stat(out)).rejects.toThrow();
|
||||
expect((await stat(`${out}${PARTIAL_SUFFIX}`)).size).toBe(300_000);
|
||||
await client.download("/s1/a.mkv", out);
|
||||
expect(Buffer.compare(await readFile(out), data)).toBe(0);
|
||||
});
|
||||
|
||||
it("直链过期(401):重新 get 再续", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/a.mkv", bytes(1000));
|
||||
const realFetch = mock.fetch;
|
||||
let expired = false;
|
||||
const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => {
|
||||
const u = String(input instanceof Request ? input.url : input);
|
||||
if (u.includes("/d/") && !expired) {
|
||||
expired = true;
|
||||
mock.expireSigns();
|
||||
}
|
||||
return realFetch(input, init);
|
||||
}) as typeof fetch;
|
||||
const c2 = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "perm-token" }, fetch: f, qps: 0 });
|
||||
await c2.download("/s1/a.mkv", join(dir, "a.mkv"));
|
||||
expect((await stat(join(dir, "a.mkv"))).size).toBe(1000);
|
||||
void client;
|
||||
});
|
||||
|
||||
it("本地目标已存在:冲突", async () => {
|
||||
const { mock, client } = setup();
|
||||
mock.put("/s1/a.mkv", "x");
|
||||
await writeFile(join(dir, "a.mkv"), "local");
|
||||
expect((await caught(client.download("/s1/a.mkv", join(dir, "a.mkv")))).kind).toBe("conflict");
|
||||
});
|
||||
|
||||
it("第三方直链不带令牌", async () => {
|
||||
const mock = createMockAlist({ token: "t" });
|
||||
mock.put("/s1/a.mkv", "x");
|
||||
const calls: { url: string; auth: string | null }[] = [];
|
||||
const f: typeof fetch = (async (input: Request | URL | string, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(String(input), init);
|
||||
calls.push({ url: req.url, auth: req.headers.get("authorization") });
|
||||
if (req.url.startsWith("https://cdn.example")) return new Response("x");
|
||||
const res = await mock.fetch(req);
|
||||
if (new URL(req.url).pathname === "/api/fs/get") {
|
||||
const j = await res.json();
|
||||
j.data.raw_url = "https://cdn.example/file?sign=zzz";
|
||||
return Response.json(j);
|
||||
}
|
||||
return res;
|
||||
}) as typeof fetch;
|
||||
const c = new AlistClient({ baseUrl: mock.base, auth: { kind: "token", token: "t" }, fetch: f, qps: 0 });
|
||||
await c.download("/s1/a.mkv", join(dir, "a.mkv"));
|
||||
expect(calls.find((x) => x.url.startsWith("https://cdn.example"))!.auth).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("限速与脱敏", () => {
|
||||
it("令牌桶:QPS 限制按时钟放行", async () => {
|
||||
let now = 0;
|
||||
const waits: number[] = [];
|
||||
const l = new AlistLimiter(
|
||||
2,
|
||||
0,
|
||||
() => now,
|
||||
async (ms) => {
|
||||
waits.push(ms);
|
||||
now += ms;
|
||||
},
|
||||
);
|
||||
for (let i = 0; i < 5; i++) await l.run(async () => i);
|
||||
expect(waits.length).toBe(3);
|
||||
expect(now).toBeGreaterThanOrEqual(1500);
|
||||
});
|
||||
|
||||
it("并发上限", async () => {
|
||||
const l = new AlistLimiter(0, 2);
|
||||
let max = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: 6 }, () =>
|
||||
l.run(async () => {
|
||||
max = Math.max(max, l.inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(max).toBe(2);
|
||||
});
|
||||
|
||||
it("直链 sign、令牌、路径密码不进日志", () => {
|
||||
expect(redactAlistUrl("http://u:p@h/d/a.mkv?sign=SECRET123:0")).not.toContain("SECRET123");
|
||||
const t = redactAlistText("GET http://h/d/a.mkv?sign=abcdef failed token=perm-token-xyz", ["perm-token-xyz"]);
|
||||
expect(t).not.toContain("abcdef");
|
||||
expect(t).not.toContain("perm-token-xyz");
|
||||
});
|
||||
|
||||
it("日志记录里没有令牌", async () => {
|
||||
const lines: string[] = [];
|
||||
const logger = { warn: (o: Record<string, unknown>, m: string) => void lines.push(JSON.stringify(o) + m) };
|
||||
const { mock, client } = setup({ username: "u", password: "secretpw" }, { logger });
|
||||
await client.me();
|
||||
mock.invalidateSessions();
|
||||
await client.me();
|
||||
expect(lines.length).toBeGreaterThan(0);
|
||||
expect(lines.join("\n")).not.toMatch(/secretpw|sess-/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,957 @@
|
||||
// Alist / OpenList v3 接口客户端(docs/design/storage-alist.md)。对照 MP v3 `storages/alist.py`,只借行为。
|
||||
// 与 MP 的差异(§9):错误与「不存在」分开、401 自动重登、异步任务轮询到完成、同名冲突检查 + 暂存目录改名、
|
||||
// 上传先传临时名、下载断点续传 + 直链过期重取、refresh 节制 + 每实例限速。
|
||||
// 网盘方向的成功判定与 MP 一致:接口成功 + 目标存在,不比对大小 / 哈希(用户决定)。
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { link, open, rm, stat, unlink } from "node:fs/promises";
|
||||
import { posix } from "node:path";
|
||||
import { AlistError, classifyAlist, isAlistNotFound } from "./errors.ts";
|
||||
import { AlistLimiter } from "./limiter.ts";
|
||||
|
||||
export type AlistAuth =
|
||||
| { kind: "token"; token: string }
|
||||
| { kind: "password"; username: string; password: string }
|
||||
| { kind: "guest" };
|
||||
|
||||
export interface AlistLogger {
|
||||
debug?(obj: Record<string, unknown>, msg: string): void;
|
||||
warn?(obj: Record<string, unknown>, msg: string): void;
|
||||
}
|
||||
|
||||
export interface AlistClientOptions {
|
||||
baseUrl: string;
|
||||
auth: AlistAuth;
|
||||
/** 路径密码(Alist「元信息」里给目录设的密码),list / get 时带上 */
|
||||
pathPassword?: string | null;
|
||||
fetch?: typeof fetch;
|
||||
/** 普通请求超时(ms),缺省 30 s */
|
||||
timeoutMs?: number;
|
||||
/** 数据流无进度空闲超时(ms),缺省 120 s */
|
||||
idleTimeoutMs?: number;
|
||||
/** 可重试错误的重试次数,缺省 3 */
|
||||
retries?: number;
|
||||
/** 每秒请求数上限,缺省 5;0 不限 */
|
||||
qps?: number;
|
||||
/** 并发上限,缺省 4;0 不限 */
|
||||
concurrency?: number;
|
||||
/** 异步任务等待上限(ms),缺省 6 h */
|
||||
taskTimeoutMs?: number;
|
||||
/** 上传用 As-Task(缺省 false,同 MP) */
|
||||
uploadAsTask?: boolean;
|
||||
/** 退避起点(ms),缺省 500 */
|
||||
backoffMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
logger?: AlistLogger;
|
||||
}
|
||||
|
||||
export interface AlistObj {
|
||||
/** Alist 内绝对路径(无尾斜杠) */
|
||||
path: string;
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size: number;
|
||||
/** 修改时间(ms) */
|
||||
modified: number;
|
||||
created: number;
|
||||
sign: string;
|
||||
thumb: string;
|
||||
hashInfo: Record<string, string> | null;
|
||||
rawUrl?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface AlistHashes {
|
||||
md5: string;
|
||||
sha1: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface AlistTask {
|
||||
id: string;
|
||||
name: string;
|
||||
state: number;
|
||||
status: string;
|
||||
progress: number;
|
||||
error: string;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface AlistUsage {
|
||||
total: number;
|
||||
free: number;
|
||||
used: number;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (done: number, total: number) => void;
|
||||
export type TaskKind = "copy" | "move" | "upload";
|
||||
|
||||
/** 任务状态:0 待执行 1 执行中 2 成功 3 取消中 4 已取消 5 出错 6 失败中 7 失败 8 等待重试 9 重试前 */
|
||||
export const TASK_SUCCEEDED = 2;
|
||||
const TASK_CANCELED = new Set([3, 4]);
|
||||
const TASK_FAILED = new Set([5, 7]);
|
||||
|
||||
/** MP:per_page<=0 会退回后端默认 200,显式指定最大页大小 */
|
||||
export const ALIST_PAGE_SIZE = 500;
|
||||
/** MP:账号密码令牌缓存 2 天、提前 5 分钟更新 */
|
||||
export const ALIST_TOKEN_TTL_MS = 2 * 86_400_000 - 5 * 60_000;
|
||||
export const PARTIAL_SUFFIX = ".uhd-partial";
|
||||
const STAGE_PREFIX = ".umt-stage-";
|
||||
|
||||
/** 规范化 Alist 路径:以 / 开头、无尾斜杠、无空段 */
|
||||
export function normAlistPath(p: string): string {
|
||||
const segs = p.split("/").filter((s) => s.length > 0 && s !== ".");
|
||||
if (segs.some((s) => s === "..")) throw new AlistError("error", `路径不能含 ..: ${p}`);
|
||||
return `/${segs.join("/")}`;
|
||||
}
|
||||
|
||||
/** 按段 URL 编码(保留 /),File-Path 头与 /d 下载链接用 */
|
||||
export const encodeAlistPath = (p: string) => normAlistPath(p).split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const parseTime = (v: unknown): number => {
|
||||
const t = typeof v === "string" ? Date.parse(v) : Number.NaN;
|
||||
return Number.isFinite(t) ? t : 0;
|
||||
};
|
||||
|
||||
function parseHashInfo(v: unknown): Record<string, string> | null {
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, val] of Object.entries(v as Record<string, unknown>)) if (typeof val === "string" && val) out[k] = val;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
function toObj(path: string, d: Record<string, unknown>): AlistObj {
|
||||
return {
|
||||
path: normAlistPath(path),
|
||||
name: String(d.name ?? posix.basename(path)),
|
||||
isDir: d.is_dir === true,
|
||||
size: Number(d.size ?? 0) || 0,
|
||||
modified: parseTime(d.modified),
|
||||
created: parseTime(d.created),
|
||||
sign: String(d.sign ?? ""),
|
||||
thumb: String(d.thumb ?? ""),
|
||||
hashInfo: parseHashInfo(d.hash_info),
|
||||
...(typeof d.raw_url === "string" && d.raw_url ? { rawUrl: d.raw_url } : {}),
|
||||
...(typeof d.provider === "string" ? { provider: d.provider } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toTask(d: Record<string, unknown>): AlistTask {
|
||||
return {
|
||||
id: String(d.id ?? ""),
|
||||
name: String(d.name ?? ""),
|
||||
state: Number(d.state ?? 0),
|
||||
status: String(d.status ?? ""),
|
||||
progress: Number(d.progress ?? 0),
|
||||
error: String(d.error ?? ""),
|
||||
totalBytes: Number(d.total_bytes ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/** 一遍读出 md5 / sha1 / sha256(MP 秒传头;§9-5) */
|
||||
export async function hashLocalFile(
|
||||
path: string,
|
||||
opts: { signal?: AbortSignal; onProgress?: ProgressCallback } = {},
|
||||
): Promise<AlistHashes> {
|
||||
const md5 = createHash("md5");
|
||||
const sha1 = createHash("sha1");
|
||||
const sha256 = createHash("sha256");
|
||||
const total = (await stat(path)).size;
|
||||
let done = 0;
|
||||
for await (const chunk of createReadStream(path, { highWaterMark: 1024 * 1024 })) {
|
||||
opts.signal?.throwIfAborted();
|
||||
const b = chunk as Uint8Array;
|
||||
md5.update(b);
|
||||
sha1.update(b);
|
||||
sha256.update(b);
|
||||
done += b.length;
|
||||
opts.onProgress?.(done, total);
|
||||
}
|
||||
return { md5: md5.digest("hex"), sha1: sha1.digest("hex"), sha256: sha256.digest("hex") };
|
||||
}
|
||||
|
||||
const abortedError = () => new AlistError("aborted", "操作已取消");
|
||||
|
||||
/** 空闲计时器:每次前进 touch(),超过 ms 没前进就 abort */
|
||||
function idleWatch(ms: number, outer?: AbortSignal) {
|
||||
const ctl = new AbortController();
|
||||
let timedOut = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const arm = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
ctl.abort(new AlistError("timeout", `数据流 ${Math.round(ms / 1000)} 秒没有进展`));
|
||||
}, ms);
|
||||
};
|
||||
const onOuter = () => ctl.abort(abortedError());
|
||||
outer?.addEventListener("abort", onOuter, { once: true });
|
||||
arm();
|
||||
return {
|
||||
signal: ctl.signal,
|
||||
touch: arm,
|
||||
get timedOut() {
|
||||
return timedOut;
|
||||
},
|
||||
done() {
|
||||
clearTimeout(timer);
|
||||
outer?.removeEventListener("abort", onOuter);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class AlistClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly limiter: AlistLimiter;
|
||||
private readonly o: Required<
|
||||
Pick<
|
||||
AlistClientOptions,
|
||||
"timeoutMs" | "idleTimeoutMs" | "retries" | "taskTimeoutMs" | "uploadAsTask" | "backoffMs" | "sleep" | "now"
|
||||
>
|
||||
>;
|
||||
private tokenCache: { token: string; exp: number } | null = null;
|
||||
private loginInflight: Promise<string> | null = null;
|
||||
/** 观测用:每个 API 请求(路径 + 是否 refresh) */
|
||||
readonly stats = { requests: 0, refreshes: 0, relogins: 0, retries: 0 };
|
||||
|
||||
constructor(private readonly opts: AlistClientOptions) {
|
||||
this.baseUrl = opts.baseUrl.trim().replace(/\/+$/, "");
|
||||
if (!/^https?:\/\//i.test(this.baseUrl)) throw new AlistError("error", "Alist 地址必须以 http:// 或 https:// 开头");
|
||||
this.fetchFn = opts.fetch ?? fetch;
|
||||
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||
const now = opts.now ?? (() => Date.now());
|
||||
this.o = {
|
||||
timeoutMs: opts.timeoutMs ?? 30_000,
|
||||
idleTimeoutMs: opts.idleTimeoutMs ?? 120_000,
|
||||
retries: opts.retries ?? 3,
|
||||
taskTimeoutMs: opts.taskTimeoutMs ?? 6 * 3600_000,
|
||||
uploadAsTask: opts.uploadAsTask ?? false,
|
||||
backoffMs: opts.backoffMs ?? 500,
|
||||
sleep,
|
||||
now,
|
||||
};
|
||||
this.limiter = new AlistLimiter(opts.qps ?? 5, opts.concurrency ?? 4, now, sleep);
|
||||
}
|
||||
|
||||
// ── 令牌(§2.2、§9-2)──
|
||||
|
||||
private async login(): Promise<string> {
|
||||
const a = this.opts.auth;
|
||||
if (a.kind !== "password") throw new AlistError("auth", "没有账号密码,无法登录");
|
||||
const data = await this.request<{ token?: string }>("POST", "/api/auth/login", {
|
||||
body: { username: a.username, password: a.password },
|
||||
auth: false,
|
||||
});
|
||||
if (!data?.token) throw new AlistError("auth", "登录成功但没有返回令牌");
|
||||
this.tokenCache = { token: data.token, exp: this.o.now() + ALIST_TOKEN_TTL_MS };
|
||||
this.opts.logger?.debug?.({ base: this.baseUrl }, "Alist 登录换取令牌成功");
|
||||
return data.token;
|
||||
}
|
||||
|
||||
private async token(): Promise<string | null> {
|
||||
const a = this.opts.auth;
|
||||
if (a.kind === "token") return a.token;
|
||||
if (a.kind === "guest") return null;
|
||||
if (this.tokenCache && this.tokenCache.exp > this.o.now()) return this.tokenCache.token;
|
||||
this.loginInflight ??= this.login().finally(() => {
|
||||
this.loginInflight = null;
|
||||
});
|
||||
return this.loginInflight;
|
||||
}
|
||||
|
||||
/** 配置变更时调用方重建客户端;这里只供测试 / 运维清缓存 */
|
||||
invalidateToken(): void {
|
||||
this.tokenCache = null;
|
||||
}
|
||||
|
||||
// ── 请求 ──
|
||||
|
||||
private backoff(attempt: number, err: AlistError): number {
|
||||
const base = err.kind === "rate_limited" ? Math.max(this.o.backoffMs, 5000) : this.o.backoffMs;
|
||||
return Math.min(base * 2 ** attempt, 60_000);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: "GET" | "POST",
|
||||
apiPath: string,
|
||||
opts: { body?: unknown; auth?: boolean; signal?: AbortSignal; path?: string } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (opts.auth !== false) {
|
||||
const t = await this.token();
|
||||
if (t) headers.Authorization = t;
|
||||
}
|
||||
const signal = opts.signal
|
||||
? AbortSignal.any([opts.signal, AbortSignal.timeout(this.o.timeoutMs)])
|
||||
: AbortSignal.timeout(this.o.timeoutMs);
|
||||
let res: Response;
|
||||
this.stats.requests++;
|
||||
try {
|
||||
res = await this.fetchFn(`${this.baseUrl}${apiPath}`, {
|
||||
method,
|
||||
headers,
|
||||
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
||||
signal,
|
||||
});
|
||||
} catch (e) {
|
||||
if (opts.signal?.aborted) throw abortedError();
|
||||
if ((e as Error)?.name === "TimeoutError")
|
||||
throw new AlistError("timeout", `请求超时:${apiPath}`, opts.path ? { path: opts.path } : {});
|
||||
throw new AlistError(
|
||||
"unavailable",
|
||||
`连不上 Alist:${(e as Error)?.message ?? e}`,
|
||||
opts.path ? { path: opts.path } : {},
|
||||
);
|
||||
}
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: { code?: number; message?: string; data?: unknown } | null = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
if (!res.ok || !json || typeof json.code !== "number") {
|
||||
throw classifyAlist(res.status, json?.code ?? null, json?.message ?? text.slice(0, 200), opts.path);
|
||||
}
|
||||
if (json.code !== 200) throw classifyAlist(res.status, json.code, json.message ?? "", opts.path);
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
/** 带限速、401 重登一次、可重试错误退避的 API 调用 */
|
||||
private async api<T>(
|
||||
apiPath: string,
|
||||
body: unknown,
|
||||
opts: { retry?: boolean; signal?: AbortSignal; path?: string; method?: "GET" | "POST" } = {},
|
||||
): Promise<T> {
|
||||
let attempt = 0;
|
||||
let relogged = false;
|
||||
for (;;) {
|
||||
try {
|
||||
return await this.limiter.run(() =>
|
||||
this.request<T>(opts.method ?? "POST", apiPath, {
|
||||
body,
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
...(opts.path ? { path: opts.path } : {}),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
const err = e instanceof AlistError ? e : new AlistError("error", String(e));
|
||||
if (err.kind === "auth" && this.opts.auth.kind === "password" && !relogged) {
|
||||
relogged = true;
|
||||
this.stats.relogins++;
|
||||
this.tokenCache = null;
|
||||
this.opts.logger?.warn?.({ base: this.baseUrl, api: apiPath }, "Alist 令牌失效,重新登录");
|
||||
continue;
|
||||
}
|
||||
if (opts.retry !== false && err.retryable && attempt < this.o.retries && !opts.signal?.aborted) {
|
||||
this.stats.retries++;
|
||||
await this.o.sleep(this.backoff(attempt, err));
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 基本信息 ──
|
||||
|
||||
async me(): Promise<{ username: string; basePath: string; role: number; permission: number }> {
|
||||
const d = await this.api<Record<string, unknown>>("/api/me", undefined, { method: "GET" });
|
||||
return {
|
||||
username: String(d.username ?? ""),
|
||||
basePath: String(d.base_path ?? "/"),
|
||||
role: Number(d.role ?? 0),
|
||||
permission: Number(d.permission ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async version(): Promise<string | null> {
|
||||
try {
|
||||
const d = await this.limiter.run(() =>
|
||||
this.request<Record<string, unknown>>("GET", "/api/public/settings", { auth: false }),
|
||||
);
|
||||
return typeof d?.version === "string" ? d.version : null;
|
||||
} catch (e) {
|
||||
if (e instanceof AlistError && (e.kind === "unavailable" || e.kind === "timeout")) throw e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 读 ──
|
||||
|
||||
/** 列目录(自动翻页;refresh 只在用户刷新 / 写后确认时传,§9-8) */
|
||||
async list(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<AlistObj[]> {
|
||||
const dir = normAlistPath(path);
|
||||
const out: AlistObj[] = [];
|
||||
if (opts.refresh) this.stats.refreshes++;
|
||||
for (let page = 1; ; page++) {
|
||||
const d = await this.api<Record<string, unknown>>(
|
||||
"/api/fs/list",
|
||||
{
|
||||
path: dir,
|
||||
password: this.opts.pathPassword ?? "",
|
||||
page,
|
||||
per_page: ALIST_PAGE_SIZE,
|
||||
refresh: page === 1 && opts.refresh === true,
|
||||
},
|
||||
{ path: dir, ...(opts.signal ? { signal: opts.signal } : {}) },
|
||||
);
|
||||
const content = (d?.content as Record<string, unknown>[] | null) ?? [];
|
||||
for (const it of content) out.push(toObj(posix.join(dir, String(it.name ?? "")), it));
|
||||
const total = Number(d?.filtered_total ?? d?.total ?? 0) || 0;
|
||||
const pagesTotal = Number(d?.pages_total ?? 0) || 0;
|
||||
if (content.length === 0 || out.length >= total) return out;
|
||||
if (d?.has_more === false || (pagesTotal && page >= pagesTotal)) return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 取单个对象;不存在抛 AlistError(not_found),其它失败抛对应类型(§9-1) */
|
||||
async get(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<AlistObj> {
|
||||
const p = normAlistPath(path);
|
||||
if (opts.refresh) this.stats.refreshes++;
|
||||
const d = await this.api<Record<string, unknown>>(
|
||||
"/api/fs/get",
|
||||
{ path: p, password: this.opts.pathPassword ?? "", page: 1, per_page: 0, refresh: opts.refresh === true },
|
||||
{ path: p, ...(opts.signal ? { signal: opts.signal } : {}) },
|
||||
);
|
||||
return toObj(p, d ?? {});
|
||||
}
|
||||
|
||||
/** 不存在 → null;其它失败照抛 */
|
||||
async stat(path: string, opts: { refresh?: boolean; signal?: AbortSignal } = {}): Promise<AlistObj | null> {
|
||||
try {
|
||||
return await this.get(path, opts);
|
||||
} catch (e) {
|
||||
if (isAlistNotFound(e)) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** 下载直链:raw_url 优先,否则 /d<path>?sign=(同 MP) */
|
||||
async rawUrl(path: string, obj?: AlistObj): Promise<string> {
|
||||
const o = obj ?? (await this.get(path));
|
||||
if (o.isDir) throw new AlistError("error", `是目录,不能下载:${o.path}`);
|
||||
if (o.rawUrl) return o.rawUrl;
|
||||
return `${this.baseUrl}/d${encodeAlistPath(o.path)}${o.sign ? `?sign=${encodeURIComponent(o.sign)}` : ""}`;
|
||||
}
|
||||
|
||||
/** 直链与 Alist 同源时才带令牌(第三方 CDN 直链不能带令牌,MP 会带) */
|
||||
private async downloadHeaders(url: string, extra: Record<string, string> = {}): Promise<Record<string, string>> {
|
||||
const h = { ...extra };
|
||||
if (new URL(url).origin === new URL(this.baseUrl).origin) {
|
||||
const t = await this.token();
|
||||
if (t) h.Authorization = t;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 打开下载流(文件管理下载代理用;支持 Range 透传) */
|
||||
async openRead(path: string, opts: { range?: string; signal?: AbortSignal } = {}): Promise<Response> {
|
||||
const obj = await this.get(path);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const url = await this.rawUrl(path, i === 0 ? obj : undefined);
|
||||
const res = await this.limiter.run(
|
||||
async () =>
|
||||
this.fetchFn(url, {
|
||||
headers: await this.downloadHeaders(url, opts.range ? { Range: opts.range } : {}),
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
}),
|
||||
{ data: true },
|
||||
);
|
||||
if ([401, 403, 410].includes(res.status)) {
|
||||
await res.body?.cancel();
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw classifyAlist(res.status, null, `下载失败 HTTP ${res.status}`, obj.path);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
throw new AlistError("auth", "下载直链多次被拒绝(签名过期或无权限)", { path: obj.path });
|
||||
}
|
||||
|
||||
/** 读小文件到内存(NFO 比对等);超过 maxBytes 抛错 */
|
||||
async readFile(path: string, maxBytes = 4 * 1024 * 1024): Promise<Uint8Array> {
|
||||
const res = await this.openRead(path);
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
if (buf.length > maxBytes) throw new AlistError("error", `文件过大(${buf.length} 字节)`, { path });
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** 容量:/api/admin/storage/list 的 mount_details;拿不到返回 null,不报错(§9-10) */
|
||||
async usage(path = "/"): Promise<AlistUsage | null> {
|
||||
try {
|
||||
const d = await this.api<{ content?: Record<string, unknown>[] }>("/api/admin/storage/list", undefined, {
|
||||
method: "GET",
|
||||
retry: false,
|
||||
});
|
||||
const p = normAlistPath(path);
|
||||
const hit = (d?.content ?? [])
|
||||
.filter((s) => {
|
||||
const m = normAlistPath(String(s.mount_path ?? "/"));
|
||||
return m === "/" || p === m || p.startsWith(`${m}/`);
|
||||
})
|
||||
.sort((a, b) => String(b.mount_path).length - String(a.mount_path).length)[0];
|
||||
const md = hit?.mount_details as Record<string, unknown> | undefined;
|
||||
const total = Number(md?.total_space ?? 0);
|
||||
if (!md || !(total > 0)) return null;
|
||||
const free = Number(md.free_space ?? 0);
|
||||
return { total, free, used: Number(md.used_space ?? total - free) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 写 ──
|
||||
|
||||
/** 建目录(Alist 递归建,已存在也成功) */
|
||||
async mkdir(path: string): Promise<void> {
|
||||
const p = normAlistPath(path);
|
||||
if (p === "/") return;
|
||||
await this.api("/api/fs/mkdir", { path: p }, { path: p });
|
||||
}
|
||||
|
||||
/** 改名(只改最后一段);目标已存在抛 conflict */
|
||||
async rename(path: string, newName: string): Promise<void> {
|
||||
if (!newName || newName.includes("/")) throw new AlistError("error", `非法文件名:${newName}`);
|
||||
const p = normAlistPath(path);
|
||||
await this.api("/api/fs/rename", { path: p, name: newName }, { path: p, retry: false });
|
||||
}
|
||||
|
||||
/** 删除(注意 Alist 删除不存在的名字也返回成功) */
|
||||
async remove(dir: string, names: string[]): Promise<void> {
|
||||
if (!names.length) return;
|
||||
const d = normAlistPath(dir);
|
||||
await this.api("/api/fs/remove", { dir: d, names }, { path: d, retry: false });
|
||||
}
|
||||
|
||||
async removePath(path: string): Promise<void> {
|
||||
const p = normAlistPath(path);
|
||||
if (p === "/") throw new AlistError("error", "不能删除根目录");
|
||||
await this.remove(posix.dirname(p), [posix.basename(p)]);
|
||||
}
|
||||
|
||||
/** 原始 copy / move:返回任务 id(同步完成时为空) */
|
||||
async rawTransfer(kind: "copy" | "move", srcDir: string, dstDir: string, names: string[]): Promise<string[]> {
|
||||
const d = await this.api<{ tasks?: Record<string, unknown>[] } | null>(
|
||||
`/api/fs/${kind}`,
|
||||
{ src_dir: normAlistPath(srcDir), dst_dir: normAlistPath(dstDir), names },
|
||||
{ path: srcDir, retry: false },
|
||||
);
|
||||
return (d?.tasks ?? []).map((t) => String(t.id ?? "")).filter(Boolean);
|
||||
}
|
||||
|
||||
/** 查任务;接口不支持(老版本)返回 null,任务已不在返回 undefined */
|
||||
async taskInfo(kind: TaskKind, id: string): Promise<AlistTask | null | undefined> {
|
||||
for (const prefix of ["/api/task", "/api/admin/task"]) {
|
||||
try {
|
||||
const d = await this.api<Record<string, unknown>>(
|
||||
`${prefix}/${kind}/info?tid=${encodeURIComponent(id)}`,
|
||||
undefined,
|
||||
{},
|
||||
);
|
||||
return toTask(d ?? {});
|
||||
} catch (e) {
|
||||
if (!(e instanceof AlistError)) throw e;
|
||||
// HTTP 404(路由不存在)→ 试下一个前缀;JSON code 404 task not found → 任务不在了
|
||||
if (e.detail.status === 404 && e.detail.code === undefined) continue;
|
||||
if (e.kind === "not_found") return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等异步任务完成(§9-3):指数退避轮询,成功返回 "succeeded";接口不支持 / 任务已不在返回 "unknown"
|
||||
* (调用方改为等目标出现);取消 / 失败抛 task_failed(带任务 error);超时抛 timeout。
|
||||
*/
|
||||
async waitTask(
|
||||
kind: TaskKind,
|
||||
id: string,
|
||||
opts: { timeoutMs?: number; signal?: AbortSignal; onProgress?: (t: AlistTask) => void } = {},
|
||||
): Promise<"succeeded" | "unknown"> {
|
||||
const deadline = this.o.now() + (opts.timeoutMs ?? this.o.taskTimeoutMs);
|
||||
let delay = 500;
|
||||
for (;;) {
|
||||
opts.signal?.throwIfAborted();
|
||||
const t = await this.taskInfo(kind, id);
|
||||
if (t === null || t === undefined) return "unknown";
|
||||
opts.onProgress?.(t);
|
||||
if (t.state === TASK_SUCCEEDED) return "succeeded";
|
||||
if (TASK_CANCELED.has(t.state)) throw new AlistError("task_failed", `Alist ${kind} 任务已取消:${t.name}`);
|
||||
if (TASK_FAILED.has(t.state))
|
||||
throw new AlistError("task_failed", `Alist ${kind} 任务失败:${t.error || t.status || t.name}`);
|
||||
if (this.o.now() >= deadline) throw new AlistError("timeout", `等待 Alist ${kind} 任务超时:${t.name}`);
|
||||
await this.o.sleep(delay);
|
||||
delay = Math.min(delay * 2, 10_000);
|
||||
}
|
||||
}
|
||||
|
||||
/** 写后等目标出现(refresh,退避);超时抛 not_found */
|
||||
async waitExists(path: string, opts: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise<AlistObj> {
|
||||
const deadline = this.o.now() + (opts.timeoutMs ?? 60_000);
|
||||
let delay = 500;
|
||||
const p = normAlistPath(path);
|
||||
for (;;) {
|
||||
opts.signal?.throwIfAborted();
|
||||
const o = await this.stat(p, { refresh: true });
|
||||
if (o) return o;
|
||||
// OpenList 的 get 不认 refresh:刷新父目录列表让缓存失效,再按列表结果判断
|
||||
const hit = (await this.list(posix.dirname(p), { refresh: true }).catch(() => [])).find(
|
||||
(x) => x.name === posix.basename(p),
|
||||
);
|
||||
if (hit) return hit;
|
||||
if (this.o.now() >= deadline) throw new AlistError("not_found", `写入后目标仍不存在:${p}`);
|
||||
await this.o.sleep(delay);
|
||||
delay = Math.min(delay * 2, 10_000);
|
||||
}
|
||||
}
|
||||
|
||||
private async finishTransfer(kind: "copy" | "move", ids: string[], target: string, signal?: AbortSignal) {
|
||||
let unknown = ids.length === 0;
|
||||
for (const id of ids) {
|
||||
if ((await this.waitTask(kind, id, signal ? { signal } : {})) === "unknown") unknown = true;
|
||||
}
|
||||
// 成功判定(同 MP、用户决定):任务成功 + 目标存在;没有任务信息时等目标出现
|
||||
return this.waitExists(target, {
|
||||
timeoutMs: unknown ? this.o.taskTimeoutMs : 60_000,
|
||||
...(signal ? { signal } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 同实例复制一个文件或目录到 dst(完整目标路径)。目标已存在抛 conflict(§9-4);
|
||||
* 名字不同或目标目录里已有同名源名时走暂存目录 `.umt-stage-*`:复制 → 改名 → 移入,失败清理暂存。
|
||||
*/
|
||||
async copyFile(src: string, dst: string, opts: { signal?: AbortSignal } = {}): Promise<AlistObj> {
|
||||
return this.transferFile("copy", src, dst, opts);
|
||||
}
|
||||
|
||||
/** 同实例移动(语义同 copyFile;同目录只改名) */
|
||||
async moveFile(src: string, dst: string, opts: { signal?: AbortSignal } = {}): Promise<AlistObj> {
|
||||
return this.transferFile("move", src, dst, opts);
|
||||
}
|
||||
|
||||
private async transferFile(
|
||||
kind: "copy" | "move",
|
||||
srcIn: string,
|
||||
dstIn: string,
|
||||
opts: { signal?: AbortSignal },
|
||||
): Promise<AlistObj> {
|
||||
const src = normAlistPath(srcIn);
|
||||
const dst = normAlistPath(dstIn);
|
||||
if (src === dst) throw new AlistError("conflict", `源与目标相同:${src}`);
|
||||
if (dst.startsWith(`${src}/`)) throw new AlistError("error", "不能把目录复制 / 移动到它自己里面");
|
||||
await this.get(src); // 源不存在 → not_found
|
||||
if (await this.stat(dst)) throw new AlistError("conflict", `目标已存在:${dst}`, { path: dst });
|
||||
const srcDir = posix.dirname(src);
|
||||
const srcName = posix.basename(src);
|
||||
const dstDir = posix.dirname(dst);
|
||||
const dstName = posix.basename(dst);
|
||||
await this.mkdir(dstDir);
|
||||
if (kind === "move" && srcDir === dstDir) {
|
||||
await this.rename(src, dstName);
|
||||
return this.waitExists(dst, opts.signal ? { signal: opts.signal } : {});
|
||||
}
|
||||
if (srcName === dstName) {
|
||||
const ids = await this.rawTransfer(kind, srcDir, dstDir, [srcName]);
|
||||
return this.finishTransfer(kind, ids, dst, opts.signal);
|
||||
}
|
||||
// 暂存目录:不在目标目录里按源名落地,避免与已有同名文件冲突 / 覆盖
|
||||
const stageName = `${STAGE_PREFIX}${randomUUID().slice(0, 8)}`;
|
||||
const stage = posix.join(dstDir, stageName);
|
||||
let movedToStage = false;
|
||||
try {
|
||||
await this.mkdir(stage);
|
||||
const ids = await this.rawTransfer(kind, srcDir, stage, [srcName]);
|
||||
movedToStage = kind === "move";
|
||||
await this.finishTransfer(kind, ids, posix.join(stage, srcName), opts.signal);
|
||||
await this.rename(posix.join(stage, srcName), dstName);
|
||||
// 暂存目录与目标同目录 → 同驱动 move,立即完成
|
||||
const ids2 = await this.rawTransfer("move", stage, dstDir, [dstName]);
|
||||
movedToStage = false;
|
||||
const obj = await this.finishTransfer("move", ids2, dst, opts.signal);
|
||||
await this.remove(dstDir, [stageName]).catch(() => undefined);
|
||||
return obj;
|
||||
} catch (e) {
|
||||
if (movedToStage) {
|
||||
// 移动失败:尽量把源搬回去
|
||||
for (const n of [srcName, dstName]) {
|
||||
const p = posix.join(stage, n);
|
||||
if (await this.stat(p, { refresh: true }).catch(() => null)) {
|
||||
await this.rename(p, srcName).catch(() => undefined);
|
||||
await this.rawTransfer("move", stage, srcDir, [srcName]).catch(() => undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.remove(dstDir, [stageName]).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 上传(§2.6)──
|
||||
|
||||
/**
|
||||
* 上传本地文件到 dst(完整路径)。目标已存在抛 conflict(Alist 的 put 会静默覆盖,不依赖它);
|
||||
* 先传 `<name>.uhd-partial` 再改名;成功判定 = 接口成功 + 目标存在(同 MP)。
|
||||
*/
|
||||
async upload(
|
||||
localPath: string,
|
||||
dstIn: string,
|
||||
opts: {
|
||||
signal?: AbortSignal;
|
||||
onProgress?: ProgressCallback;
|
||||
onHashProgress?: ProgressCallback;
|
||||
hashes?: AlistHashes;
|
||||
asTask?: boolean;
|
||||
} = {},
|
||||
): Promise<{ obj: AlistObj; hashes: AlistHashes; bytes: number }> {
|
||||
const dst = normAlistPath(dstIn);
|
||||
const st = await stat(localPath);
|
||||
if (!st.isFile()) throw new AlistError("error", `不是文件:${localPath}`);
|
||||
const hashes =
|
||||
opts.hashes ??
|
||||
(await hashLocalFile(localPath, {
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
...(opts.onHashProgress ? { onProgress: opts.onHashProgress } : {}),
|
||||
}));
|
||||
if (await this.stat(dst)) throw new AlistError("conflict", `目标已存在:${dst}`, { path: dst });
|
||||
const tmp = `${dst}${PARTIAL_SUFFIX}`;
|
||||
await this.mkdir(posix.dirname(dst));
|
||||
if (await this.stat(tmp)) await this.removePath(tmp);
|
||||
let attempt = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
await this.putOnce(localPath, tmp, st.size, st.mtimeMs, hashes, opts);
|
||||
break;
|
||||
} catch (e) {
|
||||
const err = e instanceof AlistError ? e : new AlistError("unavailable", String((e as Error)?.message ?? e));
|
||||
await this.removePath(tmp).catch(() => undefined);
|
||||
if (err.retryable && attempt < this.o.retries && !opts.signal?.aborted) {
|
||||
this.stats.retries++;
|
||||
this.opts.logger?.warn?.({ dst, attempt, kind: err.kind }, "Alist 上传失败,重试");
|
||||
await this.o.sleep(this.backoff(attempt, err));
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.waitExists(tmp, { timeoutMs: 60_000, ...(opts.signal ? { signal: opts.signal } : {}) });
|
||||
await this.rename(tmp, posix.basename(dst));
|
||||
const obj = await this.waitExists(dst, opts.signal ? { signal: opts.signal } : {});
|
||||
return { obj, hashes, bytes: st.size };
|
||||
} catch (e) {
|
||||
await this.removePath(tmp).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async putOnce(
|
||||
localPath: string,
|
||||
target: string,
|
||||
size: number,
|
||||
mtimeMs: number,
|
||||
hashes: AlistHashes,
|
||||
opts: { signal?: AbortSignal; onProgress?: ProgressCallback; asTask?: boolean },
|
||||
): Promise<void> {
|
||||
const asTask = opts.asTask ?? this.o.uploadAsTask;
|
||||
const t = await this.token();
|
||||
const watch = idleWatch(this.o.idleTimeoutMs, opts.signal);
|
||||
const fh = await open(localPath, "r");
|
||||
let sent = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(ctl) {
|
||||
const buf = new Uint8Array(1024 * 1024);
|
||||
const { bytesRead } = await fh.read(buf, 0, buf.length, null);
|
||||
if (bytesRead === 0) {
|
||||
ctl.close();
|
||||
return;
|
||||
}
|
||||
sent += bytesRead;
|
||||
watch.touch();
|
||||
opts.onProgress?.(sent, size);
|
||||
ctl.enqueue(buf.subarray(0, bytesRead));
|
||||
},
|
||||
});
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(size),
|
||||
"File-Path": encodeAlistPath(target),
|
||||
"As-Task": String(asTask),
|
||||
"Last-Modified": String(Math.floor(mtimeMs)),
|
||||
"X-File-Md5": hashes.md5,
|
||||
"X-File-Sha1": hashes.sha1,
|
||||
"X-File-Sha256": hashes.sha256,
|
||||
};
|
||||
if (t) headers.Authorization = t;
|
||||
let data: { task?: Record<string, unknown> } | null;
|
||||
try {
|
||||
data = await this.limiter.run(
|
||||
async () => {
|
||||
this.stats.requests++;
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchFn(`${this.baseUrl}/api/fs/put`, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body,
|
||||
signal: watch.signal,
|
||||
duplex: "half",
|
||||
} as RequestInit);
|
||||
} catch (e) {
|
||||
if (opts.signal?.aborted) throw abortedError();
|
||||
if (watch.timedOut)
|
||||
throw new AlistError("timeout", `上传 ${Math.round(this.o.idleTimeoutMs / 1000)} 秒没有进展`);
|
||||
throw new AlistError("unavailable", `上传中断:${(e as Error)?.message ?? e}`, { path: target });
|
||||
}
|
||||
const text = await res.text().catch(() => "");
|
||||
let json: { code?: number; message?: string; data?: unknown } | null = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
if (!res.ok || !json || json.code !== 200)
|
||||
throw classifyAlist(res.status, json?.code ?? null, json?.message ?? text.slice(0, 200), target);
|
||||
return json.data as { task?: Record<string, unknown> } | null;
|
||||
},
|
||||
{ data: true },
|
||||
);
|
||||
} finally {
|
||||
watch.done();
|
||||
await fh.close().catch(() => undefined);
|
||||
}
|
||||
const taskId = data?.task?.id;
|
||||
if (asTask && typeof taskId === "string" && taskId) {
|
||||
await this.waitTask("upload", taskId, opts.signal ? { signal: opts.signal } : {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 下载(§2.7)──
|
||||
|
||||
/**
|
||||
* 下载到本地 localPath:写 `<localPath>.uhd-partial`,支持 Range 续传、直链过期重取;
|
||||
* 流正常结束即成功(同 MP,不比对大小 / 哈希),之后不覆盖地改名。目标已存在抛 conflict。
|
||||
*/
|
||||
async download(
|
||||
remote: string,
|
||||
localPath: string,
|
||||
opts: { signal?: AbortSignal; onProgress?: ProgressCallback } = {},
|
||||
): Promise<{ bytes: number; sha256: string; obj: AlistObj }> {
|
||||
if (
|
||||
await stat(localPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
throw new AlistError("conflict", `本地目标已存在:${localPath}`);
|
||||
let obj = await this.get(remote, opts.signal ? { signal: opts.signal } : {});
|
||||
if (obj.isDir) throw new AlistError("error", `是目录,不能下载:${obj.path}`);
|
||||
const partial = `${localPath}${PARTIAL_SUFFIX}`;
|
||||
let signFailures = 0;
|
||||
let attempt = 0;
|
||||
let url = await this.rawUrl(remote, obj);
|
||||
for (;;) {
|
||||
opts.signal?.throwIfAborted();
|
||||
const have = await stat(partial).then(
|
||||
(s) => s.size,
|
||||
() => 0,
|
||||
);
|
||||
const watch = idleWatch(this.o.idleTimeoutMs, opts.signal);
|
||||
try {
|
||||
const res = await this.limiter.run(
|
||||
async () =>
|
||||
this.fetchFn(url, {
|
||||
headers: await this.downloadHeaders(url, have > 0 ? { Range: `bytes=${have}-` } : {}),
|
||||
signal: watch.signal,
|
||||
}),
|
||||
{ data: true },
|
||||
);
|
||||
if ([401, 403, 410].includes(res.status)) {
|
||||
await res.body?.cancel();
|
||||
if (++signFailures > 3)
|
||||
throw new AlistError("auth", `下载直链多次被拒绝(HTTP ${res.status})`, { path: obj.path });
|
||||
this.opts.logger?.warn?.({ path: obj.path, status: res.status }, "Alist 直链失效,重新获取");
|
||||
obj = await this.get(remote);
|
||||
url = await this.rawUrl(remote, obj);
|
||||
continue;
|
||||
}
|
||||
if (res.status === 416 && have > 0) {
|
||||
// 已经下全了(上次在改名前中断)
|
||||
await res.body?.cancel();
|
||||
break;
|
||||
}
|
||||
if (!res.ok || !res.body) {
|
||||
await res.body?.cancel();
|
||||
throw classifyAlist(res.status, null, `下载失败 HTTP ${res.status}`, obj.path);
|
||||
}
|
||||
const append = have > 0 && res.status === 206;
|
||||
const total = append
|
||||
? have + Number(res.headers.get("content-length") ?? 0)
|
||||
: Number(res.headers.get("content-length") ?? obj.size) || obj.size;
|
||||
const fh = await open(partial, append ? "a" : "w");
|
||||
let done = append ? have : 0;
|
||||
try {
|
||||
const reader = res.body.getReader();
|
||||
for (;;) {
|
||||
const { value, done: end } = await reader.read();
|
||||
if (end) break;
|
||||
watch.touch();
|
||||
await fh.write(value);
|
||||
done += value.length;
|
||||
opts.onProgress?.(done, total);
|
||||
}
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
break;
|
||||
} catch (e) {
|
||||
if (opts.signal?.aborted) throw abortedError();
|
||||
const err =
|
||||
e instanceof AlistError
|
||||
? e
|
||||
: watch.timedOut
|
||||
? new AlistError("timeout", `下载 ${Math.round(this.o.idleTimeoutMs / 1000)} 秒没有进展`, {
|
||||
path: obj.path,
|
||||
})
|
||||
: new AlistError("unavailable", `下载中断:${(e as Error)?.message ?? e}`, { path: obj.path });
|
||||
if (err.retryable && attempt < this.o.retries) {
|
||||
this.stats.retries++;
|
||||
await this.o.sleep(this.backoff(attempt, err));
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
watch.done();
|
||||
}
|
||||
}
|
||||
const sha = createHash("sha256");
|
||||
let bytes = 0;
|
||||
for await (const chunk of createReadStream(partial, { highWaterMark: 1024 * 1024 })) {
|
||||
sha.update(chunk as Uint8Array);
|
||||
bytes += (chunk as Uint8Array).length;
|
||||
}
|
||||
try {
|
||||
await link(partial, localPath);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string })?.code === "EEXIST") throw new AlistError("conflict", `本地目标已存在:${localPath}`);
|
||||
throw e;
|
||||
}
|
||||
await unlink(partial).catch(() => undefined);
|
||||
return { bytes, sha256: sha.digest("hex"), obj };
|
||||
}
|
||||
|
||||
/** 清掉本地 partial(调用方放弃续传时) */
|
||||
static async dropPartial(localPath: string): Promise<void> {
|
||||
await rm(`${localPath}${PARTIAL_SUFFIX}`, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Alist / OpenList 错误分类(docs/design/storage-alist.md §2.1、§9-1):
|
||||
// 「不存在」与连接失败 / 鉴权失败 / 服务端错误分开,调用方不能把后者当成不存在。
|
||||
|
||||
export type AlistErrorKind =
|
||||
| "not_found"
|
||||
| "conflict"
|
||||
| "auth"
|
||||
| "rate_limited"
|
||||
| "unavailable"
|
||||
| "task_failed"
|
||||
| "timeout"
|
||||
| "aborted"
|
||||
| "error";
|
||||
|
||||
export class AlistError extends Error {
|
||||
override name = "AlistError";
|
||||
constructor(
|
||||
readonly kind: AlistErrorKind,
|
||||
message: string,
|
||||
readonly detail: { status?: number; code?: number; path?: string } = {},
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
/** 可重试:连不上、超时、限流 / 风控、5xx */
|
||||
get retryable(): boolean {
|
||||
return this.kind === "unavailable" || this.kind === "rate_limited" || this.kind === "timeout";
|
||||
}
|
||||
/** 对应本地文件系统的错误码(vfs 用;与 FsErrorCode 同名) */
|
||||
get fsCode(): string {
|
||||
switch (this.kind) {
|
||||
case "not_found":
|
||||
return "ENOENT";
|
||||
case "conflict":
|
||||
return "EEXIST";
|
||||
case "auth":
|
||||
return "EACCES";
|
||||
case "timeout":
|
||||
case "rate_limited":
|
||||
return "TIMEOUT";
|
||||
case "aborted":
|
||||
return "ABORTED";
|
||||
default:
|
||||
return "IO";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isAlistNotFound = (e: unknown): boolean => e instanceof AlistError && e.kind === "not_found";
|
||||
export const isAlistConflict = (e: unknown): boolean => e instanceof AlistError && e.kind === "conflict";
|
||||
|
||||
const NOT_FOUND_RE = /object not found|not exist|no such file|not found/i;
|
||||
const EXISTS_RE = /\bexists?\b|already exist/i;
|
||||
const AUTH_RE = /token is (invalidated|expired)|invalid token|unauthorized|password is incorrect|incorrect password/i;
|
||||
const RATE_RE = /too many|rate limit|频繁|风控|429/i;
|
||||
|
||||
/** HTTP 状态 + 业务 code + message → 错误类型 */
|
||||
export function classifyAlist(status: number, code: number | null, message: string, path?: string): AlistError {
|
||||
const detail = { status, ...(code !== null ? { code } : {}), ...(path ? { path } : {}) };
|
||||
const msg = message || `HTTP ${status}`;
|
||||
if (status === 429 || code === 429 || RATE_RE.test(msg)) return new AlistError("rate_limited", msg, detail);
|
||||
if (status === 401 || code === 401 || AUTH_RE.test(msg)) return new AlistError("auth", msg, detail);
|
||||
if (code === 404 && /task/i.test(msg)) return new AlistError("not_found", msg, detail);
|
||||
if (NOT_FOUND_RE.test(msg)) return new AlistError("not_found", msg, detail);
|
||||
if (code === 403 && EXISTS_RE.test(msg)) return new AlistError("conflict", msg, detail);
|
||||
if (status === 403 || code === 403) return new AlistError("auth", msg, detail);
|
||||
if (status >= 500 && code === null) return new AlistError("unavailable", msg, detail);
|
||||
if (status >= 500 || (code !== null && code >= 500 && /timeout|connect|EOF|reset|unavailable|bad gateway/i.test(msg)))
|
||||
return new AlistError("unavailable", msg, detail);
|
||||
return new AlistError("error", msg, detail);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./client.ts";
|
||||
export * from "./errors.ts";
|
||||
export * from "./limiter.ts";
|
||||
export * from "./redact.ts";
|
||||
@@ -0,0 +1,61 @@
|
||||
// 每个 Alist 实例一个限速器:令牌桶(QPS)+ 并发上限(docs/design/storage-alist.md §2.3、§9-8)。
|
||||
// 数据流(上传 / 下载)占并发但不占 QPS。
|
||||
|
||||
export class AlistLimiter {
|
||||
private tokens: number;
|
||||
private last: number;
|
||||
private active = 0;
|
||||
private readonly waiters: (() => void)[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly qps: number,
|
||||
private readonly concurrency: number,
|
||||
private readonly now: () => number = () => Date.now(),
|
||||
private readonly sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
|
||||
) {
|
||||
this.tokens = Math.max(1, qps);
|
||||
this.last = now();
|
||||
}
|
||||
|
||||
private async takeToken(): Promise<void> {
|
||||
if (this.qps <= 0) return;
|
||||
for (;;) {
|
||||
const t = this.now();
|
||||
this.tokens = Math.min(Math.max(1, this.qps), this.tokens + ((t - this.last) / 1000) * this.qps);
|
||||
this.last = t;
|
||||
if (this.tokens >= 1) {
|
||||
this.tokens -= 1;
|
||||
return;
|
||||
}
|
||||
await this.sleep(Math.ceil(((1 - this.tokens) / this.qps) * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
private async acquire(): Promise<void> {
|
||||
if (this.concurrency <= 0 || this.active < this.concurrency) {
|
||||
this.active++;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((r) => this.waiters.push(r));
|
||||
this.active++;
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.active--;
|
||||
this.waiters.shift()?.();
|
||||
}
|
||||
|
||||
async run<T>(fn: () => Promise<T>, opts: { data?: boolean } = {}): Promise<T> {
|
||||
if (!opts.data) await this.takeToken();
|
||||
await this.acquire();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
get inFlight(): number {
|
||||
return this.active;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
// 测试用的进程内 Alist / OpenList v4 模拟(docs/design/storage-alist.md §12)。
|
||||
// 返回形状照真机(OpenList v4.2.6)核对过:HTTP 200 + {code,message,data};不存在 code 500 "object not found";
|
||||
// 令牌无效 code 401;copy 一律异步任务;同挂载 move 立即完成、跨挂载 move 是任务;put 覆盖同名;/d 支持 Range 与 sign。
|
||||
// 顶层目录视为「挂载」(驱动),用于区分同驱动 / 跨驱动。
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
export interface MockFile {
|
||||
data: Uint8Array;
|
||||
modified: number;
|
||||
hashInfo?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
export interface MockAlistOptions {
|
||||
/** 永久令牌 */
|
||||
token?: string;
|
||||
/** 账号密码 */
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** 目录密码(需要时 list / get 必须带) */
|
||||
pathPassword?: string;
|
||||
/** 任务完成前需要被查询的次数(模拟异步) */
|
||||
taskPolls?: number;
|
||||
/** 不支持任务接口(模拟老 Alist v3) */
|
||||
noTaskApi?: boolean;
|
||||
/** copy / move 不返回任务 id(模拟老 Alist v3 同步返回 data:null,但实际延迟完成) */
|
||||
noTaskIds?: boolean;
|
||||
/** 挂载容量(mount_details) */
|
||||
mountDetails?: Record<string, { total_space: number; free_space: number; used_space: number }>;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
kind: "copy" | "move" | "upload";
|
||||
name: string;
|
||||
polls: number;
|
||||
state: number;
|
||||
error: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export interface MockAlist {
|
||||
readonly base: string;
|
||||
setOrigin(origin: string): void;
|
||||
handler: (req: Request) => Promise<Response>;
|
||||
fetch: typeof fetch;
|
||||
files: Map<string, MockFile>;
|
||||
dirs: Map<string, number>;
|
||||
tasks: Map<string, Task>;
|
||||
requests: { path: string; body: Record<string, unknown> | null; headers: Record<string, string> }[];
|
||||
/** 已签发且有效的临时令牌 */
|
||||
sessions: Set<string>;
|
||||
/** 注入:下 n 个 API 请求返回 429 */
|
||||
rateLimitNext(n: number): void;
|
||||
/** 注入:下 n 个 API 请求连接失败(fetch 抛 TypeError) */
|
||||
failNext(n: number): void;
|
||||
/** 注入:上传读到 n 字节后断开 */
|
||||
breakUploadAfter(bytes: number | null): void;
|
||||
/** 注入:下载读到 n 字节后断开(仅一次) */
|
||||
breakDownloadAfter(bytes: number | null): void;
|
||||
/** 注入:使所有 sign 失效一次(下载返回 401) */
|
||||
expireSigns(): void;
|
||||
/** 注入:下一个任务以失败结束 */
|
||||
failNextTask(message: string): void;
|
||||
/** 注入:put 成功但文件不落地(模拟「接口成功目标不存在」) */
|
||||
dropNextUpload(): void;
|
||||
invalidateSessions(): void;
|
||||
put(path: string, data: Uint8Array | string, opts?: { modified?: number; hashInfo?: Record<string, string> }): void;
|
||||
mkdirp(path: string): void;
|
||||
read(path: string): Uint8Array | null;
|
||||
exists(path: string): boolean;
|
||||
listNames(path: string): string[];
|
||||
}
|
||||
|
||||
const norm = (p: string) =>
|
||||
`/${p
|
||||
.split("/")
|
||||
.filter((s) => s && s !== ".")
|
||||
.join("/")}`;
|
||||
const parent = (p: string) => norm(p.split("/").slice(0, -1).join("/"));
|
||||
const base = (p: string) => p.split("/").filter(Boolean).pop() ?? "";
|
||||
const mountOf = (p: string) => p.split("/").filter(Boolean)[0] ?? "";
|
||||
const iso = (ms: number) => new Date(ms).toISOString();
|
||||
|
||||
export function createMockAlist(opts: MockAlistOptions = {}): MockAlist {
|
||||
let origin = "http://alist.mock";
|
||||
const files = new Map<string, MockFile>();
|
||||
const dirs = new Map<string, number>([["/", Date.now()]]);
|
||||
const tasks = new Map<string, Task>();
|
||||
const sessions = new Set<string>();
|
||||
const requests: MockAlist["requests"] = [];
|
||||
let signEpoch = 1;
|
||||
let rateLimit = 0;
|
||||
let fail = 0;
|
||||
let uploadBreak: number | null = null;
|
||||
let downloadBreak: number | null = null;
|
||||
let nextTaskError: string | null = null;
|
||||
let dropUpload = false;
|
||||
|
||||
const mkdirp = (p: string) => {
|
||||
const segs = norm(p).split("/").filter(Boolean);
|
||||
let cur = "";
|
||||
for (const s of segs) {
|
||||
cur += `/${s}`;
|
||||
if (files.has(cur)) throw new Error(`not a dir: ${cur}`);
|
||||
if (!dirs.has(cur)) dirs.set(cur, Date.now());
|
||||
}
|
||||
};
|
||||
const touch = (dir: string) => {
|
||||
if (dirs.has(dir)) dirs.set(dir, Date.now());
|
||||
};
|
||||
const putFile = (p: string, data: Uint8Array, modified = Date.now(), hashInfo?: Record<string, string> | null) => {
|
||||
mkdirp(parent(p));
|
||||
files.set(norm(p), { data, modified, ...(hashInfo !== undefined ? { hashInfo } : {}) });
|
||||
touch(parent(p));
|
||||
};
|
||||
const children = (dir: string) => {
|
||||
const d = norm(dir);
|
||||
const pre = d === "/" ? "/" : `${d}/`;
|
||||
const names = new Set<string>();
|
||||
for (const k of [...files.keys(), ...dirs.keys()]) {
|
||||
if (k !== d && k.startsWith(pre)) {
|
||||
const rest = k.slice(pre.length);
|
||||
if (rest && !rest.includes("/")) names.add(rest);
|
||||
}
|
||||
}
|
||||
return [...names].sort();
|
||||
};
|
||||
const exists = (p: string) => files.has(norm(p)) || dirs.has(norm(p));
|
||||
const signOf = (p: string) => createHash("sha256").update(`${p}:${signEpoch}`).digest("hex").slice(0, 16);
|
||||
const objOf = (p: string, withRaw = false) => {
|
||||
const f = files.get(p);
|
||||
const isDir = !f;
|
||||
return {
|
||||
name: base(p),
|
||||
size: f ? f.data.length : 0,
|
||||
is_dir: isDir,
|
||||
modified: iso(f ? f.modified : (dirs.get(p) ?? 0)),
|
||||
created: iso(f ? f.modified : (dirs.get(p) ?? 0)),
|
||||
sign: isDir ? "" : signOf(p),
|
||||
thumb: "",
|
||||
type: isDir ? 1 : 0,
|
||||
hashinfo: "null",
|
||||
hash_info: f?.hashInfo ?? null,
|
||||
...(withRaw
|
||||
? {
|
||||
raw_url: isDir ? "" : `${origin}/d${p.split("/").map(encodeURIComponent).join("/")}?sign=${signOf(p)}`,
|
||||
provider: "Local",
|
||||
readme: "",
|
||||
header: "",
|
||||
related: null,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
const ok = (data: unknown = null) => Response.json({ code: 200, message: "success", data });
|
||||
const err = (code: number, message: string) => Response.json({ code, message, data: null });
|
||||
const copyTree = (src: string, dst: string) => {
|
||||
if (files.has(src)) {
|
||||
const f = files.get(src)!;
|
||||
putFile(dst, f.data.slice(), f.modified, f.hashInfo ?? null);
|
||||
return;
|
||||
}
|
||||
mkdirp(dst);
|
||||
for (const n of children(src)) copyTree(`${src}/${n}`, `${dst}/${n}`);
|
||||
};
|
||||
const removeTree = (p: string) => {
|
||||
for (const k of [...files.keys()]) if (k === p || k.startsWith(`${p}/`)) files.delete(k);
|
||||
for (const k of [...dirs.keys()]) if (k === p || k.startsWith(`${p}/`)) dirs.delete(k);
|
||||
touch(parent(p));
|
||||
};
|
||||
const newTask = (kind: Task["kind"], name: string, run: () => void): Task => {
|
||||
const t: Task = { id: randomUUID().slice(0, 21), kind, name, polls: 0, state: 0, error: "", run };
|
||||
if (nextTaskError) {
|
||||
t.error = nextTaskError;
|
||||
nextTaskError = null;
|
||||
t.run = () => {
|
||||
throw new Error(t.error);
|
||||
};
|
||||
}
|
||||
tasks.set(t.id, t);
|
||||
return t;
|
||||
};
|
||||
const advanceTask = (t: Task) => {
|
||||
if (t.state >= 2) return;
|
||||
t.polls++;
|
||||
t.state = 1;
|
||||
if (t.polls > (opts.taskPolls ?? 1)) {
|
||||
try {
|
||||
t.run();
|
||||
t.state = 2;
|
||||
} catch (e) {
|
||||
t.state = 7;
|
||||
t.error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
};
|
||||
const taskJson = (t: Task) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
creator: "admin",
|
||||
creator_role: 2,
|
||||
state: t.state,
|
||||
status: t.state === 2 ? "done" : "running",
|
||||
progress: t.state === 2 ? 100 : 0,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
total_bytes: 0,
|
||||
error: t.state === 7 ? t.error : "",
|
||||
});
|
||||
|
||||
const authed = (req: Request) => {
|
||||
const a = req.headers.get("authorization") ?? "";
|
||||
if (opts.token && a === opts.token) return true;
|
||||
if (sessions.has(a)) return true;
|
||||
return !opts.token && !opts.username;
|
||||
};
|
||||
const pwOk = (body: Record<string, unknown>) => !opts.pathPassword || body.password === opts.pathPassword;
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
if (path.startsWith("/d/")) {
|
||||
const p = norm(decodeURIComponent(path.slice(2)));
|
||||
if (url.searchParams.get("sign") !== signOf(p)) return new Response("sign invalid", { status: 401 });
|
||||
const f = files.get(p);
|
||||
if (!f) return new Response("not found", { status: 404 });
|
||||
const range = req.headers.get("range");
|
||||
let start = 0;
|
||||
if (range) {
|
||||
const m = /bytes=(\d+)-/.exec(range);
|
||||
start = m ? Number(m[1]) : 0;
|
||||
if (start >= f.data.length) return new Response(null, { status: 416 });
|
||||
}
|
||||
let chunk = f.data.subarray(start);
|
||||
const broken = downloadBreak;
|
||||
if (broken !== null) {
|
||||
downloadBreak = null;
|
||||
const cut = Math.max(0, broken - start);
|
||||
const head = chunk.subarray(0, cut);
|
||||
let sentHead = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(ctl) {
|
||||
if (!sentHead && head.length) {
|
||||
sentHead = true;
|
||||
ctl.enqueue(head.slice());
|
||||
return;
|
||||
}
|
||||
ctl.error(new Error("connection reset"));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: start ? 206 : 200,
|
||||
headers: { "content-length": String(chunk.length) },
|
||||
});
|
||||
}
|
||||
chunk = chunk.slice();
|
||||
return new Response(chunk as Uint8Array<ArrayBuffer>, {
|
||||
status: start ? 206 : 200,
|
||||
headers: { "content-length": String(chunk.length) },
|
||||
});
|
||||
}
|
||||
let body: Record<string, unknown> | null = null;
|
||||
if (path !== "/api/fs/put" && req.method === "POST") body = (await req.json().catch(() => null)) ?? null;
|
||||
requests.push({ path: path + url.search, body, headers: Object.fromEntries(req.headers) });
|
||||
if (fail > 0) {
|
||||
fail--;
|
||||
throw new TypeError("error sending request: connection refused");
|
||||
}
|
||||
if (rateLimit > 0) {
|
||||
rateLimit--;
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
if (path === "/api/public/settings") return ok({ version: opts.version ?? "v4.2.6 (mock)" });
|
||||
if (path === "/api/auth/login") {
|
||||
if (body?.username === opts.username && body?.password === opts.password) {
|
||||
const t = `sess-${randomUUID()}`;
|
||||
sessions.add(t);
|
||||
return ok({ token: t });
|
||||
}
|
||||
return err(400, "password is incorrect or you have no permission");
|
||||
}
|
||||
if (!authed(req)) return err(401, "token is invalidated");
|
||||
const b = body ?? {};
|
||||
switch (path) {
|
||||
case "/api/me":
|
||||
return ok({ id: 1, username: opts.username ?? "admin", base_path: "/", role: 2, permission: 29183 });
|
||||
case "/api/admin/storage/list":
|
||||
return ok({
|
||||
content: [...new Set([...dirs.keys()].map(mountOf).filter(Boolean))].map((m, i) => ({
|
||||
id: i + 1,
|
||||
mount_path: `/${m}`,
|
||||
driver: "Local",
|
||||
status: "work",
|
||||
...(opts.mountDetails?.[`/${m}`] ? { mount_details: opts.mountDetails[`/${m}`] } : {}),
|
||||
})),
|
||||
total: 0,
|
||||
});
|
||||
case "/api/fs/list": {
|
||||
const p = norm(String(b.path ?? "/"));
|
||||
if (!pwOk(b)) return err(403, "password is incorrect or you have no permission");
|
||||
if (!dirs.has(p)) return err(500, "failed to get dir: failed to get obj: object not found");
|
||||
const all = children(p).map((n) => objOf(p === "/" ? `/${n}` : `${p}/${n}`));
|
||||
const per = Number(b.per_page ?? 0) || 200;
|
||||
const page = Number(b.page ?? 1) || 1;
|
||||
return ok({
|
||||
content: all.slice((page - 1) * per, page * per),
|
||||
total: all.length,
|
||||
readme: "",
|
||||
header: "",
|
||||
write: true,
|
||||
provider: "Local",
|
||||
});
|
||||
}
|
||||
case "/api/fs/get": {
|
||||
const p = norm(String(b.path ?? "/"));
|
||||
if (!pwOk(b)) return err(403, "password is incorrect or you have no permission");
|
||||
if (!exists(p)) return err(500, "failed to get obj: object not found");
|
||||
return ok(objOf(p, true));
|
||||
}
|
||||
case "/api/fs/mkdir":
|
||||
try {
|
||||
mkdirp(String(b.path ?? "/"));
|
||||
} catch (e) {
|
||||
return err(500, (e as Error).message);
|
||||
}
|
||||
return ok();
|
||||
case "/api/fs/rename": {
|
||||
const p = norm(String(b.path ?? ""));
|
||||
if (!exists(p)) return err(500, "failed to get obj: object not found");
|
||||
const to = `${parent(p)}/${String(b.name)}`.replace("//", "/");
|
||||
if (exists(to)) return err(403, `file [${b.name}] exists`);
|
||||
copyTree(p, to);
|
||||
removeTree(p);
|
||||
return ok();
|
||||
}
|
||||
case "/api/fs/remove": {
|
||||
const dir = norm(String(b.dir ?? "/"));
|
||||
for (const n of (b.names as string[]) ?? []) removeTree(`${dir}/${n}`.replace("//", "/"));
|
||||
return ok();
|
||||
}
|
||||
case "/api/fs/copy":
|
||||
case "/api/fs/move": {
|
||||
const kind = path.endsWith("copy") ? "copy" : "move";
|
||||
const srcDir = norm(String(b.src_dir));
|
||||
const dstDir = norm(String(b.dst_dir));
|
||||
if (!dirs.has(dstDir)) return err(500, "failed to get dst dir: failed to get obj: object not found");
|
||||
const names = (b.names as string[]) ?? [];
|
||||
for (const n of names) {
|
||||
if (!exists(`${srcDir}/${n}`)) return err(500, "failed to get src obj: failed to get obj: object not found");
|
||||
if (exists(`${dstDir}/${n}`)) return err(403, `file [${n}] exists`);
|
||||
}
|
||||
if (kind === "move" && mountOf(srcDir) === mountOf(dstDir)) {
|
||||
for (const n of names) {
|
||||
copyTree(`${srcDir}/${n}`, `${dstDir}/${n}`);
|
||||
removeTree(`${srcDir}/${n}`);
|
||||
}
|
||||
return ok({ message: "Move operations completed immediately" });
|
||||
}
|
||||
const created = names.map((n) =>
|
||||
newTask(kind, `${kind} [${srcDir}](${n}) to [${dstDir}]`, () => {
|
||||
copyTree(`${srcDir}/${n}`, `${dstDir}/${n}`);
|
||||
if (kind === "move") removeTree(`${srcDir}/${n}`);
|
||||
}),
|
||||
);
|
||||
if (opts.noTaskIds) {
|
||||
// 老版本:同步返回,但内容稍后才出现(由任务表推进)
|
||||
return ok(null);
|
||||
}
|
||||
return ok({ message: `Successfully created ${created.length} ${kind} task(s)`, tasks: created.map(taskJson) });
|
||||
}
|
||||
case "/api/fs/put": {
|
||||
const p = norm(decodeURIComponent(req.headers.get("file-path") ?? ""));
|
||||
const asTask = req.headers.get("as-task") === "true";
|
||||
const reader = req.body!.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let got = 0;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
got += value.length;
|
||||
if (uploadBreak !== null && got >= uploadBreak) {
|
||||
uploadBreak = null;
|
||||
await reader.cancel();
|
||||
throw new TypeError("connection reset by peer");
|
||||
}
|
||||
}
|
||||
const data = new Uint8Array(got);
|
||||
let off = 0;
|
||||
for (const c of chunks) {
|
||||
data.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
const mod = Number(req.headers.get("last-modified") ?? Date.now());
|
||||
const commit = () => {
|
||||
if (dropUpload) {
|
||||
dropUpload = false;
|
||||
return;
|
||||
}
|
||||
putFile(p, data, mod);
|
||||
};
|
||||
if (asTask) {
|
||||
const t = newTask("upload", `upload ${base(p)} to [${parent(p)}]`, commit);
|
||||
return ok({ task: taskJson(t) });
|
||||
}
|
||||
commit();
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
const tm = /^\/api\/(admin\/)?task\/(copy|move|upload)\/info$/.exec(path);
|
||||
if (tm) {
|
||||
if (opts.noTaskApi) return new Response("404 page not found", { status: 404 });
|
||||
const t = tasks.get(url.searchParams.get("tid") ?? "");
|
||||
if (!t) return err(404, "task not found");
|
||||
advanceTask(t);
|
||||
return ok(taskJson(t));
|
||||
}
|
||||
return new Response("404 page not found", { status: 404 });
|
||||
};
|
||||
|
||||
const mock: MockAlist = {
|
||||
get base() {
|
||||
return origin;
|
||||
},
|
||||
setOrigin(o: string) {
|
||||
origin = o;
|
||||
},
|
||||
handler,
|
||||
fetch: (async (input: Request | URL | string, init?: RequestInit) => {
|
||||
const req = input instanceof Request ? input : new Request(String(input), init);
|
||||
// 老版本(noTaskIds):没有任务 id 的复制 / 移动,在之后的任意 get 上推进
|
||||
if (opts.noTaskIds) for (const t of tasks.values()) advanceTask(t);
|
||||
return handler(req);
|
||||
}) as typeof fetch,
|
||||
files,
|
||||
dirs,
|
||||
tasks,
|
||||
requests,
|
||||
sessions,
|
||||
rateLimitNext: (n) => {
|
||||
rateLimit = n;
|
||||
},
|
||||
failNext: (n) => {
|
||||
fail = n;
|
||||
},
|
||||
breakUploadAfter: (n) => {
|
||||
uploadBreak = n;
|
||||
},
|
||||
breakDownloadAfter: (n) => {
|
||||
downloadBreak = n;
|
||||
},
|
||||
expireSigns: () => {
|
||||
signEpoch++;
|
||||
},
|
||||
failNextTask: (m) => {
|
||||
nextTaskError = m;
|
||||
},
|
||||
dropNextUpload: () => {
|
||||
dropUpload = true;
|
||||
},
|
||||
invalidateSessions: () => sessions.clear(),
|
||||
put: (p, d, o = {}) => putFile(p, typeof d === "string" ? new TextEncoder().encode(d) : d, o.modified, o.hashInfo),
|
||||
mkdirp,
|
||||
read: (p) => files.get(norm(p))?.data ?? null,
|
||||
exists,
|
||||
listNames: children,
|
||||
};
|
||||
return mock;
|
||||
}
|
||||
|
||||
/** 起一个真 HTTP 监听(ffprobe / 下载代理等需要真实 URL 的测试用);返回 base 与关闭函数 */
|
||||
export function serveMockAlist(mock: MockAlist): { base: string; close: () => Promise<void> } {
|
||||
const server = Deno.serve({ port: 0, hostname: "127.0.0.1", onListen: () => {} }, (req) => {
|
||||
const u = new URL(req.url);
|
||||
return mock
|
||||
.handler(new Request(`${mock.base}${u.pathname}${u.search}`, req))
|
||||
.catch(() => new Response("connection reset", { status: 502 }));
|
||||
});
|
||||
const addr = server.addr as Deno.NetAddr;
|
||||
const prev = mock.base;
|
||||
const base = `http://127.0.0.1:${addr.port}`;
|
||||
mock.setOrigin(base);
|
||||
return {
|
||||
base,
|
||||
close: async () => {
|
||||
await server.shutdown();
|
||||
mock.setOrigin(prev);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redactString, redactUrl } from "@umt/contracts";
|
||||
|
||||
// Alist 相关文本进日志 / 界面前的脱敏:直链的 sign 参数、令牌、密码(§2.3、§9-11)。
|
||||
|
||||
/** 遮蔽 URL 查询串里的凭证(sign 等)与 userinfo */
|
||||
export function redactAlistUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.username = "";
|
||||
u.password = "";
|
||||
return redactUrl(u.toString());
|
||||
} catch {
|
||||
return redactString(url);
|
||||
}
|
||||
}
|
||||
|
||||
/** 遮蔽任意文本里出现的给定秘密值(令牌 / 密码 / 路径密码)与直链签名 */
|
||||
export function redactAlistText(text: string, secrets: (string | null | undefined)[] = []): string {
|
||||
let out = text.replace(/([?&]sign=)[^&\s"']+/gi, "$1***");
|
||||
for (const s of secrets) if (s && s.length >= 4) out = out.split(s).join("***");
|
||||
return redactString(out);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { basename, dirname, join } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { errnoOf, FsError, toFsError } from "./errors.ts";
|
||||
import { assertLocalPaths } from "./remote-guard.ts";
|
||||
|
||||
export type VerifyMethod = "size" | "sha256" | "sample";
|
||||
|
||||
@@ -231,6 +232,7 @@ const LINK_UNSUPPORTED = ["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"];
|
||||
|
||||
/** no-clobber 提交:link(tmp,dst) 在目标存在时原子失败;文件系统不支持硬链接时退回 lstat+rename。 */
|
||||
export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
|
||||
assertLocalPaths("commitNoClobber", tmp, dst);
|
||||
try {
|
||||
await link(tmp, dst);
|
||||
await unlink(tmp);
|
||||
@@ -334,6 +336,7 @@ async function replaceCommit(
|
||||
* 用源文件权限覆盖会清掉继承 ACL(与 MP _copy_with_target_permissions 同理)。不要补 chmod/chown。
|
||||
*/
|
||||
export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> {
|
||||
assertLocalPaths("copy", src, dst, opts.replaceOld?.trashDir);
|
||||
const started = Date.now();
|
||||
const method = opts.hash ?? "size";
|
||||
const onExisting = opts.onExisting ?? "conflict";
|
||||
@@ -503,6 +506,7 @@ export async function filesMatch(
|
||||
|
||||
/** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */
|
||||
export async function sweepStalePartials(dir: string, olderThanMs: number, suffix = ".uhd-partial"): Promise<string[]> {
|
||||
assertLocalPaths("sweep", dir);
|
||||
const removed: string[] = [];
|
||||
const now = Date.now();
|
||||
for (const name of await readdir(dir)) {
|
||||
|
||||
@@ -10,6 +10,8 @@ export type FsErrorCode =
|
||||
| "ABORTED"
|
||||
/** 文件操作挂死(进度停滞/快操作超时),执行子进程已被回收;与 IO 同属可退避重试,不代表文件缺失 */
|
||||
| "TIMEOUT"
|
||||
/** 本地文件操作收到 Alist 虚拟路径(/@alist/…):调用方没按存储分派,拒绝而不是写错位置 */
|
||||
| "REMOTE_PATH_ON_LOCAL_FS"
|
||||
| "IO";
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,6 +49,8 @@ export interface ProbeOptions {
|
||||
/** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */
|
||||
bin?: string;
|
||||
timeoutMs?: number;
|
||||
/** 输入前的附加参数(远端直链用 -rw_timeout 等) */
|
||||
inputArgs?: string[];
|
||||
}
|
||||
|
||||
const optNum = (v: unknown): number | undefined => {
|
||||
@@ -78,7 +80,7 @@ export function ffprobe(path: string, opts: ProbeOptions = {}): Promise<ProbeRes
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
opts.bin ?? "ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", path],
|
||||
["-v", "error", ...(opts.inputArgs ?? []), "-print_format", "json", "-show_format", "-show_streams", "--", path],
|
||||
{ timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { unlink } from "node:fs/promises";
|
||||
import { type CopyOptions, copyWithVerify, filesMatchSha256, sha256Full, sweepStalePartials } from "./copy.ts";
|
||||
import { FsError, toFsError } from "./errors.ts";
|
||||
import { type CrossMoveOptions, crossDeviceMove, moveSafe } from "./move.ts";
|
||||
import { assertLocalPaths } from "./remote-guard.ts";
|
||||
import { checkRoot, statSafe } from "./stat.ts";
|
||||
|
||||
/** 可序列化的复制选项(去掉信号与回调) */
|
||||
@@ -45,7 +46,25 @@ export interface FsOpContext {
|
||||
progress: (p: FsProgress) => void;
|
||||
}
|
||||
|
||||
/** 请求里会被写入 / 删除的路径(远端虚拟路径守卫用;读类操作对它只会 ENOENT,不拦) */
|
||||
export function writePathsOf(req: FsOpRequest): string[] {
|
||||
switch (req.op) {
|
||||
case "copy":
|
||||
return [req.src, req.dst, ...(req.opts?.replaceOld?.trashDir ? [req.opts.replaceOld.trashDir] : [])];
|
||||
case "crossMove":
|
||||
case "move":
|
||||
return [req.src, req.dst];
|
||||
case "sweep":
|
||||
return [req.dir];
|
||||
case "unlink":
|
||||
return [req.path];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function runFsOp(req: FsOpRequest, ctx: FsOpContext): Promise<unknown> {
|
||||
assertLocalPaths(req.op, ...writePathsOf(req));
|
||||
const { signal, progress } = ctx;
|
||||
const at = (phase: string) => (bytes: number, total: number) => progress({ phase, bytes, total });
|
||||
switch (req.op) {
|
||||
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
runFsOp,
|
||||
type WireCopyOptions,
|
||||
type WireCrossMoveOptions,
|
||||
writePathsOf,
|
||||
} from "./fsops.ts";
|
||||
import type { CrossMoveResult, MoveResult } from "./move.ts";
|
||||
import { assertLocalPaths } from "./remote-guard.ts";
|
||||
import type { RootHealth, StatResult } from "./stat.ts";
|
||||
|
||||
export type { FsProgress } from "./fsops.ts";
|
||||
@@ -243,6 +245,8 @@ export class FsProxy {
|
||||
|
||||
async run(req: FsOpRequest, call: FsCallOptions = {}): Promise<unknown> {
|
||||
call.signal?.throwIfAborted();
|
||||
// 远端虚拟路径在父进程就拒绝,不起执行子进程
|
||||
assertLocalPaths(req.op, ...writePathsOf(req));
|
||||
if (this.#mode === "subprocess") {
|
||||
try {
|
||||
return await this.#runSubprocess(req, call);
|
||||
|
||||
@@ -4,5 +4,6 @@ export * from "./ffprobe.ts";
|
||||
export * from "./fsops.ts";
|
||||
export * from "./fsproxy.ts";
|
||||
export * from "./move.ts";
|
||||
export * from "./remote-guard.ts";
|
||||
export * from "./scan.ts";
|
||||
export * from "./stat.ts";
|
||||
@@ -4,6 +4,7 @@ import { link, lstat, rename, stat, unlink } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy.ts";
|
||||
import { errnoOf, FsError, toFsError } from "./errors.ts";
|
||||
import { assertLocalPaths } from "./remote-guard.ts";
|
||||
|
||||
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
|
||||
|
||||
@@ -13,6 +14,7 @@ export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "
|
||||
* 其他错误抛 FsError。
|
||||
*/
|
||||
export async function moveSafe(src: string, dst: string): Promise<MoveResult> {
|
||||
assertLocalPaths("move", src, dst);
|
||||
let s: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
s = await lstat(src);
|
||||
@@ -102,6 +104,7 @@ async function exists(p: string): Promise<boolean> {
|
||||
* - 删源失败 → attention(不能报成功)
|
||||
*/
|
||||
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
|
||||
assertLocalPaths("crossMove", src, dst);
|
||||
const method = opts.hash ?? "size";
|
||||
const io = {
|
||||
...(opts.signal ? { signal: opts.signal } : {}),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { commitNoClobber, copyWithVerify, sweepStalePartials } from "./copy.ts";
|
||||
import { FsError, fsErrorClass } from "./errors.ts";
|
||||
import { type FsOpRequest, runFsOp } from "./fsops.ts";
|
||||
import { FsProxy } from "./fsproxy.ts";
|
||||
import { crossDeviceMove, moveSafe } from "./move.ts";
|
||||
import { assertLocalPaths, isVirtualRemotePath } from "./remote-guard.ts";
|
||||
import { writeRootSentinel } from "./stat.ts";
|
||||
|
||||
// 远端虚拟路径守卫:Alist 路径(/@alist/…)传到本地写类操作一律抛 REMOTE_PATH_ON_LOCAL_FS,本机不能出现 /@alist。
|
||||
|
||||
const V = "/@alist/00000000-0000-4000-8000-0000000000ff/s1/lib";
|
||||
let dir: string;
|
||||
let src: string;
|
||||
beforeAll(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "umt-guard-"));
|
||||
src = join(dir, "a.mkv");
|
||||
await writeFile(src, "video");
|
||||
});
|
||||
afterAll(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const rejects = async (p: Promise<unknown> | (() => unknown)) => {
|
||||
let err: unknown = null;
|
||||
try {
|
||||
await (typeof p === "function" ? p() : p);
|
||||
} catch (e) {
|
||||
err = e;
|
||||
}
|
||||
expect(err).toBeInstanceOf(FsError);
|
||||
expect((err as FsError).code).toBe("REMOTE_PATH_ON_LOCAL_FS");
|
||||
expect((err as Error).message).toContain("remote_path_on_local_fs");
|
||||
expect(existsSync("/@alist")).toBe(false);
|
||||
expect(existsSync(src)).toBe(true);
|
||||
};
|
||||
|
||||
describe("远端虚拟路径守卫", () => {
|
||||
it("识别与分类", () => {
|
||||
expect(isVirtualRemotePath(V)).toBe(true);
|
||||
expect(isVirtualRemotePath("/@alist")).toBe(true);
|
||||
expect(isVirtualRemotePath("/@alistx/a")).toBe(false);
|
||||
expect(isVirtualRemotePath("/media/@alist/a")).toBe(false);
|
||||
expect(fsErrorClass("REMOTE_PATH_ON_LOCAL_FS")).toBe("manual");
|
||||
expect(() => assertLocalPaths("x", "/tmp/a", null, undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it("各写类函数:目标或源是虚拟路径都拒绝,不落盘", async () => {
|
||||
await rejects(copyWithVerify(src, `${V}/a.mkv`));
|
||||
await rejects(copyWithVerify(`${V}/a.mkv`, join(dir, "b.mkv")));
|
||||
await rejects(
|
||||
copyWithVerify(src, join(dir, "c.mkv"), {
|
||||
onExisting: "replace",
|
||||
replaceOld: { mode: "trash", trashDir: `${V}/.umt-trash` },
|
||||
}),
|
||||
);
|
||||
await rejects(crossDeviceMove(src, `${V}/a.mkv`));
|
||||
await rejects(moveSafe(src, `${V}/a.mkv`));
|
||||
await rejects(commitNoClobber(src, `${V}/a.mkv`));
|
||||
await rejects(sweepStalePartials(V, 0));
|
||||
await rejects(writeRootSentinel(V));
|
||||
expect(existsSync(join(dir, "b.mkv"))).toBe(false);
|
||||
});
|
||||
|
||||
const ctx = { signal: new AbortController().signal, progress: () => {} };
|
||||
const reqs: FsOpRequest[] = [
|
||||
{ op: "copy", src: "/tmp/x", dst: `${V}/a.mkv` },
|
||||
{ op: "crossMove", src: "/tmp/x", dst: `${V}/a.mkv` },
|
||||
{ op: "move", src: `${V}/a.mkv`, dst: "/tmp/y" },
|
||||
{ op: "sweep", dir: V, olderThanMs: 0 },
|
||||
{ op: "unlink", path: `${V}/a.mkv` },
|
||||
];
|
||||
it.each(reqs.map((r) => [r.op, r] as const))("runFsOp %s:拒绝", async (_op, req) => {
|
||||
await rejects(runFsOp(req, ctx));
|
||||
});
|
||||
|
||||
it("FsProxy.run(子进程与进程内两种模式):在父进程就拒绝,不起子进程", async () => {
|
||||
for (const mode of ["subprocess", "inprocess"] as const) {
|
||||
const px = new FsProxy({ mode });
|
||||
for (const req of reqs) await rejects(px.run(req));
|
||||
await rejects(px.copy(src, `${V}/a.mkv`));
|
||||
await rejects(px.moveSafe(src, `${V}/a.mkv`));
|
||||
await rejects(px.sweepStalePartials(V, 0));
|
||||
expect(px.stats().spawned).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("读类操作不拦(虚拟路径只会是不存在)", async () => {
|
||||
const px = new FsProxy({ mode: "inprocess" });
|
||||
expect(await px.statSafe(`${V}/a.mkv`)).toEqual({ kind: "missing" });
|
||||
expect(existsSync("/@alist")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { FsError } from "./errors.ts";
|
||||
|
||||
// 远端虚拟路径守卫(docs/design/storage-alist.md §0):Alist 路径在本系统里写成 /@alist/<存储 id>/<路径>。
|
||||
// 本地 fs 读它只会 ENOENT,但写类操作(mkdir -p、复制 / 移动目标、写文件、回收站落点)会在本机真建出
|
||||
// /@alist/... 并把文件写进去——静默写错位置。所有本地写入口先过这里:参数是远端路径就抛
|
||||
// REMOTE_PATH_ON_LOCAL_FS,不落盘。
|
||||
|
||||
export const REMOTE_PATH_PREFIX = "/@alist";
|
||||
export const isVirtualRemotePath = (p: unknown): boolean =>
|
||||
typeof p === "string" && (p === REMOTE_PATH_PREFIX || p.startsWith(`${REMOTE_PATH_PREFIX}/`));
|
||||
|
||||
/** 任一路径是远端虚拟路径 → 抛 FsError("REMOTE_PATH_ON_LOCAL_FS") */
|
||||
export function assertLocalPaths(op: string, ...paths: (string | null | undefined)[]): void {
|
||||
const hit = paths.find(isVirtualRemotePath);
|
||||
if (hit)
|
||||
throw new FsError(
|
||||
"REMOTE_PATH_ON_LOCAL_FS",
|
||||
`remote_path_on_local_fs: 本地文件操作 ${op} 收到 Alist 路径,已拒绝:${hit}`,
|
||||
{ path: hit },
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { lstat, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { errnoOf } from "./errors.ts";
|
||||
import { assertLocalPaths } from "./remote-guard.ts";
|
||||
|
||||
export const DEFAULT_ROOT_SENTINEL = ".uhd-root";
|
||||
|
||||
@@ -57,6 +58,7 @@ export async function checkRoot(root: string, sentinel: string = DEFAULT_ROOT_SE
|
||||
|
||||
/** 在根目录写入哨兵文件(首次登记库根时调用)。 */
|
||||
export async function writeRootSentinel(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<void> {
|
||||
assertLocalPaths("writeRootSentinel", root);
|
||||
await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, {
|
||||
flag: "wx",
|
||||
}).catch((e) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @umt/adapters 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/adapters";
|
||||
export * from "./alist/index.ts";
|
||||
export * from "./browser/index.ts";
|
||||
export * from "./emby/index.ts";
|
||||
export * from "./fs/index.ts";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { IdParam } from "../common.ts";
|
||||
import * as instances from "../instances.ts";
|
||||
import * as storage from "../storage.ts";
|
||||
import { crud, del, ep, instanceTest, R } from "./shared.ts";
|
||||
|
||||
export const instancesApi = {
|
||||
@@ -54,6 +55,27 @@ export const instancesApi = {
|
||||
test: instanceTest("/media-server-instances/{id}/test", "测试媒体服务器连接"),
|
||||
remove: del("/media-server-instances/{id}", "删除媒体服务器实例"),
|
||||
},
|
||||
storageInstances: {
|
||||
...crud(
|
||||
"/storage-instances",
|
||||
"存储实例",
|
||||
{
|
||||
row: storage.StorageInstance,
|
||||
create: storage.StorageInstanceCreate,
|
||||
update: storage.StorageInstanceUpdate,
|
||||
},
|
||||
R,
|
||||
),
|
||||
test: ep({
|
||||
method: "post",
|
||||
path: "/storage-instances/{id}/test",
|
||||
scope: "admin",
|
||||
summary: "测试 Alist / OpenList 连接(版本、登录用户、根路径、容量)",
|
||||
params: IdParam,
|
||||
response: storage.StorageTestResult,
|
||||
}),
|
||||
remove: del("/storage-instances/{id}", "删除存储实例"),
|
||||
},
|
||||
pathMappings: {
|
||||
...crud(
|
||||
"/path-mappings",
|
||||
|
||||
@@ -266,6 +266,14 @@ export const organizeApi = {
|
||||
body: files.FilesProbeRequest,
|
||||
response: files.FilesProbeResult,
|
||||
}),
|
||||
upload: ep({
|
||||
method: "put",
|
||||
path: "/files/upload",
|
||||
scope: "organize.execute",
|
||||
summary: "上传文件到目录(请求体为文件字节;同名不覆盖;Alist 根经服务端转传)",
|
||||
query: files.FilesUploadQuery,
|
||||
response: files.FilesUploadResult,
|
||||
}),
|
||||
download: ep({
|
||||
method: "get",
|
||||
path: "/files/download",
|
||||
|
||||
@@ -225,6 +225,10 @@ export const AuditAction = z.enum([
|
||||
"media_server_instance.update",
|
||||
"media_server_instance.delete",
|
||||
"media_server_instance.test",
|
||||
"storage_instance.create",
|
||||
"storage_instance.update",
|
||||
"storage_instance.delete",
|
||||
"storage_instance.test",
|
||||
"path_mapping.create",
|
||||
"path_mapping.update",
|
||||
"path_mapping.delete",
|
||||
|
||||
@@ -13,8 +13,10 @@ import { ScrapeMode, ScrapeRecord } from "./scrape.ts";
|
||||
// 移动/复制/整理/删除都是两段式:dryRun=true 取得 planId 与前后路径清单,执行时回传 planId;
|
||||
// 源文件 size/mtime 变化或 planId 过期/与请求不符 → 409 plan_stale。
|
||||
|
||||
/** `lib:<library_root.id>` 或 `dl:<downloader_instance.id>:<路径映射序号>` */
|
||||
export const FileRootId = z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i, { message: "非法根 id" });
|
||||
/** `lib:<library_root.id>`、`dl:<downloader_instance.id>:<路径映射序号>` 或 `st:<storage_instance.id>`(Alist 存储) */
|
||||
export const FileRootId = z
|
||||
.string()
|
||||
.regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3}|st:[0-9a-f-]{36})$/i, { message: "非法根 id" });
|
||||
export type FileRootId = z.infer<typeof FileRootId>;
|
||||
|
||||
/** 根内相对路径:不以 / 开头,不含 `..` 段与 NUL;服务端仍会 realpath 复核 */
|
||||
@@ -41,7 +43,7 @@ export const IdempotencyKey = z.string().min(8).max(200);
|
||||
|
||||
// ── 根 ──
|
||||
|
||||
export const FileRootKind = z.enum(["library", "download"]);
|
||||
export const FileRootKind = z.enum(["library", "download", "storage"]);
|
||||
export type FileRootKind = z.infer<typeof FileRootKind>;
|
||||
|
||||
export const FileRoot = z.object({
|
||||
@@ -59,6 +61,8 @@ export const FileRoot = z.object({
|
||||
/** 健康探测失败原因(离线时) */
|
||||
healthReason: z.string().nullable(),
|
||||
usage: z.object({ totalBytes: NonNegInt, freeBytes: NonNegInt }).nullable(),
|
||||
/** 根所在存储:本机为 null;Alist 根给实例 id 与名称(界面显示「存储名:/路径」) */
|
||||
storage: z.object({ id: z.string(), name: z.string(), path: z.string() }).nullable().default(null),
|
||||
/** G6:下载目录下待识别视频数(与列表"待识别"筛选同口径,递归,缓存 5 分钟);库根与离线根为 null */
|
||||
pendingCount: NonNegInt.nullable(),
|
||||
/** G6:递归超过扫描上限时为 true,pendingCount 是下限 */
|
||||
@@ -182,6 +186,8 @@ export const FilesListQuery = z.object({
|
||||
.optional(),
|
||||
/** 默认 false:隐藏点文件、.uhd-partial、.umt-trash、哨兵 */
|
||||
hidden: boolQuery.default(false),
|
||||
/** Alist 根:让 Alist 刷新目录缓存(穿透到网盘接口;只在用户点「刷新」时带) */
|
||||
refresh: boolQuery.default(false),
|
||||
});
|
||||
export type FilesListQuery = z.infer<typeof FilesListQuery>;
|
||||
|
||||
@@ -797,6 +803,20 @@ export type FilesProbeResult = z.infer<typeof FilesProbeResult>;
|
||||
|
||||
// 下载(W5-A2):GET /files/download 直接返回文件流(非 JSON 信封);仅管理员会话;
|
||||
// 仅 nfo/字幕/图片且 ≤ FILES_DOWNLOAD_MAX_BYTES(默认 20 MiB);inline=true 仅对图片生效(预览)。
|
||||
/**
|
||||
* 上传(PUT /files/upload,请求体是文件原始字节):放进 {root, path} 目录,文件名 name;同名已存在 409(不覆盖)。
|
||||
* Alist 根经服务端转传(先写本机临时文件再上传到 Alist,同 MP);本地根直接写临时文件后不覆盖改名。
|
||||
*/
|
||||
export const FilesUploadQuery = z.object({
|
||||
root: FileRootId,
|
||||
path: FileRelPath,
|
||||
name: FileName,
|
||||
idempotencyKey: IdempotencyKey,
|
||||
});
|
||||
export type FilesUploadQuery = z.infer<typeof FilesUploadQuery>;
|
||||
export const FilesUploadResult = z.object({ path: FileRelPath, size: NonNegInt });
|
||||
export type FilesUploadResult = z.infer<typeof FilesUploadResult>;
|
||||
|
||||
export const FilesDownloadQuery = z.object({ root: FileRootId, path: FileRelPath, inline: boolQuery.default(false) });
|
||||
export type FilesDownloadQuery = z.infer<typeof FilesDownloadQuery>;
|
||||
/** OpenAPI 描述用:实际响应为二进制流 */
|
||||
|
||||
@@ -31,5 +31,6 @@ export * from "./redact.ts";
|
||||
export * from "./scrape.ts";
|
||||
export * from "./settings.ts";
|
||||
export * from "./sources.ts";
|
||||
export * from "./storage.ts";
|
||||
export * from "./subtitles.ts";
|
||||
export * from "./system-resources.ts";
|
||||
@@ -168,6 +168,7 @@ export const SecretPurpose = z.enum([
|
||||
"media_server_api_key",
|
||||
"metadata_api_key",
|
||||
"llm_api_key",
|
||||
"storage_credential",
|
||||
"other",
|
||||
]);
|
||||
export type SecretPurpose = z.infer<typeof SecretPurpose>;
|
||||
@@ -448,7 +449,8 @@ export const SystemDisk = z.object({
|
||||
export type SystemDisk = z.infer<typeof SystemDisk>;
|
||||
|
||||
export const SystemMount = z.object({
|
||||
kind: z.enum(["library_root", "path_mapping"]),
|
||||
/** storage_instance = Alist / OpenList 实例(连通 + 根路径可访问;令牌失效为 offline/auth) */
|
||||
kind: z.enum(["library_root", "path_mapping", "storage_instance"]),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Id, JsonObject, Timestamp } from "./common.ts";
|
||||
|
||||
// Independent reference shape avoids settings → instances → settings import cycles.
|
||||
const SourceRef = z.object({
|
||||
root: z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3})$/i),
|
||||
root: z.string().regex(/^(lib:[0-9a-f-]{36}|dl:[0-9a-f-]{36}:\d{1,3}|st:[0-9a-f-]{36})$/i),
|
||||
path: z
|
||||
.string()
|
||||
.max(4096)
|
||||
|
||||
@@ -1085,9 +1085,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
id: "library",
|
||||
group: "flow",
|
||||
label: "媒体库与目录",
|
||||
description: "媒体库目录、整理来源、分类规则、命名模板",
|
||||
description: "存储、媒体库目录、整理来源、分类规则、命名模板",
|
||||
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
|
||||
anchors: [
|
||||
{ id: "storages", label: "存储", keywords: ["Alist", "OpenList", "网盘", "令牌"] },
|
||||
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
|
||||
{ id: "sources", label: "整理来源" },
|
||||
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
|
||||
@@ -1102,7 +1103,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
|
||||
"naming.variety",
|
||||
"naming.lockFileNameForBound",
|
||||
],
|
||||
resources: ["library-roots"],
|
||||
resources: ["storage-instances", "library-roots"],
|
||||
},
|
||||
{
|
||||
id: "organize",
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { z } from "zod";
|
||||
import { EntityBase, Id, JsonObject, Timestamp, WriteOnlySecret } from "./common.ts";
|
||||
import { InstanceHealth } from "./instances.ts";
|
||||
|
||||
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3)。凭据(令牌 / 密码)与路径密码只写不读。
|
||||
|
||||
export const StorageKind = z.enum(["alist"]);
|
||||
export type StorageKind = z.infer<typeof StorageKind>;
|
||||
/** 只用于展示与版本差异;接口相同(MP 的 AlistGo 是同实现的子类) */
|
||||
export const StorageFlavor = z.enum(["alist", "openlist"]);
|
||||
export type StorageFlavor = z.infer<typeof StorageFlavor>;
|
||||
/** token 永久令牌(Alist 后台「设置 → 其他 → 令牌」);password 账号密码换临时令牌;guest 访客 */
|
||||
export const StorageAuthKind = z.enum(["token", "password", "guest"]);
|
||||
export type StorageAuthKind = z.infer<typeof StorageAuthKind>;
|
||||
|
||||
/** 实例高级选项;不填用缺省(括号内) */
|
||||
export const StorageOptions = z
|
||||
.object({
|
||||
/** 每秒请求数(5);0 不限 */
|
||||
qps: z.number().int().min(0).max(100),
|
||||
/** 并发(4);0 不限 */
|
||||
concurrency: z.number().int().min(0).max(32),
|
||||
/** 普通请求超时秒(30) */
|
||||
timeoutSec: z.number().int().min(5).max(600),
|
||||
/** 上传 / 下载无进度空闲超时秒(120) */
|
||||
idleTimeoutSec: z.number().int().min(10).max(3600),
|
||||
/** 等待 Alist 复制 / 移动 / 上传任务的上限分钟(360) */
|
||||
taskTimeoutMin: z.number().int().min(1).max(10080),
|
||||
/** 可重试错误的重试次数(3) */
|
||||
retries: z.number().int().min(0).max(10),
|
||||
/** 上传用 As-Task(false,同 MP) */
|
||||
uploadAsTask: z.boolean(),
|
||||
/** 目录监控最大深度(5,同 MP) */
|
||||
snapshotMaxDepth: z.number().int().min(1).max(20),
|
||||
/** 目录监控按目录修改时间跳过未变子树(true) */
|
||||
snapshotCheckFolderModtime: z.boolean(),
|
||||
})
|
||||
.partial();
|
||||
export type StorageOptions = z.infer<typeof StorageOptions>;
|
||||
|
||||
export const STORAGE_OPTION_DEFAULTS: Required<StorageOptions> = {
|
||||
qps: 5,
|
||||
concurrency: 4,
|
||||
timeoutSec: 30,
|
||||
idleTimeoutSec: 120,
|
||||
taskTimeoutMin: 360,
|
||||
retries: 3,
|
||||
uploadAsTask: false,
|
||||
snapshotMaxDepth: 5,
|
||||
snapshotCheckFolderModtime: true,
|
||||
};
|
||||
|
||||
/** Alist 内路径:以 / 开头,不含 .. 段 */
|
||||
export const AlistPath = z
|
||||
.string()
|
||||
.max(4096)
|
||||
.refine((p) => p.startsWith("/") && !p.includes("\0") && !p.split("/").some((s) => s === ".." || s === "."), {
|
||||
message: "Alist 路径必须以 / 开头,不能含 . 或 ..",
|
||||
});
|
||||
|
||||
export const StorageInstance = EntityBase.extend({
|
||||
name: z.string().min(1).max(100),
|
||||
kind: StorageKind,
|
||||
flavor: StorageFlavor,
|
||||
baseUrl: z.url(),
|
||||
/** 本系统可浏览 / 使用的 Alist 根路径(缺省 /) */
|
||||
rootPath: AlistPath,
|
||||
authKind: StorageAuthKind,
|
||||
username: z.string().nullable(),
|
||||
secretRef: Id.nullable(),
|
||||
pathPasswordRef: Id.nullable(),
|
||||
enabled: z.boolean(),
|
||||
options: StorageOptions,
|
||||
healthStatus: InstanceHealth,
|
||||
lastHealthAt: Timestamp.nullable(),
|
||||
healthDetail: JsonObject,
|
||||
versionInfo: JsonObject,
|
||||
});
|
||||
export type StorageInstance = z.infer<typeof StorageInstance>;
|
||||
|
||||
export const StorageInstanceCreate = StorageInstance.pick({
|
||||
name: true,
|
||||
flavor: true,
|
||||
baseUrl: true,
|
||||
rootPath: true,
|
||||
authKind: true,
|
||||
username: true,
|
||||
enabled: true,
|
||||
options: true,
|
||||
})
|
||||
.partial({ flavor: true, rootPath: true, authKind: true, username: true, enabled: true, options: true })
|
||||
.extend({
|
||||
/** 令牌(authKind=token)或密码(authKind=password);只写 */
|
||||
credential: WriteOnlySecret(4096).optional(),
|
||||
/** 路径密码;只写 */
|
||||
pathPassword: WriteOnlySecret(1024).optional(),
|
||||
});
|
||||
export type StorageInstanceCreate = z.infer<typeof StorageInstanceCreate>;
|
||||
/** 更新:credential / pathPassword 省略或空串 = 不变,null = 清除 */
|
||||
export const StorageInstanceUpdate = StorageInstanceCreate.partial().extend({
|
||||
credential: WriteOnlySecret(4096).nullable().optional(),
|
||||
pathPassword: WriteOnlySecret(1024).nullable().optional(),
|
||||
});
|
||||
export type StorageInstanceUpdate = z.infer<typeof StorageInstanceUpdate>;
|
||||
|
||||
export const StorageUsage = z.object({ total: z.number(), free: z.number(), used: z.number() });
|
||||
export type StorageUsage = z.infer<typeof StorageUsage>;
|
||||
|
||||
export const StorageTestResult = z.object({
|
||||
ok: z.boolean(),
|
||||
healthStatus: InstanceHealth,
|
||||
version: z.string().nullable(),
|
||||
/** 登录用户(访客为 guest) */
|
||||
user: z.string().nullable(),
|
||||
/** 根路径存在且是目录 */
|
||||
rootOk: z.boolean(),
|
||||
/** 容量;驱动不给或令牌不是管理员时为 null(显示「未知」) */
|
||||
usage: StorageUsage.nullable(),
|
||||
message: z.string().nullable(),
|
||||
});
|
||||
export type StorageTestResult = z.infer<typeof StorageTestResult>;
|
||||
@@ -65,6 +65,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
|
||||
MediaAsset: { row: C.MediaAsset },
|
||||
MediaEntity: { row: C.MediaEntity },
|
||||
MediaServerInstance: { row: C.MediaServerInstance },
|
||||
StorageInstance: { row: C.StorageInstance },
|
||||
Observation: { row: C.Observation },
|
||||
OrganizationFileResult: { row: C.OrganizationFileResult },
|
||||
OrganizeSourceSnapshot: { row: C.OrganizeSourceSnapshotRow },
|
||||
@@ -224,6 +225,10 @@ const ENUMS: Record<string, z.ZodEnum> = {
|
||||
MediaEntityStatus: C.MediaEntityStatus,
|
||||
MediaServerInstanceKind: C.MediaServerKind,
|
||||
MediaServerInstanceHealthStatus: C.InstanceHealth,
|
||||
StorageInstanceKind: C.StorageKind,
|
||||
StorageInstanceFlavor: C.StorageFlavor,
|
||||
StorageInstanceAuthKind: C.StorageAuthKind,
|
||||
StorageInstanceHealthStatus: C.InstanceHealth,
|
||||
ObservationStatus: C.ObservationStatus,
|
||||
OrganizationFileResultRole: C.FileRole,
|
||||
OrganizationFileResultMode: C.OrganizationAction,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// Alist / OpenList 存储实例(docs/design/storage-alist.md §3):远端路径用虚拟前缀 /@alist/<id>/<路径> 表示,
|
||||
// 库根 local_path、整理记录、资产位置里都直接存这个路径,不需要新列。凭据走 secret 表(purpose storage_credential)。
|
||||
|
||||
const PURPOSE_OLD = [
|
||||
"site_cookie",
|
||||
"site_passkey",
|
||||
"site_api_key",
|
||||
"site_password",
|
||||
"feed_url",
|
||||
"downloader_password",
|
||||
"media_server_api_key",
|
||||
"metadata_api_key",
|
||||
"llm_api_key",
|
||||
"other",
|
||||
];
|
||||
const PURPOSE_NEW = [...PURPOSE_OLD.slice(0, -1), "storage_credential", "other"];
|
||||
const HEALTH = ["unknown", "healthy", "degraded", "offline"];
|
||||
|
||||
const swapPurpose = (values: readonly string[]) => `
|
||||
alter table secret drop constraint if exists secret_purpose_check;
|
||||
alter table secret add constraint secret_purpose_check ${oneOf("purpose", values)};
|
||||
`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(db, swapPurpose(PURPOSE_NEW));
|
||||
await execDdl(
|
||||
db,
|
||||
entityTable(
|
||||
"storage_instance",
|
||||
`
|
||||
name text not null unique,
|
||||
kind text not null default 'alist' ${oneOf("kind", ["alist"])},
|
||||
flavor text not null default 'openlist' ${oneOf("flavor", ["alist", "openlist"])},
|
||||
base_url text not null,
|
||||
root_path text not null default '/' check (root_path like '/%'),
|
||||
auth_kind text not null default 'token' ${oneOf("auth_kind", ["token", "password", "guest"])},
|
||||
username text,
|
||||
secret_ref uuid references secret(id) on delete set null,
|
||||
path_password_ref uuid references secret(id) on delete set null,
|
||||
enabled boolean not null default true,
|
||||
options jsonb not null default '{}'::jsonb,
|
||||
health_status text not null default 'unknown' ${oneOf("health_status", HEALTH)},
|
||||
last_health_at timestamptz,
|
||||
health_detail jsonb not null default '{}'::jsonb,
|
||||
version_info jsonb not null default '{}'::jsonb`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`drop table if exists storage_instance cascade;
|
||||
delete from secret where purpose = 'storage_credential';
|
||||
${swapPurpose(PURPOSE_OLD)}`,
|
||||
);
|
||||
}
|
||||
@@ -246,6 +246,7 @@ export type SecretPurpose =
|
||||
| "media_server_api_key"
|
||||
| "metadata_api_key"
|
||||
| "llm_api_key"
|
||||
| "storage_credential"
|
||||
| "other";
|
||||
export type ServerMappingItemType = "movie" | "series" | "season" | "episode" | "other";
|
||||
export type ServerMappingMatchStatus = "matched" | "ambiguous" | "unmatched" | "stale";
|
||||
@@ -260,6 +261,10 @@ export type SiteSnapshotKind = "periodic" | "backfill" | "live_search" | "manual
|
||||
export type SiteSnapshotStatus = "running" | "ok" | "partial" | "failed";
|
||||
export type SourceReleaseResolutionStatus = "unresolved" | "resolved" | "ambiguous" | "unidentifiable" | "rejected";
|
||||
export type SourceReleaseAvailability = "active" | "site_disabled" | "expired" | "unknown";
|
||||
export type StorageInstanceKind = "alist";
|
||||
export type StorageInstanceFlavor = "alist" | "openlist";
|
||||
export type StorageInstanceAuthKind = "token" | "password" | "guest";
|
||||
export type StorageInstanceHealthStatus = "unknown" | "healthy" | "degraded" | "offline";
|
||||
export type SubscriptionMediaKind = "movie" | "series" | "variety";
|
||||
export type SubscriptionMode = "once" | "continuous";
|
||||
export type SubscriptionSeasonScope = "current_season" | "following_seasons";
|
||||
@@ -1635,6 +1640,27 @@ export interface SseEventTable {
|
||||
payload: Generated<Json>;
|
||||
}
|
||||
|
||||
export interface StorageInstanceTable {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
kind: Generated<StorageInstanceKind>;
|
||||
flavor: Generated<StorageInstanceFlavor>;
|
||||
base_url: string;
|
||||
root_path: Generated<string>;
|
||||
auth_kind: Generated<StorageInstanceAuthKind>;
|
||||
username: string | null;
|
||||
secret_ref: string | null;
|
||||
path_password_ref: string | null;
|
||||
enabled: Generated<boolean>;
|
||||
options: Generated<Json>;
|
||||
health_status: Generated<StorageInstanceHealthStatus>;
|
||||
last_health_at: Timestamptz | null;
|
||||
health_detail: Generated<Json>;
|
||||
version_info: Generated<Json>;
|
||||
created_at: GeneratedTimestamptz;
|
||||
updated_at: GeneratedTimestamptz;
|
||||
}
|
||||
|
||||
export interface SubscriptionTable {
|
||||
id: Generated<string>;
|
||||
media_entity_id: string | null;
|
||||
@@ -1869,6 +1895,7 @@ export interface DB {
|
||||
slot_unit: SlotUnitTable;
|
||||
source_release: SourceReleaseTable;
|
||||
sse_event: SseEventTable;
|
||||
storage_instance: StorageInstanceTable;
|
||||
subscription: SubscriptionTable;
|
||||
subscription_history: SubscriptionHistoryTable;
|
||||
subtitle_write: SubtitleWriteTable;
|
||||
@@ -2115,6 +2142,9 @@ export type SourceReleaseUpdate = Updateable<SourceReleaseTable>;
|
||||
export type SseEventRow = Selectable<SseEventTable>;
|
||||
export type NewSseEvent = Insertable<SseEventTable>;
|
||||
export type SseEventUpdate = Updateable<SseEventTable>;
|
||||
export type StorageInstanceRow = Selectable<StorageInstanceTable>;
|
||||
export type NewStorageInstance = Insertable<StorageInstanceTable>;
|
||||
export type StorageInstanceUpdate = Updateable<StorageInstanceTable>;
|
||||
export type SubscriptionRow = Selectable<SubscriptionTable>;
|
||||
export type NewSubscription = Insertable<SubscriptionTable>;
|
||||
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
|
||||
|
||||
@@ -24,6 +24,7 @@ const EXPECTED_TABLES = [
|
||||
"downloader_instance",
|
||||
"media_server_instance",
|
||||
"path_mapping",
|
||||
"storage_instance",
|
||||
// 内容 / 身份
|
||||
"media_entity",
|
||||
"content_unit",
|
||||
|
||||
Reference in new issue
Block a user