import { createMockQb, type MockQb } from "@umt/testkit"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts"; import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts"; // DL-2 下载面板:进行中 / 已完成分组与计数、每个种子的实时速度 / 剩余时间 / 连接数、下载器总速度、 // 实时数据短 TTL 缓存(轮询不直打 qB)、下载器不可达时退化为只看数据库。 const hasPg = await pgAvailable(); const TTL = 400; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const h40 = (n: number) => n.toString(16).padStart(40, "b"); describe.skipIf(!hasPg)("DL-2 下载面板(真实 PG + mock qB)", () => { let h: Harness; let qb: MockQb; let instId: string; beforeAll(async () => { qb = await createMockQb({ addVisibleAfterPolls: 0 }); h = await createHarness("dlboard", { options: { liveTtlMs: TTL } }); instId = ( await h.db .insertInto("downloader_instance") .values({ name: "qb-main", base_url: qb.url, username: "admin", settings: JSON.stringify({}) }) .returning("id") .executeTakeFirstOrThrow() ).id; // 进行中:下载中(有速度与 eta)、暂停、排队、出错;已完成:做种、完成暂停;管理标签外的任务不纳入 const dl = qb.seed({ hash: h40(1), name: "Dune.Part.Two.2024.2160p", tags: ["umt"], progress: 0.4 }); Object.assign(dl, { dlspeed: 12_900_000, upspeed: 1_200_000, eta: 620, num_seeds: 12, num_leechs: 3, num_complete: 80, num_incomplete: 9, }); qb.seed({ hash: h40(2), name: "Paused.Show.S01", tags: ["umt"], progress: 0.2, state: "pausedDL" }); qb.seed({ hash: h40(3), name: "Queued.Movie.2020", tags: ["umt"], progress: 0, state: "queuedDL" }); qb.seed({ hash: h40(4), name: "Broken.Movie.2019", tags: ["umt"], progress: 0.1, state: "error" }); const seeding = qb.seed({ hash: h40(5), name: "Heat.1995.1080p", tags: ["umt"], progress: 1 }); Object.assign(seeding, { upspeed: 300_000, ratio: 1.25 }); qb.seed({ hash: h40(6), name: "Done.Paused.2018", tags: ["umt"], progress: 1, state: "pausedUP" }); const other = qb.seed({ hash: h40(7), name: "Not.Managed", tags: ["other"], progress: 0.5 }); other.dlspeed = 100_000; await syncDownloads(h.d); const heat = await seedEntity(h.db, { kind: "movie", title: "盗火线", year: 1995 }); const t5 = await h.db .updateTable("download_task") .set({ media_entity_id: heat }) .where("torrent_hash", "=", h40(5)) .returning("id") .executeTakeFirstOrThrow(); await h.db .insertInto("organization_record") .values({ trigger: "auto", actor_type: "system", action: "copy", status: "succeeded", download_task_id: t5.id, media_entity_id: heat, }) .execute(); // 完成暂停的那个:最近一次整理失败 + 未关闭的待处理事项(下载任务维度) const t6 = await h.db .selectFrom("download_task") .select("id") .where("torrent_hash", "=", h40(6)) .executeTakeFirstOrThrow(); await h.db .insertInto("organization_record") .values({ trigger: "auto", actor_type: "system", action: "copy", status: "failed", reason: "identity_unknown", download_task_id: t6.id, }) .execute(); await h.db .insertInto("decision") .values({ kind: "identity_unresolved", title: "没识别出作品", subject_type: "download_task", subject_id: t6.id, dedupe_key: `test:${t6.id}`, }) .execute(); }, 60_000); afterAll(async () => { await h?.close(); await qb?.close(); }); it("默认进行中:只含未完成任务,两组各带计数,附实时速度与剩余时间", async () => { await sleep(TTL + 50); const r = await h.api("GET", "/download-tasks/board"); expect(r.status).toBe(200); const b = r.body.data; expect(b.counts).toEqual({ active: 4, completed: 2 }); const names = b.items.map((i: { name: string }) => i.name).sort(); expect(names).toEqual(["Broken.Movie.2019", "Dune.Part.Two.2024.2160p", "Paused.Show.S01", "Queued.Movie.2020"]); const dune = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)); expect(dune.live).toMatchObject({ dlSpeed: 12_900_000, upSpeed: 1_200_000, etaSec: 620, seeds: 12, leechs: 3, seedsTotal: 80, leechsTotal: 9, rawState: "downloading", }); expect(dune.live.progress).toBeCloseTo(0.4); // eta 8640000(qB 的无穷)记为 null;num_complete -1 记为未知 const queued = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(3)); expect(queued.live.etaSec).toBeNull(); expect(queued.live.seedsTotal).toBeNull(); expect(queued.live.dlSpeed).toBe(0); // 总速度来自 transfer/info:整个下载器(含管理标签外的任务) expect(b.transfer.dlSpeed).toBe(12_900_000 + 100_000); expect(b.transfer.upSpeed).toBe(1_200_000 + 300_000); expect(b.transfer.instances).toEqual([ { instanceId: instId, name: "qb-main", reachable: true, dlSpeed: 13_000_000, upSpeed: 1_500_000 }, ]); expect(b.liveAt).toBeTruthy(); }); it("已完成:含做种与完成后暂停,带作品与最近整理记录", async () => { const b = (await h.api("GET", "/download-tasks/board?state=completed")).body.data; expect(b.counts).toEqual({ active: 4, completed: 2 }); expect(b.items).toHaveLength(2); const heat = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(5)); expect(heat.status).toBe("completed"); expect(heat.media).toMatchObject({ title: "盗火线", year: 1995 }); expect(heat.organize).toMatchObject({ status: "succeeded", reason: null, decision: null }); expect(heat.live).toMatchObject({ upSpeed: 300_000, ratio: 1.25, etaSec: null }); expect(heat.live.completedAt).toBeTruthy(); const paused = b.items.find((i: { torrentHash: string }) => i.torrentHash === h40(6)); expect(paused.organize).toMatchObject({ status: "failed", reason: "identity_unknown", decision: { kind: "identity_unresolved" }, }); expect(paused.media).toBeNull(); }); it("TTL 内的轮询共用一次 qB 查询,并发请求合并;过期后重新查询", async () => { await sleep(TTL + 50); const before = qb.callsTo("torrents/info").length; const beforeT = qb.callsTo("transfer/info").length; await Promise.all([ h.api("GET", "/download-tasks/board"), h.api("GET", "/download-tasks/board?state=completed"), h.api("GET", "/download-tasks/board"), ]); await h.api("GET", "/download-tasks/board"); expect(qb.callsTo("torrents/info").length - before).toBe(1); expect(qb.callsTo("transfer/info").length - beforeT).toBe(1); // 缓存期间速度变化不可见,过期后可见 const dl = qb.get(h40(1)); if (dl) dl.dlspeed = 5_000_000; const cached = (await h.api("GET", "/download-tasks/board")).body.data; expect(cached.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(12_900_000); await sleep(TTL + 50); const fresh = (await h.api("GET", "/download-tasks/board")).body.data; expect(fresh.items.find((i: { torrentHash: string }) => i.torrentHash === h40(1)).live.dlSpeed).toBe(5_000_000); expect(qb.callsTo("torrents/info").length - before).toBe(2); }); it("下载器不可达:列表照常返回,实时数据为空,实例标记不可达", async () => { await sleep(TTL + 50); qb.fault("torrents/info", { status: 500, times: 10 }); const b = (await h.api("GET", "/download-tasks/board")).body.data; qb.clearFaults(); expect(b.counts.active).toBe(4); expect(b.items.every((i: { live: unknown }) => i.live === null)).toBe(true); expect(b.transfer).toMatchObject({ dlSpeed: 0, upSpeed: 0 }); expect(b.transfer.instances[0].reachable).toBe(false); expect(b.liveAt).toBeNull(); }); it("按实例过滤;非法 state 返回 422", async () => { const other = await h.api("GET", `/download-tasks/board?instanceId=${crypto.randomUUID()}`); expect(other.body.data).toMatchObject({ items: [], counts: { active: 0, completed: 0 } }); expect(other.body.data.transfer.instances).toEqual([]); expect((await h.api("GET", "/download-tasks/board?state=done")).status).toBe(422); }); });