feat(organize): 整理矩阵支持 Alist(上传、下载、Alist 内复制移动、跨实例中转)
- 本地→Alist 上传、Alist→本地 下载、同实例 API copy/move(等任务完成)、跨实例下载再上传 - 成功判定同 MP:接口成功 + 目标存在;移动在判定通过后删源,删源失败记 attention - 连不上 / 超时 / 限流按可重试 IO 走整理账本,不当成源不存在;上传 / 下载中断不删源 - 计划期的目标冲突、库根在线、洗版替换、非视频隔离、对账均按存储分派;远端文件 ffprobe 读直链头 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9d95076a0e
commit
ccd2cc2c37
9 files changed
+426
-68
No files matched your search
@@ -0,0 +1,276 @@
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user