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:
rootandClaude Opus 5.5 committed 2026-10-06 23:52:39 +08:00
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);
});
});