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:
root committed 2026-10-07 00:48:53 +08:00
commit 339105e151
108 files changed
+7255 -214

No files matched your search

@@ -0,0 +1,176 @@
import { createMockAlist, serveMockAlist } from "@umt/adapters/alist-mock";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildHealth } from "../../src/modules/system/health.ts";
import { clearStorageCache, storageEntry } from "../../src/storage/registry.ts";
import { DEV_KEY, json } from "../helpers.ts";
import { bearer, createHarness, type Harness, pgAvailable } from "./harness.ts";
// 存储实例(Alist / OpenList)设置接口:只写凭据、测试连接(版本 / 用户 / 根路径 / 容量)、
// 令牌失效 → offline、被库根引用不能删、健康检查里的存储实例(docs/design/storage-alist.md §3、§4)。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("存储实例(真实 PG + 模拟 Alist HTTP)", () => {
let h: Harness;
let admin: ReturnType<typeof bearer>;
const mock = createMockAlist({
token: "perm-token-xyz",
username: "alice",
password: "pw-alice",
mountDetails: { "/s1": { total_space: 1000, free_space: 250, used_space: 750 } },
});
mock.mkdirp("/s1/media");
let srv: { base: string; close: () => Promise<void> };
beforeAll(async () => {
srv = serveMockAlist(mock);
h = await createHarness({ engine: false });
admin = bearer(h.app, DEV_KEY);
}, 60_000);
afterAll(async () => {
await h?.close();
await srv?.close();
});
it("新建(令牌):凭据只写不读;测试连接拿到版本、用户、根路径、容量", async () => {
const c = await admin("POST", "/storage-instances", {
name: "openlist-a",
baseUrl: `${srv.base}/`,
rootPath: "/s1/media/",
authKind: "token",
credential: "perm-token-xyz",
pathPassword: "folder-pw",
});
expect(c.status).toBe(200);
const row = (await json(c)).data;
expect(row.baseUrl).toBe(srv.base);
expect(row.rootPath).toBe("/s1/media");
expect(row.secretRef).toBeTruthy();
expect(row.pathPasswordRef).toBeTruthy();
expect(JSON.stringify(row)).not.toContain("perm-token-xyz");
expect(JSON.stringify(row)).not.toContain("folder-pw");
expect(await h.ctx.secrets.resolve(row.secretRef)).toBe("perm-token-xyz");
const t = (await json(await admin("POST", `/storage-instances/${row.id}/test`))).data;
expect(t).toMatchObject({ ok: true, healthStatus: "healthy", user: "alice", rootOk: true });
expect(t.version).toContain("v4");
expect(t.usage).toEqual({ total: 1000, free: 250, used: 750 });
const after = (await json(await admin("GET", `/storage-instances/${row.id}`))).data;
expect(after.healthStatus).toBe("healthy");
});
it("账号密码:缺密码 422;改成错密码后测试 = offline 且信息里没有密码", async () => {
const bad = await admin("POST", "/storage-instances", {
name: "pw-missing",
baseUrl: srv.base,
authKind: "password",
username: "alice",
});
expect(bad.status).toBe(422);
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "openlist-pw",
baseUrl: srv.base,
authKind: "password",
username: "alice",
credential: "pw-alice",
}),
)
).data;
expect((await json(await admin("POST", `/storage-instances/${c.id}/test`))).data.ok).toBe(true);
await admin("PATCH", `/storage-instances/${c.id}`, { credential: "wrong-secret-pw" });
const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data;
expect(t).toMatchObject({ ok: false, healthStatus: "offline" });
expect(t.message).toContain("登录失败");
expect(t.message).not.toContain("wrong-secret-pw");
});
it("永久令牌失效:测试与健康检查都报不可用", async () => {
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "openlist-badtoken",
baseUrl: srv.base,
rootPath: "/s1",
credential: "revoked-token",
}),
)
).data;
const t = (await json(await admin("POST", `/storage-instances/${c.id}/test`))).data;
expect(t.ok).toBe(false);
expect(t.message).toContain("令牌失效");
clearStorageCache();
const health = await buildHealth({
db: h.ctx.db,
engineSchema: "openworkflow",
dataDir: "/tmp",
self: { version: "t", startedAt: new Date(), sseConnections: 0 },
thresholds: { warnPercent: 85, criticalPercent: 95 },
});
const m = health.mounts.find((x) => x.kind === "storage_instance" && x.id === c.id);
expect(m).toMatchObject({ status: "offline", reason: "permission_denied" });
const ok = health.mounts.find((x) => x.kind === "storage_instance" && x.name === "openlist-a");
expect(ok?.status).toBe("online");
});
it("改凭据后注册表重建客户端;清除路径密码", async () => {
const row = (await json(await admin("GET", "/storage-instances?limit=50"))).data.items.find(
(x: { name: string }) => x.name === "openlist-a",
);
const e1 = await storageEntry(row.id);
expect(e1.secrets).toContain("folder-pw");
const r = (await json(await admin("PATCH", `/storage-instances/${row.id}`, { pathPassword: null }))).data;
expect(r.pathPasswordRef).toBeNull();
const e2 = await storageEntry(row.id);
expect(e2.client).not.toBe(e1.client);
expect(e2.secrets).not.toContain("folder-pw");
});
it("被媒体库目录引用时不能删;解除后可删,凭据一并删除", async () => {
const c = (
await json(
await admin("POST", "/storage-instances", {
name: "to-delete",
baseUrl: srv.base,
rootPath: "/s1",
credential: "perm-token-xyz",
}),
)
).data;
const lib = await admin("POST", "/library-roots", {
name: "Alist 电影",
localPath: `/@alist/${c.id}/s1/media`,
mediaKind: "movie",
});
expect(lib.status).toBe(200);
const libRow = (await json(lib)).data;
expect(libRow.sentinelFile).toBeNull();
const missing = await admin("POST", "/library-roots", {
name: "不存在的目录",
localPath: `/@alist/${c.id}/s1/nope`,
});
expect(missing.status).toBe(412);
const blocked = await admin("DELETE", `/storage-instances/${c.id}`);
expect(blocked.status).toBe(409);
const test = (await json(await admin("POST", `/library-roots/${libRow.id}/test`))).data;
expect(test.ok).toBe(true);
await admin("DELETE", `/library-roots/${libRow.id}`);
const del = await admin("DELETE", `/storage-instances/${c.id}`);
expect(del.status).toBe(200);
await expect(h.ctx.secrets.resolve(c.secretRef)).rejects.toThrow();
const audits = await h.ctx.db
.selectFrom("audit_event")
.select("action")
.where("action", "like", "storage_instance.%")
.execute();
expect(new Set(audits.map((a) => a.action))).toEqual(
new Set([
"storage_instance.create",
"storage_instance.update",
"storage_instance.test",
"storage_instance.delete",
]),
);
});
});
+11
View File
@@ -74,6 +74,8 @@ export interface FmHarness {
api(method: string, path: string, body?: unknown, key?: string): Promise<{ status: number; body: Loose }>;
/** 原始响应(非 JSON 接口,如 /files/download) */
raw(method: string, path: string, key?: string): Promise<Response>;
/** 带原始请求体(如 PUT /files/upload) */
rawBody(method: string, path: string, body: BodyInit, key?: string): Promise<Response>;
close(): Promise<void>;
}
@@ -264,6 +266,15 @@ export async function createFmHarness(
raw(method, path, key = DEV_KEY) {
return Promise.resolve(app.request(`/api/v1${path}`, { method, headers: authHeaders(key) }));
},
rawBody(method, path, body, key = DEV_KEY) {
return Promise.resolve(
app.request(`/api/v1${path}`, {
method,
headers: { ...authHeaders(key), "content-type": "application/octet-stream" },
body,
}),
);
},
async close() {
await ts.drop();
await rm(base, { recursive: true, force: true });
@@ -0,0 +1,240 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createFmHarness, type FmHarness, key, pgAvailable, SESSION } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 文件管理接入 Alist(对照 MP 存储切换 + Alist 浏览 / 新建目录 / 改名 / 删除 / 移动 / 复制 / 上传 / 下载):
// 根列表多出 st: 根、列目录一次 list(refresh 只在用户刷新时带)、删除进 Alist 上的 .umt-trash 并可还原、
// 存储内移动 / 复制走 Alist API、上传同名不覆盖、连不上返回 503 而不是「空目录」。
const skip = !(await pgAvailable());
describe.skipIf(skip)("文件管理:Alist 存储", () => {
let h: FmHarness;
let a: TestAlist;
beforeAll(async () => {
h = await createFmHarness("alist_files");
a = await addAlist(h.db, {
name: "OpenList",
mockOptions: { token: "t", mountDetails: { "/s1": { total_space: 100, free_space: 40, used_space: 60 } } },
rootPath: "/s1",
});
a.mock.put("/s1/movies/A.2020.1080p.mkv", "video-a");
a.mock.put("/s1/movies/A.2020.1080p.chs.srt", "sub-a");
a.mock.put("/s1/notes/readme.nfo", "<movie/>");
});
afterAll(async () => h?.close());
it("根列表:每个启用的 Alist 实例一个 st: 根,带存储名与容量", async () => {
const r = await h.api("GET", "/files/roots");
expect(r.status).toBe(200);
const st = r.body.data.items.find((x: { id: string }) => x.id === a.root);
expect(st).toMatchObject({
kind: "storage",
name: "OpenList",
health: "healthy",
writable: true,
storage: { id: a.id, name: "OpenList", path: "/s1" },
usage: { totalBytes: 100, freeBytes: 40 },
});
});
it("列目录:一次 list,不带 refresh;用户刷新才带", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(200);
expect(r.body.data.entries.map((e: { name: string }) => e.name)).toEqual([
"A.2020.1080p.chs.srt",
"A.2020.1080p.mkv",
]);
expect(r.body.data.entries.find((e: { name: string }) => e.name.endsWith(".mkv"))).toMatchObject({
type: "file",
size: 7,
fileKind: "video",
status: "unidentified",
});
const lists = a.mock.requests.filter((x) => x.path === "/api/fs/list");
expect(lists).toHaveLength(1);
expect(lists[0]!.body!.refresh).toBe(false);
await h.api("GET", `/files?root=${a.root}&path=movies&refresh=true`);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list").at(-1)!.body!.refresh).toBe(true);
});
it("新建目录 / 改名(带字幕)/ 同名不覆盖", async () => {
const mk = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(mk.status).toBe(200);
expect(a.mock.exists("/s1/新目录")).toBe(true);
const dup = await h.api("POST", "/files/mkdir", { idempotencyKey: key(), root: a.root, path: "", name: "新目录" });
expect(dup.status).toBe(409);
const rn = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/A.2020.1080p.mkv",
newName: "A (2020).mkv",
withSidecars: true,
});
expect(rn.status).toBe(200);
expect(a.mock.listNames("/s1/movies")).toEqual(["A (2020).chs.srt", "A (2020).mkv"]);
a.mock.put("/s1/movies/B.mkv", "b");
const clash = await h.api("POST", "/files/rename", {
idempotencyKey: key(),
root: a.root,
path: "movies/B.mkv",
newName: "A (2020).mkv",
});
expect(clash.status).toBe(409);
expect(text(a.mock.read("/s1/movies/A (2020).mkv"))).toBe("video-a");
});
it("上传:流式转传到 Alist,同名不覆盖", async () => {
const r = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=${encodeURIComponent("新 文件.txt")}&idempotencyKey=${key()}`,
"hello alist",
);
expect(r.status).toBe(200);
expect((await r.json()).data).toEqual({ path: "notes/新 文件.txt", size: 11 });
expect(text(a.mock.read("/s1/notes/新 文件.txt"))).toBe("hello alist");
const again = await h.rawBody(
"PUT",
`/files/upload?root=${a.root}&path=notes&name=readme.nfo&idempotencyKey=${key()}`,
"overwrite?",
);
expect(again.status).toBe(409);
expect(text(a.mock.read("/s1/notes/readme.nfo"))).toBe("<movie/>");
// 本地根同样可以上传
const local = await h.rawBody(
"PUT",
`/files/upload?root=${h.libRoot}&path=&name=up.txt&idempotencyKey=${key()}`,
"local",
);
expect(local.status).toBe(200);
expect(await readFile(join(h.lib, "up.txt"), "utf8")).toBe("local");
});
it("下载与预览(管理员会话):流式转发 Alist 直链", async () => {
const r = await h.raw("GET", `/files/download?root=${a.root}&path=notes/readme.nfo`, SESSION);
expect(r.status).toBe(200);
expect(await r.text()).toBe("<movie/>");
const p = await h.api("GET", `/files/preview?root=${a.root}&path=notes/readme.nfo`, undefined, SESSION);
expect(p.status).toBe(200);
expect(JSON.stringify(p.body.data)).toContain("movie");
});
it("删除进 Alist 上的回收站,可还原", async () => {
const del = await h.api("POST", "/files/delete", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
dryRun: false,
});
expect(del.status).toBe(200);
expect(del.body.data.trashed).toHaveLength(1);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
const trashPath = del.body.data.trashed[0].trashPath as string;
expect(trashPath.startsWith(a.v("/s1/.umt-trash/"))).toBe(true);
const restore = await h.api("POST", "/files/trash/restore", {
idempotencyKey: key(),
ids: [del.body.data.trashed[0].id],
});
expect(restore.status).toBe(200);
expect(restore.body.data.restored).toHaveLength(1);
expect(text(a.mock.read("/s1/movies/B.mkv"))).toBe("b");
});
it("存储内复制 / 移动走 Alist API(复制源保留,移动源消失)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: true,
});
expect(dry.status).toBe(200);
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "notes/readme.nfo" }],
target: { root: a.root, path: "新目录" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.status).toBe(200);
expect(exec.body.data.executed).toMatchObject({ succeeded: ["notes/readme.nfo"], failed: [] });
expect(text(a.mock.read("/s1/新目录/readme.nfo"))).toBe("<movie/>");
expect(a.mock.exists("/s1/notes/readme.nfo")).toBe(true);
expect(a.mock.requests.some((x) => x.path === "/api/fs/copy")).toBe(true);
const mdry = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: true,
});
const mexec = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/B.mkv" }],
target: { root: a.root, path: "新目录" },
mode: "move",
dryRun: false,
planId: mdry.body.data.planId,
});
expect(mexec.status).toBe(200);
expect(mexec.body.data.executed.failed).toEqual([]);
expect(a.mock.exists("/s1/新目录/B.mkv")).toBe(true);
expect(a.mock.exists("/s1/movies/B.mkv")).toBe(false);
});
it("复制到本地媒体库(下载到本地)与从本地复制到 Alist 媒体库(上传)", async () => {
const dry = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: true,
});
const exec = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: a.root, path: "movies/A (2020).mkv" }],
target: { root: h.libRoot, path: "" },
dryRun: false,
planId: dry.body.data.planId,
});
expect(exec.body.data.executed.failed).toEqual([]);
expect(await readFile(join(h.lib, "A (2020).mkv"), "utf8")).toBe("video-a");
const alib = await addAlistLibrary(h.db, a, "/s1/library");
await writeFile(join(h.lib, "local.mkv"), "local-video");
const d2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: true,
});
expect(d2.status).toBe(200);
const e2 = await h.api("POST", "/files/copy", {
idempotencyKey: key(),
items: [{ root: h.libRoot, path: "local.mkv" }],
target: { root: alib.root, path: "" },
dryRun: false,
planId: d2.body.data.planId,
});
expect(e2.body.data.executed.failed).toEqual([]);
expect(text(a.mock.read("/s1/library/local.mkv"))).toBe("local-video");
});
it("目录树:Alist 只列当前层,不逐个子目录再请求", async () => {
a.mock.requests.length = 0;
const r = await h.api("GET", `/files/tree?root=${a.root}&path=`);
expect(r.status).toBe(200);
expect(r.body.data.dirs.map((x: { name: string }) => x.name)).toEqual(
expect.arrayContaining(["movies", "notes", "新目录"]),
);
expect(a.mock.requests.filter((x) => x.path === "/api/fs/list")).toHaveLength(1);
});
it("Alist 连不上:列目录 503,不当成空目录", async () => {
a.mock.failNext(5);
const r = await h.api("GET", `/files?root=${a.root}&path=movies`);
expect(r.status).toBe(503);
expect(r.body.error.message).toMatch(/^root_unavailable:/);
});
});
@@ -0,0 +1,339 @@
import { access, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 整理矩阵(docs/design/storage-alist.md §5):本地→Alist(上传)、Alist→本地(下载)、同实例 Alist→Alist(API copy/move)、
// 跨实例(下载 → 上传),复制 / 移动各一条;成功判定同 MP(接口成功 + 目标存在),移动成功后删源;
// 失败路径:上传中断 / 上传后目标取不到 / 下载中断都不删源,连不上按可重试处理而不是「源不存在」。
const skip = !(await pgAvailable());
const exists = (p: string) =>
access(p).then(
() => true,
() => false,
);
describe.skipIf(skip)("整理矩阵:本地 ↔ Alist ↔ Alist", () => {
let h: FmHarness;
let a: TestAlist;
let b: TestAlist;
let alib: { libraryRootId: string; root: string };
let blib: { libraryRootId: string; root: string };
let n = 0;
beforeAll(async () => {
h = await createFmHarness("alist_matrix");
a = await addAlist(h.db, { name: "A", rootPath: "/s1" });
b = await addAlist(h.db, { name: "B", rootPath: "/" });
alib = await addAlistLibrary(h.db, a, "/s1/library", "A 媒体库");
blib = await addAlistLibrary(h.db, b, "/m/library", "B 媒体库");
a.mock.mkdirp("/s1/incoming");
});
afterAll(async () => h?.close());
/** 新建一部电影作品 + 源文件,返回作品 id 与源路径(相对源根) */
async function film(where: "local" | TestAlist) {
n++;
const title = `Matrix Film ${n}`;
const id = await seedEntity(h.db, { kind: "movie", title, year: 2000 + n });
const name = `Matrix.Film.${n}.${2000 + n}.1080p.WEB-DL.mkv`;
const body = `video-${n}-${"x".repeat(32)}`;
if (where === "local") {
await writeFile(join(h.dl, name), body);
return { id, root: h.dlRoot, path: name, body, title, year: 2000 + n };
}
where.mock.put(`${where === a ? "/s1/incoming" : "/m/incoming"}/${name}`, body);
return {
id,
root: where.root,
path: where === a ? `incoming/${name}` : `m/incoming/${name}`,
body,
title,
year: 2000 + n,
};
}
/** dryRun → 执行 → 跑 executeRecord(模拟 worker);返回记录与逐文件结果 */
async function organize(
src: { id: string; root: string; path: string },
action: "copy" | "move",
targetLibraryRootId: string,
) {
const items = [{ root: src.root, path: src.path, mediaEntityId: src.id }];
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: true,
});
expect(dry.status, JSON.stringify(dry.body)).toBe(200);
expect(dry.body.data.groups[0].error).toBeNull();
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
expect(exec.status, JSON.stringify(exec.body)).toBe(200);
const recordId = exec.body.data.recordIds[0] as string;
let thrown: unknown = null;
await executeRecord(h.module.deps().ao, recordId).catch((e) => {
thrown = e;
});
const rec = await h.db
.selectFrom("organization_record")
.selectAll()
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const files = await h.db
.selectFrom("organization_file_result")
.selectAll()
.where("organization_record_id", "=", recordId)
.execute();
return { rec, files, thrown, main: files.find((f) => f.role === "main")! };
}
const assetAt = (libraryRootId: string, rel: string) =>
h.db
.selectFrom("asset_location")
.select(["id", "status"])
.where("library_root_id", "=", libraryRootId)
.where("relative_path", "=", rel)
.executeTakeFirst();
it("本地 → Alist 复制:上传到媒体库目录,登记资产,源保留,记下本地 sha256", async () => {
const f = await film("local");
const r = await organize(f, "copy", alib.libraryRootId);
expect(r.rec.status).toBe("succeeded");
expect(r.main.target_path).toContain(`${f.title} (${f.year})`);
expect(text(a.mock.read(`/s1/library/${r.main.target_path}`))).toBe(f.body);
expect(await exists(join(h.dl, f.path))).toBe(true);
expect(r.main.checksum).toMatch(/^sha256:[0-9a-f]{64}$/);
expect((await assetAt(alib.libraryRootId, r.main.target_path!))?.status).toBe("available");
expect(a.mock.requests.some((x) => x.path === "/api/fs/put")).toBe(true);
});
it("本地 → Alist 移动:上传成功后删本地源", async () => {
const f = await film("local");
const r = await organize(f, "move", alib.libraryRootId);
expect(r.rec.status).toBe("succeeded");
expect(text(a.mock.read(`/s1/library/${r.main.target_path}`))).toBe(f.body);
expect(await exists(join(h.dl, f.path))).toBe(false);
});
it("Alist → 本地 复制 / 移动:下载到本地媒体库;移动后删远端源", async () => {
const c = await film(a);
const rc = await organize(c, "copy", h.libraryRootId);
expect(rc.rec.status).toBe("succeeded");
expect(await readFile(join(h.lib, rc.main.target_path!), "utf8")).toBe(c.body);
expect(a.mock.exists(`/s1/${c.path}`)).toBe(true);
expect(rc.main.checksum).toMatch(/^sha256:/);
const m = await film(a);
const rm = await organize(m, "move", h.libraryRootId);
expect(rm.rec.status).toBe("succeeded");
expect(await readFile(join(h.lib, rm.main.target_path!), "utf8")).toBe(m.body);
expect(a.mock.exists(`/s1/${m.path}`)).toBe(false);
});
it("Alist → Alist 同实例:复制 / 移动走 Alist API(等任务完成)", async () => {
const c = await film(a);
a.mock.requests.length = 0;
const rc = await organize(c, "copy", alib.libraryRootId);
expect(rc.rec.status).toBe("succeeded");
expect(text(a.mock.read(`/s1/library/${rc.main.target_path}`))).toBe(c.body);
expect(a.mock.exists(`/s1/${c.path}`)).toBe(true);
expect(a.mock.requests.some((x) => x.path === "/api/fs/copy")).toBe(true);
expect(a.mock.requests.some((x) => x.path.startsWith("/api/task/copy/info"))).toBe(true);
expect(a.mock.requests.some((x) => x.path === "/api/fs/put")).toBe(false);
const m = await film(a);
const rm = await organize(m, "move", alib.libraryRootId);
expect(rm.rec.status).toBe("succeeded");
expect(text(a.mock.read(`/s1/library/${rm.main.target_path}`))).toBe(m.body);
expect(a.mock.exists(`/s1/${m.path}`)).toBe(false);
});
it("Alist → Alist 跨实例:下载到本机中转再上传;移动后删源", async () => {
const c = await film(a);
const rc = await organize(c, "copy", blib.libraryRootId);
expect(rc.rec.status).toBe("succeeded");
expect(text(b.mock.read(`/m/library/${rc.main.target_path}`))).toBe(c.body);
expect(a.mock.exists(`/s1/${c.path}`)).toBe(true);
const m = await film(a);
const rm = await organize(m, "move", blib.libraryRootId);
expect(rm.rec.status).toBe("succeeded");
expect(text(b.mock.read(`/m/library/${rm.main.target_path}`))).toBe(m.body);
expect(a.mock.exists(`/s1/${m.path}`)).toBe(false);
});
it("失败:上传中断 → 不删源、远端不留临时文件,按可重试等待", async () => {
const f = await film("local");
a.mock.breakUploadAfter(8);
const r = await organize(f, "move", alib.libraryRootId);
expect(String(r.thrown)).toContain("可重试失败");
expect(await exists(join(h.dl, f.path))).toBe(true);
expect(r.main.status).toBe("running");
const op = await h.db
.selectFrom("asset_operation")
.select("error")
.where("organization_record_id", "=", r.rec.id)
.executeTakeFirstOrThrow();
expect((op.error as { code: string }).code).toBe("IO");
const dir = `/s1/library/${f.title} (${f.year})`;
expect((a.mock.listNames(dir) ?? []).filter((x) => x.endsWith(".uhd-partial"))).toEqual([]);
// 重试(网络恢复)后完成并删源
await executeRecord(h.module.deps().ao, r.rec.id);
const done = await h.db
.selectFrom("organization_record")
.select("status")
.where("id", "=", r.rec.id)
.executeTakeFirstOrThrow();
expect(done.status).toBe("succeeded");
expect(await exists(join(h.dl, f.path))).toBe(false);
});
it("失败:上传接口成功但目标取不到 → 判失败,不删源", async () => {
const f = await film("local");
a.mock.dropNextUpload();
const r = await organize(f, "move", alib.libraryRootId);
expect(await exists(join(h.dl, f.path))).toBe(true);
const op = await h.db
.selectFrom("asset_operation")
.select(["error", "status"])
.where("organization_record_id", "=", r.rec.id)
.executeTakeFirstOrThrow();
expect(op.error).toBeTruthy();
});
it("失败:下载中断 → 不生成目标、不删远端源", async () => {
const f = await film(a);
a.mock.breakDownloadAfter(4);
const r = await organize(f, "move", h.libraryRootId);
expect(String(r.thrown)).toContain("可重试失败");
expect(a.mock.exists(`/s1/${f.path}`)).toBe(true);
const target = join(h.lib, `${f.title} (${f.year})`);
const names = await import("node:fs/promises").then((m) => m.readdir(target).catch(() => [] as string[]));
expect(names.filter((x) => x.endsWith(".mkv"))).toEqual([]);
});
it("连不上 Alist:按可重试 IO 处理,不当成「源不存在」", async () => {
const f = await film(a);
const items = [{ root: f.root, path: f.path, mediaEntityId: f.id }];
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
targetLibraryRootId: h.libraryRootId,
dryRun: true,
});
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action: "copy",
targetLibraryRootId: h.libraryRootId,
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
const recordId = exec.body.data.recordIds[0];
a.mock.failNext(50);
await expect(executeRecord(h.module.deps().ao, recordId)).rejects.toThrow(/可重试失败/);
const op = await h.db
.selectFrom("asset_operation")
.select("error")
.where("organization_record_id", "=", recordId)
.executeTakeFirstOrThrow();
expect((op.error as { code: string }).code).not.toBe("SOURCE_MISSING");
a.mock.failNext(0);
});
it("目标已存在选替换:旧文件移进 Alist 上的回收站,新文件上传到位", async () => {
const f = await film("local");
const first = await organize(f, "copy", alib.libraryRootId);
const target = `/s1/library/${first.main.target_path}`;
a.mock.put(target, "old-version-content");
await writeFile(join(h.dl, f.path), `${f.body}-v2`);
const items = [{ root: f.root, path: f.path, mediaEntityId: f.id }];
const base = { items, action: "copy", targetLibraryRootId: alib.libraryRootId, onExisting: "replace_with_verify" };
const dry = await h.api("POST", "/files/organize", { ...base, idempotencyKey: key(), dryRun: true });
expect(dry.body.data.groups[0].files[0]).toMatchObject({ existing: "replace", action: "replace" });
const exec = await h.api("POST", "/files/organize", {
...base,
idempotencyKey: key(),
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
const recordId = exec.body.data.recordIds[0] as string;
await executeRecord(h.module.deps().ao, recordId);
const rec = await h.db
.selectFrom("organization_record")
.select("status")
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
expect(rec.status).toBe("succeeded");
expect(text(a.mock.read(target))).toBe(`${f.body}-v2`);
const trash = await h.db
.selectFrom("trash_item")
.select(["trash_path", "original_path"])
.where("original_path", "=", first.main.target_path!)
.executeTakeFirstOrThrow();
expect(trash.trash_path.startsWith(a.v("/s1/library/.umt-trash/"))).toBe(true);
const parked = trash.trash_path.replace(a.v(""), "");
expect(text(a.mock.read(parked))).toBe("old-version-content");
});
it("原地整理(Alist 媒体库里的文件):只登记,不动文件", async () => {
n++;
const id = await seedEntity(h.db, { kind: "movie", title: "In Place Film", year: 2015 });
a.mock.put("/s1/library/In Place Film (2015)/In.Place.Film.2015.1080p.mkv", "in-place-video");
const items = [{ root: alib.root, path: "In Place Film (2015)/In.Place.Film.2015.1080p.mkv", mediaEntityId: id }];
const base = { items, action: "in_place", minSizeBytes: 0, scrapeAfterImport: false };
const dry = await h.api("POST", "/files/organize", { ...base, idempotencyKey: key(), dryRun: true });
expect(dry.body.data.groups[0].error, JSON.stringify(dry.body)).toBeNull();
const exec = await h.api("POST", "/files/organize", {
...base,
idempotencyKey: key(),
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
const recordId = exec.body.data.recordIds[0] as string;
await executeRecord(h.module.deps().ao, recordId);
const rec = await h.db
.selectFrom("organization_record")
.select(["status", "action"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
expect(rec).toEqual({ status: "succeeded", action: "in_place" });
expect(text(a.mock.read("/s1/library/In Place Film (2015)/In.Place.Film.2015.1080p.mkv"))).toBe("in-place-video");
expect((await assetAt(alib.libraryRootId, "In Place Film (2015)/In.Place.Film.2015.1080p.mkv"))?.status).toBe(
"available",
);
});
it("撤销:删除整理记录连带媒体库文件 → Alist 上移进回收站(可还原)", async () => {
const f = await film("local");
const r = await organize(f, "copy", alib.libraryRootId);
expect(r.rec.status).toBe("succeeded");
const del = await h.api("POST", "/organization-records/delete", {
ids: [r.rec.id],
deleteSource: false,
deleteTarget: true,
dryRun: false,
});
expect(del.status, JSON.stringify(del.body)).toBe(200);
expect(a.mock.exists(`/s1/library/${r.main.target_path}`)).toBe(false);
const trash = await h.db
.selectFrom("trash_item")
.select(["trash_path", "library_root_id"])
.where("library_root_id", "=", alib.libraryRootId)
.execute();
expect(trash.length).toBeGreaterThan(0);
expect(trash[0]!.trash_path.startsWith(a.v("/s1/library/.umt-trash/"))).toBe(true);
});
});
@@ -0,0 +1,158 @@
import { randomBytes } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { type AlistAuth, AlistClient } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { overrideStorage } from "../../src/storage/registry.ts";
import { toVPath } from "../../src/storage/vpath.ts";
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "../file-manager/harness.ts";
// 真实 OpenList 上跑一次整理往返(环境变量开启,缺省跳过;变量同 packages/adapters/src/alist/alist.real.test.ts):
// 本地下载目录 → 整理(复制)到 OpenList 上的媒体库 → 从 OpenList 整理(移动)回本地媒体库 → OpenList 内移动。
const URL_ = Deno.env.get("UMT_ALIST_REAL_URL");
const ROOT = Deno.env.get("UMT_ALIST_REAL_ROOT");
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 && (await pgAvailable());
describe.skipIf(!enabled)("真实 OpenList:整理往返 本地 → OpenList → 本地", () => {
let h: FmHarness;
let client: AlistClient;
let storageId: string;
let base: string;
let libId: string;
const body = `real-video-${randomBytes(64).toString("hex")}`;
beforeAll(async () => {
h = await createFmHarness("alist_real", {}, { runTransfers: false });
client = new AlistClient({ baseUrl: URL_!, auth: auth! });
base = `${ROOT!.replace(/\/+$/, "")}/umt-org-${randomBytes(3).toString("hex")}`;
await client.mkdir(`${base}/library`);
await client.mkdir(`${base}/incoming`);
const row = await h.db
.insertInto("storage_instance")
.values({ name: "真实 OpenList", base_url: URL_!, root_path: base })
.returning("id")
.executeTakeFirstOrThrow();
storageId = row.id;
overrideStorage(storageId, { client, name: "真实 OpenList", rootPath: base });
libId = (
await h.db
.insertInto("library_root")
.values({
name: "OpenList 媒体库",
media_kind: "mixed",
local_path: toVPath(storageId, `${base}/library`),
management_mode: "managed",
sentinel_file: null,
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}, 60_000);
afterAll(async () => {
await client?.removePath(base).catch(() => undefined);
await h?.close();
});
async function organize(items: unknown[], action: "copy" | "move", targetLibraryRootId: string) {
const dry = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: true,
});
expect(dry.body.data.groups[0].error, JSON.stringify(dry.body)).toBeNull();
const exec = await h.api("POST", "/files/organize", {
idempotencyKey: key(),
items,
action,
targetLibraryRootId,
dryRun: false,
planIds: [dry.body.data.groups[0].planId],
});
const recordId = exec.body.data.recordIds[0] as string;
await executeRecord(h.module.deps().ao, recordId);
const rec = await h.db
.selectFrom("organization_record")
.select(["status"])
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const main = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "checksum", "status"])
.where("organization_record_id", "=", recordId)
.where("role", "=", "main")
.executeTakeFirstOrThrow();
return { status: rec.status, main };
}
it("本地 → OpenList(复制)→ 本地(移动)→ OpenList 内移动", async () => {
const film = await seedEntity(h.db, { kind: "movie", title: "Real Round Trip", year: 2024 });
const name = "Real.Round.Trip.2024.1080p.WEB-DL.mkv";
await writeFile(join(h.dl, name), body);
const up = await organize([{ root: h.dlRoot, path: name, mediaEntityId: film }], "copy", libId);
console.log(`[real] 本地 → OpenList 复制:${up.status},目标 ${up.main.target_path},${up.main.checksum}`);
expect(up.status).toBe("succeeded");
const remoteFile = `${base}/library/${up.main.target_path}`;
expect((await client.get(remoteFile, { refresh: true })).size).toBe(body.length);
// 从 OpenList 移回本地媒体库(文件管理「移动到」:下载成功后删远端源,已登记位置跟随到本地)
const rel = `library/${up.main.target_path}`;
const src = { root: `st:${storageId}`, path: rel };
const dryMv = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [src],
target: { root: h.libRoot, path: "" },
mode: "move",
dryRun: true,
});
expect(dryMv.status, JSON.stringify(dryMv.body)).toBe(200);
const mv = await h.api("POST", "/files/move", {
idempotencyKey: key(),
items: [src],
target: { root: h.libRoot, path: "" },
mode: "move",
dryRun: false,
planId: dryMv.body.data.planId,
});
expect(mv.status, JSON.stringify(mv.body)).toBe(200);
const recordId = mv.body.data.executed.recordId as string;
await executeRecord(h.module.deps().ao, recordId);
const back = await h.db
.selectFrom("organization_record")
.select("status")
.where("id", "=", recordId)
.executeTakeFirstOrThrow();
const localName = up.main.target_path!.split("/").pop()!;
console.log(`[real] OpenList → 本地 移动:${back.status},目标 ${localName}`);
expect(back.status).toBe("succeeded");
expect(await readFile(join(h.lib, localName), "utf8")).toBe(body);
expect(await client.stat(remoteFile, { refresh: true })).toBeNull();
// OpenList 内整理(同实例 API move):另一部作品放在 OpenList 的 incoming
const film2 = await seedEntity(h.db, { kind: "movie", title: "Real Inside Move", year: 2023 });
const name2 = "Real.Inside.Move.2023.1080p.WEB-DL.mkv";
await writeFile(join(h.dl, name2), body);
await client.upload(join(h.dl, name2), `${base}/incoming/${name2}`);
const inside = await organize(
[{ root: `st:${storageId}`, path: `incoming/${name2}`, mediaEntityId: film2 }],
"move",
libId,
);
console.log(`[real] OpenList 内移动:${inside.status},目标 ${inside.main.target_path}`);
expect(inside.status).toBe("succeeded");
expect(await client.stat(`${base}/incoming/${name2}`, { refresh: true })).toBeNull();
expect((await client.get(`${base}/library/${inside.main.target_path}`, { refresh: true })).size).toBe(body.length);
}, 120_000);
});
@@ -0,0 +1,213 @@
import { randomUUID } from "node:crypto";
import type { OrganizeSourceConfig } from "@umt/contracts";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runScrapeRecord, scrapeEntity } from "../../src/modules/acquisition-organize/services/scrape.ts";
import { saveOrganizeSource } from "../../src/modules/file-manager/organize-sources.ts";
import { scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts";
import { createFmHarness, type FmHarness, pgAvailable, seedEntity } from "../file-manager/harness.ts";
import { addAlist, addAlistLibrary, type TestAlist, text } from "./alist.ts";
// 刮削写远端(NFO / 海报 / 背景 / clearlogo 本地生成后上传;已存在策略与本地一致)
// 与目录监控发现 Alist 新文件(快照两次稳定才整理、深度上限、按目录修改时间跳过未变子树、列表失败保留基线)。
const skip = !(await pgAvailable());
const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]);
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
describe.skipIf(skip)("Alist:刮削写远端", () => {
let h: FmHarness;
let a: TestAlist;
let lib: { libraryRootId: string; root: string };
beforeAll(async () => {
h = await createFmHarness(
"alist_scrape",
{
ao: {
async scrapeImage(p) {
return p.endsWith(".png") ? { content: PNG, mime: "image/png" } : { content: JPEG, mime: "image/jpeg" };
},
async scrapeTmdbDetail() {
throw new Error("不应请求 TMDB:本地作品有元数据");
},
},
},
{ runTransfers: false },
);
a = await addAlist(h.db, { name: "OL", rootPath: "/s1" });
lib = await addAlistLibrary(h.db, a, "/s1/movies");
});
afterAll(async () => h?.close());
const run = async (entity: string, mode: "missing" | "overwrite") => {
const recs = await scrapeEntity(h.module.deps().ao, entity, { trigger: "manual", mode });
for (const r of recs) await runScrapeRecord(h.module.deps().ao, r.recordId);
return h.db
.selectFrom("scrape_record")
.selectAll()
.where(
"id",
"in",
recs.map((r) => r.recordId),
)
.execute();
};
it("NFO 与图片(含 clearlogo)写到 Alist 上作品目录;只补缺失跳过已有,全部覆盖改写", async () => {
const film = await seedEntity(h.db, { kind: "movie", title: "星际穿越", year: 2014 });
await h.db
.updateTable("media_entity")
.set({
metadata: JSON.stringify({
overview: { value: "穿越虫洞" },
posterPath: { value: "/inter.jpg" },
backdropPath: { value: "/inter-bg.jpg" },
logoPath: { value: "/inter-logo.png" },
}),
})
.where("id", "=", film)
.execute();
await h.db
.insertInto("external_identity")
.values({
media_entity_id: film,
provider: "tmdb",
entity_type: "movie",
external_id: "157336",
status: "accepted",
})
.execute();
a.mock.put("/s1/movies/星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv", "video");
const asset = await h.db
.insertInto("media_asset")
.values({ media_entity_id: film, kind: "video", size_bytes: 5 })
.returning("id")
.executeTakeFirstOrThrow();
await h.db
.insertInto("asset_location")
.values({
media_asset_id: asset.id,
location_kind: "library",
library_root_id: lib.libraryRootId,
relative_path: "星际穿越 (2014)/星际穿越 (2014) - 1080p.mkv",
status: "available",
is_primary: true,
})
.execute();
const first = await run(film, "overwrite");
expect(first[0]!.status, JSON.stringify(first[0])).toBe("succeeded");
const names = a.mock.listNames("/s1/movies/星际穿越 (2014)");
expect(names.some((n) => n.endsWith(".nfo"))).toBe(true);
expect(names.some((n) => n.includes("poster"))).toBe(true);
expect(names).toContain("clearlogo.png");
expect(names.filter((n) => n.includes(".umt-new") || n.endsWith(".uhd-partial"))).toEqual([]);
const nfo = names.find((n) => n.endsWith(".nfo"))!;
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toContain("星际穿越");
// 用户改过 NFO:只补缺失不动它
a.mock.put(`/s1/movies/星际穿越 (2014)/${nfo}`, "<movie>用户改的</movie>");
const missing = await run(film, "missing");
expect(missing[0]!.status).toMatch(/succeeded|partial|skipped/);
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toBe("<movie>用户改的</movie>");
// 全部覆盖:改写回来
await run(film, "overwrite");
expect(text(a.mock.read(`/s1/movies/星际穿越 (2014)/${nfo}`))).toContain("星际穿越");
});
});
describe.skipIf(skip)("Alist:目录监控(远端快照)", () => {
let h: FmHarness;
let a: TestAlist;
let sourceId: string;
beforeAll(async () => {
h = await createFmHarness("alist_monitor", {}, { catalog: true, runTransfers: false });
a = await addAlist(h.db, { name: "OL", rootPath: "/s1", mockOptions: { token: "t" } });
a.mock.put("/s1/incoming/Old.Film.2010.1080p.mkv", "old video bytes");
a.mock.put("/s1/incoming/deep/a/b/c/d/e/Too.Deep.2011.mkv", "too deep");
const film = await seedEntity(h.db, { kind: "movie", title: "New Film", year: 2021 });
await h.db
.insertInto("external_identity")
.values({
media_entity_id: film,
provider: "tmdb",
external_id: "654321",
status: "accepted",
entity_type: "movie",
})
.execute();
const config: Omit<OrganizeSourceConfig, "id"> = {
name: "Alist 来源",
enabled: true,
source: { root: a.root as OrganizeSourceConfig["source"]["root"], path: "incoming" },
target: { root: h.libRoot as OrganizeSourceConfig["target"]["root"], path: "" },
mediaType: "all",
category: "欧美电影",
action: "copy",
trigger: "snapshot",
downloaderInstanceId: null,
tag: null,
intervalSec: 60,
};
sourceId = randomUUID();
await saveOrganizeSource(h.module.deps(), sourceId, config);
});
afterAll(async () => h?.close());
it("首扫建基线不整理;新文件两次观察稳定后整理;深度超限不进快照", async () => {
const d = h.module.deps();
const base = await scanOrganizeSource(d, { sourceId });
expect(base).toMatchObject({ baseline: true, queued: 0 });
const snap0 = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
const files0 = Object.keys((snap0.snapshot as { files: Record<string, unknown> }).files);
expect(files0).toContain("incoming/Old.Film.2010.1080p.mkv");
expect(files0.some((f) => f.includes("Too.Deep"))).toBe(false);
a.mock.put("/s1/incoming/New.Film.2021.tmdb-654321.1080p.WEB-DL.mkv", "new video bytes!!");
const first = await scanOrganizeSource(d, { sourceId });
expect(first.eligible).toBe(0);
const second = await scanOrganizeSource(d, { sourceId });
expect(second.eligible).toBe(1);
expect(second.queued).toBe(1);
const rec = await h.db.selectFrom("organization_record").select(["id", "trigger"]).executeTakeFirstOrThrow();
expect(rec.trigger).toBe("auto");
const files = await h.db
.selectFrom("organization_file_result")
.select("source_path")
.where("organization_record_id", "=", rec.id)
.execute();
expect(files.map((f) => f.source_path)).toContain(a.v("/s1/incoming/New.Film.2021.tmdb-654321.1080p.WEB-DL.mkv"));
expect(h.engine.enqueued.some((e) => e.task === "verifyAndImport")).toBe(true);
});
it("按目录修改时间跳过未变子树:子目录没变就不再列", async () => {
const d = h.module.deps();
a.mock.put("/s1/incoming/sub/Inner.Film.2012.mkv", "inner");
// sub 目录修改时间早于上次扫描 → 沿用旧快照(新文件要等目录时间变化才会被看到)
a.mock.dirs.set("/s1/incoming/sub", 1);
a.mock.requests.length = 0;
await scanOrganizeSource(d, { sourceId });
const listed = a.mock.requests.filter((r) => r.path === "/api/fs/list").map((r) => r.body!.path);
expect(listed).toContain("/s1/incoming");
expect(listed).not.toContain("/s1/incoming/sub");
// 目录时间更新后再扫就会进去
a.mock.dirs.set("/s1/incoming/sub", Date.now() + 60_000);
a.mock.requests.length = 0;
await scanOrganizeSource(d, { sourceId });
expect(a.mock.requests.filter((r) => r.path === "/api/fs/list").map((r) => r.body!.path)).toContain(
"/s1/incoming/sub",
);
});
it("Alist 连不上:记 last_error,保留基线;存量整理也能列出远端文件", async () => {
const d = h.module.deps();
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
a.mock.failNext(10);
await scanOrganizeSource(d, { sourceId });
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
expect(after.last_error).toBeTruthy();
expect(after.baseline_at).toEqual(before.baseline_at);
a.mock.failNext(0);
const stock = await scanStock(d, sourceId);
expect(stock.items.map((i) => i.path)).toContain("incoming/Old.Film.2010.1080p.mkv");
});
});
+66
View File
@@ -0,0 +1,66 @@
import { AlistClient, type AlistClientOptions } from "@umt/adapters";
import { createMockAlist, type MockAlist, type MockAlistOptions } from "@umt/adapters/alist-mock";
import type { DB, Kysely } from "@umt/db";
import { overrideStorage } from "../../src/storage/registry.ts";
import { toVPath } from "../../src/storage/vpath.ts";
// Alist 测试装配:登记一个存储实例(storage_instance 行)并把注册表里的客户端换成进程内模拟 Alist。
export interface TestAlist {
id: string;
mock: MockAlist;
client: AlistClient;
/** 文件管理根 id(st:<id>) */
root: string;
/** Alist 内路径 → 虚拟路径 */
v(p: string): string;
}
export async function addAlist(
db: Kysely<DB>,
o: {
name?: string;
rootPath?: string;
mock?: MockAlist;
mockOptions?: MockAlistOptions;
client?: Partial<AlistClientOptions>;
} = {},
): Promise<TestAlist> {
const mock = o.mock ?? createMockAlist(o.mockOptions ?? { token: "t" });
const rootPath = o.rootPath ?? "/";
const row = await db
.insertInto("storage_instance")
.values({ name: o.name ?? `alist-${crypto.randomUUID().slice(0, 6)}`, base_url: mock.base, root_path: rootPath })
.returning("id")
.executeTakeFirstOrThrow();
// 假时钟:等待 / 退避不真睡,超时按累计的「睡眠」计
let clock = 0;
const client = new AlistClient({
baseUrl: mock.base,
auth: { kind: "token", token: "t" },
fetch: mock.fetch,
qps: 0,
concurrency: 0,
retries: 0,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
...o.client,
});
overrideStorage(row.id, { client, name: o.name ?? "Alist", rootPath });
return { id: row.id, mock, client, root: `st:${row.id}`, v: (p) => toVPath(row.id, p) };
}
/** 在 Alist 上登记一个纳管媒体库目录 */
export async function addAlistLibrary(db: Kysely<DB>, a: TestAlist, path: string, name = "Alist 媒体库") {
a.mock.mkdirp(path);
const r = await db
.insertInto("library_root")
.values({ name, media_kind: "mixed", local_path: a.v(path), management_mode: "managed", sentinel_file: null })
.returning("id")
.executeTakeFirstOrThrow();
return { libraryRootId: r.id, root: `lib:${r.id}` };
}
export const text = (b: Uint8Array | null) => (b ? new TextDecoder().decode(b) : null);
@@ -0,0 +1,128 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createSubtitlesModule } from "../../src/modules/subtitles/index.ts";
import * as lfs from "../../src/storage/local-fs.ts";
import { createHarness, type Harness, pgAvailable } from "../auth-settings/harness.ts";
import { DEV_KEY } from "../helpers.ts";
// server 侧本地写入口(整理 / 刮削 / 字幕 / 回收站 / 上传共用的 storage/local-fs.ts)对 Alist 虚拟路径一律拒绝,
// 本机不出现 /@alist;补字幕的目标在 Alist 媒体库上时明确失败(不支持),不往本机写。
const V = "/@alist/00000000-0000-4000-8000-0000000000ee/s1/lib";
const code = async (p: Promise<unknown>) => {
try {
await p;
} catch (e) {
return (e as { code?: string }).code;
}
return "no-error";
};
describe("local-fs 写入口守卫", () => {
let dir: string;
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), "umt-lfs-"));
await lfs.writeFile(join(dir, "a.txt"), "x");
});
afterAll(async () => {
await rm(dir, { recursive: true, force: true });
});
it("mkdir / writeFile / rename / copyFile / rm / rmdir / unlink / link / open(写)都拒绝虚拟路径", async () => {
const R = "REMOTE_PATH_ON_LOCAL_FS";
expect(await code(lfs.mkdir(`${V}/d`, { recursive: true }))).toBe(R);
expect(await code(lfs.writeFile(`${V}/a.nfo`, "<movie/>"))).toBe(R);
expect(await code(lfs.rename(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
expect(await code(lfs.copyFile(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
expect(await code(lfs.rm(`${V}/a.txt`, { force: true }))).toBe(R);
expect(await code(lfs.rmdir(V))).toBe(R);
expect(await code(lfs.unlink(`${V}/a.txt`))).toBe(R);
expect(await code(lfs.link(join(dir, "a.txt"), `${V}/a.txt`))).toBe(R);
expect(await code(lfs.open(`${V}/a.txt`, "w"))).toBe(R);
expect(existsSync("/@alist")).toBe(false);
expect(existsSync(join(dir, "a.txt"))).toBe(true);
});
it("本地路径与只读打开照常", async () => {
await lfs.mkdir(join(dir, "sub"), { recursive: true });
await lfs.rename(join(dir, "a.txt"), join(dir, "sub", "a.txt"));
expect(await lfs.readFile(join(dir, "sub", "a.txt"), "utf8")).toBe("x");
expect(await code(lfs.open(`${V}/a.txt`, "r"))).toBe("ENOENT");
});
});
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("补字幕:目标在 Alist 媒体库上明确失败", () => {
let h: Harness;
let entityId: string;
let locationId: string;
beforeAll(async () => {
h = await createHarness({ extraModules: [createSubtitlesModule()] });
const root = await h.db
.insertInto("library_root")
.values({
name: "Alist 剧集",
media_kind: "mixed",
local_path: V,
management_mode: "managed",
sentinel_file: null,
})
.returning("id")
.executeTakeFirstOrThrow();
entityId = (
await h.db
.insertInto("media_entity")
.values({ kind: "series", title: "远端剧", year: 2024 } as never)
.returning("id")
.executeTakeFirstOrThrow()
).id;
const a = await h.db
.insertInto("media_asset")
.values({ media_entity_id: entityId, kind: "video", status: "active" })
.returning("id")
.executeTakeFirstOrThrow();
locationId = (
await h.db
.insertInto("asset_location")
.values({
media_asset_id: a.id,
location_kind: "library",
library_root_id: root.id,
relative_path: "远端剧 (2024)/Season 1/远端剧 - S01E01.mkv",
status: "available",
})
.returning("id")
.executeTakeFirstOrThrow()
).id;
}, 120_000);
afterAll(async () => h?.close());
it("写入返回失败原因,不写任何文件、不建资产", async () => {
const res = await h.app.request("/api/v1/subtitles/apply", {
method: "POST",
headers: { authorization: `Bearer ${DEV_KEY}`, "content-type": "application/json" },
body: JSON.stringify({
entityId,
items: [
{
name: "a.srt",
ext: "srt",
language: "zh-Hans",
content: btoa("1\n00:00:01,000 --> 00:00:02,000\nhi\n"),
targetLocationId: locationId,
},
],
}),
});
const body = await res.json();
expect(res.status, JSON.stringify(body)).toBe(200);
expect(body.data.written).toEqual([]);
expect(body.data.failed).toEqual([{ name: "a.srt", reason: "目标视频在 Alist 存储上,暂不支持补字幕" }]);
expect(existsSync("/@alist")).toBe(false);
const subs = await h.db.selectFrom("media_asset").select("id").where("kind", "=", "subtitle").execute();
expect(subs).toEqual([]);
});
});