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
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user