feat(organize): record filters, stats and delete with files (HIS-1)
List query gains kind/since/organizeOnly and wildcard/hash search; new GET /organization-records/stats and POST /organization-records/delete (optional source/library files to trash via files delete). Public record adds posterPath, sizeBytes and download summary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f60254e265
commit
51e82f1cd9
8 files changed
+850
-17
No files matched your search
@@ -0,0 +1,239 @@
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFmHarness,
|
||||
EXEC_KEY,
|
||||
type FmHarness,
|
||||
pgAvailable,
|
||||
seedAsset,
|
||||
seedDownload,
|
||||
seedEntity,
|
||||
} from "./harness.ts";
|
||||
|
||||
// HIS-1 删除整理记录(对照 MP deletesrc / deletedest):只删记录;连同源文件 / 媒体库文件移入回收站(已入库文件同时退役);
|
||||
// 做种未满足的源文件整条拦下;还在整理的记录不删;API Key 不能顺带删文件;删掉没入库的下载任务的记录会被自动整理再处理。
|
||||
|
||||
const skip = !(await pgAvailable());
|
||||
const exists = (p: string) =>
|
||||
access(p).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
|
||||
describe.skipIf(skip)("删除整理记录", () => {
|
||||
let h: FmHarness;
|
||||
let entity: string;
|
||||
beforeAll(async () => {
|
||||
h = await createFmHarness("recdel");
|
||||
entity = await seedEntity(h.db, { kind: "movie", title: "Heat", year: 1995 });
|
||||
});
|
||||
afterAll(async () => h?.close());
|
||||
|
||||
/** 一条复制整理记录:源在下载根,目标在库根(相对路径) */
|
||||
async function seedRecord(
|
||||
name: string,
|
||||
o: { status?: "succeeded" | "failed" | "running"; downloadTaskId?: string | null; action?: "copy" | "trash" } = {},
|
||||
) {
|
||||
const src = join(h.dl, `${name}.mkv`);
|
||||
const rel = `${name} (1995)/${name}.mkv`;
|
||||
await writeFile(src, "video");
|
||||
if (o.status !== "failed") {
|
||||
await mkdir(join(h.lib, `${name} (1995)`), { recursive: true });
|
||||
await writeFile(join(h.lib, rel), "video");
|
||||
}
|
||||
const rec = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "auto",
|
||||
actor_type: "system",
|
||||
media_entity_id: entity,
|
||||
download_task_id: o.downloadTaskId ?? null,
|
||||
source_path: src,
|
||||
target_library_root_id: h.libraryRootId,
|
||||
target_path: `${name} (1995)`,
|
||||
action: o.action ?? "copy",
|
||||
status: o.status ?? "succeeded",
|
||||
file_total: 1,
|
||||
file_succeeded: o.status === "failed" ? 0 : 1,
|
||||
file_failed: o.status === "failed" ? 1 : 0,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values({
|
||||
organization_record_id: rec.id,
|
||||
mode: o.action ?? "copy",
|
||||
source_path: src,
|
||||
source_size_bytes: 5,
|
||||
target_library_root_id: h.libraryRootId,
|
||||
target_path: o.status === "failed" ? null : rel,
|
||||
status: o.status === "failed" ? "failed" : "succeeded",
|
||||
})
|
||||
.execute();
|
||||
return { id: rec.id, src, dst: join(h.lib, rel), rel };
|
||||
}
|
||||
|
||||
const del = (body: Record<string, unknown>, k?: string) => h.api("POST", "/organization-records/delete", body, k);
|
||||
const recordExists = async (id: string) =>
|
||||
!!(await h.db.selectFrom("organization_record").select("id").where("id", "=", id).executeTakeFirst());
|
||||
|
||||
it("只删记录:文件都不动", async () => {
|
||||
const r = await seedRecord("OnlyRecord");
|
||||
const res = await del({ ids: [r.id] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 0, trashRecordId: null });
|
||||
expect(await recordExists(r.id)).toBe(false);
|
||||
expect(await exists(r.src)).toBe(true);
|
||||
expect(await exists(r.dst)).toBe(true);
|
||||
});
|
||||
|
||||
it("dryRun 列出会删的文件与大小,不动文件和记录", async () => {
|
||||
const r = await seedRecord("DryRun");
|
||||
const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true, dryRun: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ dryRun: true, removed: [], sourceFiles: 1, targetFiles: 1, bytes: 10 });
|
||||
expect(res.body.data.items[0]).toMatchObject({ id: r.id, blocked: null, sourceFiles: 1, targetFiles: 1 });
|
||||
expect(await recordExists(r.id)).toBe(true);
|
||||
expect(await exists(r.src)).toBe(true);
|
||||
});
|
||||
|
||||
it("连同源文件和媒体库文件:移入回收站,已入库文件退役,写一条「移入回收站」记录", async () => {
|
||||
const r = await seedRecord("Both");
|
||||
const a = await seedAsset(h.db, h, { entityId: entity, rel: r.rel });
|
||||
const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 1, targetFiles: 1, assets: 1 });
|
||||
expect(await exists(r.src)).toBe(false);
|
||||
expect(await exists(r.dst)).toBe(false);
|
||||
const loc = await h.db
|
||||
.selectFrom("asset_location")
|
||||
.select("status")
|
||||
.where("id", "=", a.locationId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(loc.status).toBe("removed");
|
||||
const trash = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["action", "status"])
|
||||
.where("id", "=", res.body.data.trashRecordId)
|
||||
.executeTakeFirstOrThrow();
|
||||
expect(trash).toMatchObject({ action: "trash", status: "succeeded" });
|
||||
expect(await h.db.selectFrom("trash_item").select("id").execute()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("只删媒体库文件:源文件留着", async () => {
|
||||
const r = await seedRecord("TargetOnly");
|
||||
const res = await del({ ids: [r.id], deleteTarget: true });
|
||||
expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 1 });
|
||||
expect(await exists(r.src)).toBe(true);
|
||||
expect(await exists(r.dst)).toBe(false);
|
||||
});
|
||||
|
||||
it("批量:做种未满足的源文件整条拦下,其余照删;还在整理的不删", async () => {
|
||||
const t = await seedDownload(h.db, h, { name: "Seeding.mkv", status: "completed" });
|
||||
const blocked = await seedRecord("Seeding", { downloadTaskId: t });
|
||||
const ok = await seedRecord("Batch1");
|
||||
const running = await seedRecord("Running", { status: "running" });
|
||||
const res = await del({ ids: [blocked.id, ok.id, running.id], deleteSource: true, deleteTarget: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data.removed).toEqual([ok.id]);
|
||||
const byId = Object.fromEntries(res.body.data.items.map((i: { id: string }) => [i.id, i]));
|
||||
expect(byId[blocked.id].blocked).toBe("download_active");
|
||||
expect(byId[blocked.id].message).toContain("做种");
|
||||
expect(byId[running.id].blocked).toBe("running");
|
||||
// 被拦的记录:文件和记录都在(不留半截)
|
||||
expect(await exists(blocked.src)).toBe(true);
|
||||
expect(await exists(blocked.dst)).toBe(true);
|
||||
expect(await recordExists(blocked.id)).toBe(true);
|
||||
expect(await recordExists(running.id)).toBe(true);
|
||||
expect(await exists(ok.src)).toBe(false);
|
||||
});
|
||||
|
||||
it("失败记录的下载任务没有别的记录了:提示会被自动整理再处理", async () => {
|
||||
const t = await seedDownload(h.db, h, { name: "Retry.mkv", status: "completed" });
|
||||
const r = await seedRecord("Retry", { status: "failed", downloadTaskId: t });
|
||||
const dry = await del({ ids: [r.id], dryRun: true });
|
||||
expect(dry.body.data.autoRetry).toBe(1);
|
||||
// 已入库(imported)的不会重来
|
||||
await h.db.updateTable("download_task").set({ status: "imported" }).where("id", "=", t).execute();
|
||||
const dry2 = await del({ ids: [r.id], dryRun: true });
|
||||
expect(dry2.body.data.autoRetry).toBe(0);
|
||||
});
|
||||
|
||||
it("文件管理的删除记录只删记录本身", async () => {
|
||||
const r = await seedRecord("TrashRec", { action: "trash" });
|
||||
const res = await del({ ids: [r.id], deleteSource: true, deleteTarget: true });
|
||||
expect(res.body.data).toMatchObject({ removed: [r.id], sourceFiles: 0, targetFiles: 0 });
|
||||
expect(await exists(r.src)).toBe(true);
|
||||
});
|
||||
|
||||
it("批量重新整理:几条记录的源文件换算成登记目录路径,一起走整理(每个作品一条新记录)", async () => {
|
||||
const other = await seedEntity(h.db, { kind: "movie", title: "Ronin", year: 1998 });
|
||||
const mk = async (file: string, entityId: string) => {
|
||||
const src = join(h.dl, file);
|
||||
await writeFile(src, "x".repeat(100));
|
||||
const rec = await h.db
|
||||
.insertInto("organization_record")
|
||||
.values({
|
||||
trigger: "auto",
|
||||
actor_type: "system",
|
||||
media_entity_id: null,
|
||||
source_path: src,
|
||||
action: "copy",
|
||||
status: "failed",
|
||||
reason: "identity_unknown",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
await h.db
|
||||
.insertInto("organization_file_result")
|
||||
.values({ organization_record_id: rec.id, mode: "copy", source_path: src, status: "failed", role: "main" })
|
||||
.execute();
|
||||
return { id: rec.id, entityId };
|
||||
};
|
||||
const recs = [await mk("Heat.1995.1080p.mkv", entity), await mk("Ronin.1998.1080p.mkv", other)];
|
||||
const items: { root: string; path: string; mediaEntityId: string }[] = [];
|
||||
for (const r of recs) {
|
||||
const s = await h.api("GET", `/files/record-sources?recordId=${r.id}`);
|
||||
expect(s.status).toBe(200);
|
||||
expect(s.body.data.unresolved).toEqual([]);
|
||||
for (const it of s.body.data.items) items.push({ ...it, mediaEntityId: r.entityId });
|
||||
}
|
||||
expect(items.map((i) => i.path).sort()).toEqual(["Heat.1995.1080p.mkv", "Ronin.1998.1080p.mkv"]);
|
||||
const dry = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
items,
|
||||
action: "copy",
|
||||
dryRun: true,
|
||||
});
|
||||
expect(dry.status).toBe(200);
|
||||
const planIds = dry.body.data.groups.map((g: { planId: string }) => g.planId);
|
||||
expect(planIds).toHaveLength(2);
|
||||
const exec = await h.api("POST", "/files/organize", {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
items,
|
||||
action: "copy",
|
||||
dryRun: false,
|
||||
planIds,
|
||||
});
|
||||
expect(exec.status).toBe(200);
|
||||
expect(exec.body.data.recordIds).toHaveLength(2);
|
||||
const made = await h.db
|
||||
.selectFrom("organization_record")
|
||||
.select(["media_entity_id", "trigger"])
|
||||
.where("id", "in", exec.body.data.recordIds)
|
||||
.execute();
|
||||
expect(made.map((m) => m.media_entity_id).sort()).toEqual([entity, other].sort());
|
||||
});
|
||||
|
||||
it("API Key:只删记录可以,顺带删文件 403", async () => {
|
||||
const r = await seedRecord("ApiKey");
|
||||
const no = await del({ ids: [r.id], deleteSource: true }, EXEC_KEY);
|
||||
expect(no.status).toBe(403);
|
||||
expect(await exists(r.src)).toBe(true);
|
||||
const ok = await del({ ids: [r.id] }, EXEC_KEY);
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body.data.removed).toEqual([r.id]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user