diff --git a/apps/server/src/modules/acquisition-organize/__tests__/download-board.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/download-board.test.ts new file mode 100644 index 00000000..4cfc19c1 --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/__tests__/download-board.test.ts @@ -0,0 +1,197 @@ +import { createMockQb, type MockQb } from "@umt/testkit"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { syncDownloads } from "../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); + }); +}); diff --git a/apps/server/src/modules/acquisition-organize/ports.ts b/apps/server/src/modules/acquisition-organize/ports.ts index b878e087..ca4840e3 100644 --- a/apps/server/src/modules/acquisition-organize/ports.ts +++ b/apps/server/src/modules/acquisition-organize/ports.ts @@ -56,6 +56,8 @@ export interface AoOptions { slotReevaluateIntervalMs?: number; /** 追踪中剧集的元数据(总集数)刷新周期,缺省 6h;0 = 关 */ metadataRefreshIntervalMs?: number; + /** 下载面板实时数据(qB torrents/info + transfer/info)的缓存时长,缺省 2000ms;0 = 不缓存(仍合并并发请求) */ + liveTtlMs?: number; } export interface AoPorts { diff --git a/apps/server/src/modules/acquisition-organize/routes/downloads.ts b/apps/server/src/modules/acquisition-organize/routes/downloads.ts index 321fbcc1..beb2203d 100644 --- a/apps/server/src/modules/acquisition-organize/routes/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/routes/downloads.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ApiException } from "../../../http/errors.ts"; import type { ModuleApp } from "../../types.ts"; import { type AoDeps, enqueueTracked, timeSlot } from "../services/common.ts"; +import { downloadBoard } from "../services/download-board.ts"; import { userPause, userResume } from "../services/downloads.ts"; import { actorOf, notFound, toApi, toApiList } from "../util.ts"; import { mount, paginate } from "./helpers.ts"; @@ -49,6 +50,8 @@ export function registerDownloadRoutes(app: ModuleApp, d: AoDeps) { }, { tag: TAG }, ); + // 在 /download-tasks/{id} 之前注册,避免 board 被当成 id + mount(app, Api.downloadTasks.board, async (_c, { query }) => downloadBoard(d, query), { tag: TAG }); mount( app, Api.downloadTasks.get, diff --git a/apps/server/src/modules/acquisition-organize/services/download-board.ts b/apps/server/src/modules/acquisition-organize/services/download-board.ts new file mode 100644 index 00000000..a1c6131f --- /dev/null +++ b/apps/server/src/modules/acquisition-organize/services/download-board.ts @@ -0,0 +1,222 @@ +import type { QbTorrent } from "@umt/adapters"; +import { + type DecisionKind, + DOWNLOAD_BOARD_STATUSES, + type DownloadBoard, + type DownloadBoardItem, + type DownloadBoardQuery, + type DownloadLive, + type DownloadTask, + type OrganizationStatus, +} from "@umt/contracts"; +import { type DownloaderInstanceRow, sql } from "@umt/db"; +import { paginate } from "../routes/helpers.ts"; +import { errMessage, toApi } from "../util.ts"; +import type { AoDeps } from "./common.ts"; + +// 下载面板(DL-2):数据库里的下载任务 + qB 实时数据。 +// 实时数据按实例缓存 liveTtlMs(缺省 2 s)并合并并发请求:多个标签页 / 多人每 3–5 s 轮询, +// 同一实例在一个 TTL 内只打一次 torrents/info(管理标签)+ transfer/info。 +// 对照 MP:下载中心前端 3 s 轮询、页面不活跃时停;后端每次直查下载器。这里多一层短缓存。 + +/** qB 的 eta 无穷值(8640000 = 100 天) */ +const QB_MAX_ETA = 8_640_000; +const DEFAULT_TTL_MS = 2000; + +export interface InstanceLive { + instanceId: string; + name: string; + reachable: boolean; + dlSpeed: number; + upSpeed: number; + /** hash → 实时数据 */ + torrents: Map; + at: Date; +} + +interface Entry { + stamp: string; + at: number; + done: boolean; + p: Promise; +} + +const caches = new WeakMap>(); + +const nonNeg = (v: unknown): number => { + const n = typeof v === "number" && Number.isFinite(v) ? v : 0; + return Math.max(0, Math.round(n)); +}; + +export function liveOf(t: QbTorrent): DownloadLive { + const eta = t.eta !== null && t.eta >= 0 && t.eta < QB_MAX_ETA && !t.complete ? Math.round(t.eta) : null; + return { + dlSpeed: nonNeg(t.dlspeed), + upSpeed: nonNeg(t.upspeed), + etaSec: eta, + progress: Math.min(1, Math.max(0, t.progress)), + rawState: t.rawState, + seeds: nonNeg(t.numSeeds), + leechs: nonNeg(t.numLeechs), + seedsTotal: t.numComplete === null ? null : nonNeg(t.numComplete), + leechsTotal: t.numIncomplete === null ? null : nonNeg(t.numIncomplete), + ratio: t.ratio, + completedAt: t.completionOn ? new Date(t.completionOn * 1000).toISOString() : null, + }; +} + +async function fetchLive(d: AoDeps, inst: DownloaderInstanceRow): Promise { + const base = { instanceId: inst.id, name: inst.name }; + let client: Awaited>; + try { + client = await d.ports.downloader(inst); + } catch (err) { + d.ctx.logger.warn({ instanceId: inst.id, err: { message: errMessage(err) } }, "下载面板:下载器客户端创建失败"); + return { ...base, reachable: false, dlSpeed: 0, upSpeed: 0, torrents: new Map(), at: new Date() }; + } + const [list, transfer] = await Promise.allSettled([client.list({ tag: inst.managed_tag }), client.transferInfo()]); + if (list.status === "rejected") { + d.ctx.logger.debug({ instanceId: inst.id, err: { message: errMessage(list.reason) } }, "下载面板:qB 不可达"); + return { ...base, reachable: false, dlSpeed: 0, upSpeed: 0, torrents: new Map(), at: new Date() }; + } + const torrents = new Map(list.value.map((t) => [t.hash, liveOf(t)])); + // transfer/info 是整个下载器的速度(含非本系统管理的任务);取不到时退回管理标签内任务之和 + const sum = (k: "dlSpeed" | "upSpeed") => [...torrents.values()].reduce((a, t) => a + t[k], 0); + const info = transfer.status === "fulfilled" ? transfer.value : null; + return { + ...base, + reachable: true, + dlSpeed: info ? nonNeg(info.dl_info_speed) : sum("dlSpeed"), + upSpeed: info ? nonNeg(info.up_info_speed) : sum("upSpeed"), + torrents, + at: new Date(), + }; +} + +/** 实例实时数据(短 TTL 缓存 + 合并在途请求;实例配置变更后作废) */ +export function instanceLive(d: AoDeps, inst: DownloaderInstanceRow): Promise { + const ttl = d.ports.options.liveTtlMs ?? DEFAULT_TTL_MS; + let m = caches.get(d); + if (!m) { + m = new Map(); + caches.set(d, m); + } + const stamp = `${inst.updated_at.toISOString()}|${inst.base_url}|${inst.managed_tag}`; + const hit = m.get(inst.id); + if (hit && hit.stamp === stamp && (!hit.done || Date.now() - hit.at < ttl)) return hit.p; + const entry: Entry = { stamp, at: Date.now(), done: false, p: fetchLive(d, inst) }; + entry.p.then( + () => { + entry.done = true; + entry.at = Date.now(); + }, + () => { + entry.done = true; + entry.at = 0; + }, + ); + m.set(inst.id, entry); + return entry.p; +} + +export async function downloadBoard(d: AoDeps, query: DownloadBoardQuery): Promise { + const db = d.ctx.db; + let q = db + .selectFrom("download_task") + .selectAll() + .where("removed_at", "is", null) + .where("status", "in", [...DOWNLOAD_BOARD_STATUSES[query.state]]); + if (query.instanceId) q = q.where("instance_id", "=", query.instanceId); + + let insts = db.selectFrom("downloader_instance").selectAll().where("enabled", "=", true); + if (query.instanceId) insts = insts.where("id", "=", query.instanceId); + + const [page, counts, instances] = await Promise.all([ + paginate(q, query), + sql<{ active: string; completed: string }>` + select count(*) filter (where status in (${sql.join([...DOWNLOAD_BOARD_STATUSES.active])})) as active, + count(*) filter (where status in (${sql.join([...DOWNLOAD_BOARD_STATUSES.completed])})) as completed + from download_task + where removed_at is null ${query.instanceId ? sql`and instance_id = ${query.instanceId}` : sql``}`.execute(db), + insts.orderBy("name").execute(), + ]); + const rows = page.rows; + + const entityIds = [...new Set(rows.map((r) => r.media_entity_id).filter((x): x is string => !!x))]; + const taskIds = rows.map((r) => r.id); + const [entities, records, lives] = await Promise.all([ + entityIds.length + ? db.selectFrom("media_entity").select(["id", "title", "year"]).where("id", "in", entityIds).execute() + : Promise.resolve([]), + taskIds.length + ? sql<{ + id: string; + download_task_id: string; + status: OrganizationStatus; + reason: string | null; + decision_id: string | null; + decision_kind: DecisionKind | null; + }>` + with last as ( + select distinct on (download_task_id) id, download_task_id, status, reason + from organization_record + where download_task_id in (${sql.join(taskIds)}) + order by download_task_id, created_at desc + ) + select last.*, dc.id as decision_id, dc.kind as decision_kind + from last + left join lateral ( + select id, kind from decision + where status in ('open', 'investigating', 'awaiting_user') + and kind not in ('download_stalled', 'download_failed') + and ((subject_type = 'download_task' and subject_id = last.download_task_id) + or (subject_type = 'organization_record' and subject_id = last.id)) + order by created_at desc limit 1 + ) dc on true` + .execute(db) + .then((r) => r.rows) + : Promise.resolve([]), + Promise.all(instances.map((i) => instanceLive(d, i))), + ]); + const entityById = new Map(entities.map((e) => [e.id, e])); + const recordByTask = new Map(records.map((r) => [r.download_task_id, r])); + const liveByInst = new Map(lives.map((l) => [l.instanceId, l])); + + const items: DownloadBoardItem[] = rows.map((r) => { + const e = r.media_entity_id ? entityById.get(r.media_entity_id) : undefined; + const rec = recordByTask.get(r.id); + return { + ...toApi(r), + live: liveByInst.get(r.instance_id)?.torrents.get(r.torrent_hash) ?? null, + media: e ? { id: e.id, title: e.title, year: e.year } : null, + organize: rec + ? { + recordId: rec.id, + status: rec.status, + reason: rec.reason, + decision: rec.decision_id && rec.decision_kind ? { id: rec.decision_id, kind: rec.decision_kind } : null, + } + : null, + }; + }); + const c = counts.rows[0]; + const reachable = lives.filter((l) => l.reachable); + const oldest = reachable.length ? Math.min(...reachable.map((l) => l.at.getTime())) : null; + return { + items, + nextCursor: page.nextCursor, + counts: { active: Number(c?.active ?? 0), completed: Number(c?.completed ?? 0) }, + transfer: { + dlSpeed: reachable.reduce((a, l) => a + l.dlSpeed, 0), + upSpeed: reachable.reduce((a, l) => a + l.upSpeed, 0), + instances: lives.map((l) => ({ + instanceId: l.instanceId, + name: l.name, + reachable: l.reachable, + dlSpeed: l.dlSpeed, + upSpeed: l.upSpeed, + })), + }, + liveAt: oldest === null ? null : new Date(oldest).toISOString(), + }; +} diff --git a/apps/web/app/api/downloads.ts b/apps/web/app/api/downloads.ts index 86da4215..18290455 100644 --- a/apps/web/app/api/downloads.ts +++ b/apps/web/app/api/downloads.ts @@ -3,6 +3,8 @@ import { AcquisitionPlan, cursorPage, Dashboard, + DownloadBoard, + type DownloadBoardState, DownloaderInstance, type DownloadStatus, DownloadTask, @@ -26,6 +28,7 @@ export const downloadKeys = { plans: (status?: PlanStatus) => ["downloads", "plans", status ?? "all"] as const, plan: (id: string) => ["downloads", "plan", id] as const, cache: () => ["downloads", "cache"] as const, + board: (state: DownloadBoardState) => ["downloads", "board", state] as const, }; const ACTIVE: DownloadStatus[] = ["queued", "submitting", "downloading", "importing", "stalled"]; @@ -47,6 +50,28 @@ export function useDownloadTasks(f: DownloadFilters) { }); } +/** 下载面板轮询间隔:页面可见时刷新,标签页在后台时 React Query 自动暂停(refetchIntervalInBackground=false) */ +export const BOARD_POLL_MS = 4 * TTL_SECOND; + +/** + * 下载面板(DL-2):分组列表 + 实时速度 + 两组计数 + 下载器总速度。 + * 不保留上一个分组的数据:两组列不同,切换时先显示加载态。 + */ +export function useDownloadBoard(state: DownloadBoardState) { + return useInfiniteQuery({ + queryKey: downloadKeys.board(state), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + apiParsed(DownloadBoard, "/api/v1/download-tasks/board", { + params: cleanParams({ state, cursor: pageParam, limit: 50 }), + signal, + }), + getNextPageParam: (last) => last.nextCursor ?? undefined, + refetchInterval: BOARD_POLL_MS, + refetchIntervalInBackground: false, + }); +} + export function useDownloadTask(id: string | undefined) { return useQuery({ queryKey: downloadKeys.one(id ?? ""), diff --git a/apps/web/app/components/downloads/board-format.test.ts b/apps/web/app/components/downloads/board-format.test.ts new file mode 100644 index 00000000..50515d94 --- /dev/null +++ b/apps/web/app/components/downloads/board-format.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText } from "./board-format.ts"; + +const RID = "00000000-0000-4000-8000-000000000001"; +const rec = ( + status: "queued" | "running" | "succeeded" | "partial" | "failed" | "cancelled", + extra: { reason?: string | null; decision?: { id: string; kind: "identity_unresolved" } | null } = {}, +) => ({ recordId: RID, status, reason: null, decision: null, ...extra }); + +describe("下载面板展示换算(DL-2)", () => { + it("速度:0 与未知显示 —,其余带 /s", () => { + expect(formatSpeed(0)).toBe("—"); + expect(formatSpeed(null)).toBe("—"); + expect(formatSpeed(undefined)).toBe("—"); + expect(formatSpeed(12.3 * 1024 * 1024)).toBe("12.3 MB/s"); + expect(formatSpeed(512)).toBe("512 B/s"); + }); + + it("剩余时间:分钟 / 小时 / 天,未知为 —", () => { + expect(formatEta(null)).toBe("—"); + expect(formatEta(30)).toBe("不到 1 分钟"); + expect(formatEta(620)).toBe("10 分钟"); + expect(formatEta(3600)).toBe("1 小时"); + expect(formatEta(2 * 3600 + 5 * 60)).toBe("2 小时 5 分"); + expect(formatEta(3 * 86400 + 4 * 3600)).toBe("3 天 4 小时"); + }); + + it("最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库;有待处理事项时链到待处理", () => { + expect(organizeState({ status: "completed", organize: null })).toMatchObject({ label: "待整理", to: null }); + expect(organizeState({ status: "importing", organize: null }).label).toBe("整理中"); + expect(organizeState({ status: "imported", organize: null }).label).toBe("已入库"); + expect(organizeState({ status: "completed", organize: rec("succeeded") })).toMatchObject({ + label: "已入库", + tone: "success", + to: `/console/organize/${RID}`, + }); + expect(organizeState({ status: "completed", organize: rec("running") }).label).toBe("整理中"); + const failed = organizeState({ + status: "completed", + organize: rec("failed", { + reason: "identity_unknown", + decision: { id: "00000000-0000-4000-8000-000000000002", kind: "identity_unresolved" }, + }), + }); + // 原因码不直接给人看 + expect(failed).toMatchObject({ label: "入库失败", tone: "destructive", hint: null }); + expect(failed.to).toBe("/console/inbox?kind=identity_unresolved"); + expect(organizeState({ status: "completed", organize: rec("failed", { reason: "磁盘已满" }) }).hint).toBe( + "磁盘已满", + ); + expect(organizeState({ status: "completed", organize: rec("partial") }).label).toBe("部分入库"); + expect(organizeState({ status: "completed", organize: rec("cancelled") })).toMatchObject({ + label: "待整理", + to: null, + }); + }); + + it("连接数:全 0 且总数未知时不显示", () => { + const base = { + dlSpeed: 0, + upSpeed: 0, + etaSec: null, + progress: 0, + rawState: "queuedDL", + ratio: null, + completedAt: null, + }; + expect(peersText({ ...base, seeds: 0, leechs: 0, seedsTotal: null, leechsTotal: null })).toBeNull(); + expect(peersText({ ...base, seeds: 12, leechs: 3, seedsTotal: 80, leechsTotal: null })).toBe("做种 12/80 · 下载 3"); + expect(peersText(null)).toBeNull(); + }); + + it("实时状态补充:校验、取元数据", () => { + expect(liveStateLabel("checkingDL")).toBe("校验中"); + expect(liveStateLabel("metaDL")).toBe("获取元数据"); + expect(liveStateLabel("downloading")).toBeNull(); + expect(liveStateLabel(null)).toBeNull(); + }); +}); diff --git a/apps/web/app/components/downloads/board-format.ts b/apps/web/app/components/downloads/board-format.ts new file mode 100644 index 00000000..21078881 --- /dev/null +++ b/apps/web/app/components/downloads/board-format.ts @@ -0,0 +1,109 @@ +import type { DownloadBoardItem, DownloadBoardState, DownloadLive } from "@umt/contracts"; +import { consolePath } from "~/components/console/nav.ts"; +import { formatBytes } from "~/lib/utils.ts"; + +// 下载面板(DL-2)的展示换算:速度、剩余时间、整理状态、实时状态补充。 + +/** 速度:0 或未知显示"—",不显示 "0 B/s" */ +export function formatSpeed(bps: number | null | undefined): string { + if (!bps || bps <= 0) return "—"; + return `${formatBytes(bps, 1)}/s`; +} + +/** 剩余时间:未知(qB 的无穷)显示"—" */ +export function formatEta(sec: number | null | undefined): string { + if (sec === null || sec === undefined || sec < 0) return "—"; + if (sec < 60) return "不到 1 分钟"; + const m = Math.round(sec / 60); + if (m < 60) return `${m} 分钟`; + const h = Math.floor(sec / 3600); + const rm = Math.round((sec - h * 3600) / 60); + if (h < 24) return rm ? `${h} 小时 ${rm} 分` : `${h} 小时`; + const d = Math.floor(h / 24); + const rh = h - d * 24; + return rh ? `${d} 天 ${rh} 小时` : `${d} 天`; +} + +export type OrganizeState = { + label: string; + tone: "success" | "warning" | "destructive" | "neutral"; + /** 点状态去哪:有未关闭的待处理事项去待处理,否则去整理记录;都没有为 null */ + to: string | null; + /** 失败原因(人话);原因是内部码时不直接给人看 */ + hint: string | null; +}; + +/** 原因:内部码(小写下划线)不显示,其余原样。TODO(DEAI-2 合并后):改用 ~/lib/reasons.ts 的 reasonLine 人话化 */ +export function reasonHint(raw: string | null | undefined): string | null { + if (!raw) return null; + return /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(raw) ? null : raw; +} + +/** 已完成任务的最近一次整理结果:已入库 / 整理中 / 待整理 / 入库失败 / 部分入库 */ +export function organizeState(t: Pick): OrganizeState { + const rec = t.organize; + const to = rec?.decision + ? `${consolePath("inbox")}?kind=${rec.decision.kind}` + : rec + ? consolePath("organize", rec.recordId) + : null; + if (rec?.status === "succeeded" || (t.status === "imported" && rec?.status !== "failed")) + return { label: "已入库", tone: "success", to, hint: null }; + if (rec?.status === "failed") return { label: "入库失败", tone: "destructive", to, hint: reasonHint(rec.reason) }; + if (rec?.status === "partial") return { label: "部分入库", tone: "warning", to, hint: reasonHint(rec.reason) }; + if (rec?.status === "queued" || rec?.status === "running" || t.status === "importing") + return { label: "整理中", tone: "neutral", to, hint: null }; + return { label: "待整理", tone: "warning", to: rec?.decision ? to : null, hint: null }; +} + +/** qB 实时状态里数据库状态没有细分的几种(校验、取元数据) */ +export function liveStateLabel(rawState: string | null | undefined): string | null { + const s = rawState?.toLowerCase() ?? ""; + if (s.startsWith("checking")) return "校验中"; + if (s === "metadl" || s === "forcedmetadl") return "获取元数据"; + if (s === "moving") return "移动中"; + return null; +} + +/** 进度:有实时数据时用实时进度 */ +export function progressOf(t: Pick): number { + return t.live?.progress ?? t.progress; +} + +export const BOARD_STATE_KEY = "umt.downloads.boardState"; + +export function readBoardState(): DownloadBoardState { + try { + return globalThis.localStorage?.getItem(BOARD_STATE_KEY) === "completed" ? "completed" : "active"; + } catch { + return "active"; + } +} + +export function writeBoardState(s: DownloadBoardState) { + try { + globalThis.localStorage?.setItem(BOARD_STATE_KEY, s); + } catch { + // 隐私模式等写不进去时只影响"记住选择" + } +} + +/** 连接数:"做种 12/80 · 下载 3/9";全为 0 且总数未知时不显示 */ +export function peersText(l: DownloadLive | null | undefined): string | null { + if (!l) return null; + if (!l.seeds && !l.leechs && l.seedsTotal === null && l.leechsTotal === null) return null; + const seeds = l.seedsTotal === null ? `${l.seeds}` : `${l.seeds}/${l.seedsTotal}`; + const leechs = l.leechsTotal === null ? `${l.leechs}` : `${l.leechs}/${l.leechsTotal}`; + return `做种 ${seeds} · 下载 ${leechs}`; +} + +/** 详情抽屉里的实时数据行 */ +export function liveItems(l: DownloadLive | null | undefined): { label: string; value: string | null }[] { + if (!l) return []; + return [ + { label: "下载速度", value: formatSpeed(l.dlSpeed) }, + { label: "上传速度", value: formatSpeed(l.upSpeed) }, + { label: "剩余时间", value: formatEta(l.etaSec) }, + { label: "连接", value: peersText(l) }, + ]; +} diff --git a/apps/web/app/components/downloads/download-board.tsx b/apps/web/app/components/downloads/download-board.tsx new file mode 100644 index 00000000..2b3446f7 --- /dev/null +++ b/apps/web/app/components/downloads/download-board.tsx @@ -0,0 +1,293 @@ +import type { DownloadBoard, DownloadBoardItem, DownloadBoardState } from "@umt/contracts"; +import { ArrowDown, ArrowUp, CheckCircle2, Download } from "lucide-react"; +import type { ReactNode } from "react"; +import { Link } from "react-router"; +import type { DataColumn } from "~/components/console/data-table.tsx"; +import { consolePath } from "~/components/console/nav.ts"; +import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { relTime } from "~/components/workbench/release-fields.ts"; +import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts"; +import { formatEta, formatSpeed, liveStateLabel, organizeState, peersText, progressOf } from "./board-format.ts"; +import { DownloadStatusBadges, PauseResume, ProgressLine } from "./download-detail.tsx"; + +// 下载面板(DL-2):顶部总速度 + 「进行中 / 已完成」分段;进行中看进度与速度,已完成看完成时间、分享率与整理状态。 + +/** 下载 / 上传速度一对(0 显示"—") */ +export function SpeedPair({ dl, up, className }: { dl: number; up: number; className?: string }) { + return ( + + + + {formatSpeed(dl)} + + + + {formatSpeed(up)} + + + ); +} + +/** 总速度一行;不可达的下载器单独列出 */ +export function TransferLine({ transfer }: { transfer: DownloadBoard["transfer"] | undefined }) { + if (!transfer || transfer.instances.length === 0) return null; + const down = transfer.instances.filter((i) => !i.reachable); + const perInstance = + transfer.instances.length > 1 + ? transfer.instances.map((i) => `${i.name}:↓ ${formatSpeed(i.dlSpeed)} ↑ ${formatSpeed(i.upSpeed)}`).join("\n") + : undefined; + return ( +
+ + + + {down.length ? ( + {down.map((i) => `「${i.name}」`).join("")}连接失败 + ) : null} +
+ ); +} + +export function BoardSegments({ + value, + counts, + onChange, +}: { + value: DownloadBoardState; + counts: DownloadBoard["counts"] | undefined; + onChange: (v: DownloadBoardState) => void; +}) { + const options: { value: DownloadBoardState; label: string }[] = [ + { value: "active", label: "进行中" }, + { value: "completed", label: "已完成" }, + ]; + return ( +
+ {options.map((o) => ( + // biome-ignore lint/a11y/useSemanticElements: 分段按钮 + + ))} +
+ ); +} + +function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) { + if (!t.media && !extra) return null; + return ( +

+ {t.media ? ( + e.stopPropagation()} + className="truncate underline-offset-2 hover:text-foreground hover:underline" + > + {t.media.title} + {t.media.year ? ` (${t.media.year})` : ""} + + ) : null} + {extra} +

+ ); +} + +function NameCell({ t, children }: { t: DownloadBoardItem; children?: ReactNode }) { + return ( +
+

{t.name}

+ {children} +
+ ); +} + +function ActiveStatus({ t }: { t: DownloadBoardItem }) { + const live = liveStateLabel(t.live?.rawState); + return ( + + + {live ? {live} : null} + + ); +} + +export function activeColumns(): DataColumn[] { + return [ + { + key: "name", + header: "下载任务", + cell: (t) => ( + + + {/* 窄屏:进度与速度并进名称列 */} +
+ + + {t.live?.dlSpeed || t.live?.upSpeed ? ( +

+ + {t.live.etaSec !== null ? 剩余 {formatEta(t.live.etaSec)} : null} +

+ ) : null} +
+
+ ), + }, + { + key: "progress", + header: "进度", + hideBelow: "md", + className: "w-40", + cell: (t) => , + }, + { + key: "speed", + header: "速度", + hideBelow: "md", + cell: (t) => + !t.live?.dlSpeed && !t.live?.upSpeed ? ( + — + ) : ( +
+

+ + {formatSpeed(t.live?.dlSpeed)} +

+

+ + {formatSpeed(t.live?.upSpeed)} +

+
+ ), + }, + { + key: "eta", + header: "剩余时间", + hideBelow: "lg", + cell: (t) => ( +
+

{formatEta(t.live?.etaSec)}

+ {peersText(t.live) ?

{peersText(t.live)}

: null} +
+ ), + }, + { key: "status", header: "状态", hideBelow: "md", cell: (t) => }, + { key: "ops", header: "", align: "right", cell: (t) => }, + ]; +} + +function OrganizeBadge({ t }: { t: DownloadBoardItem }) { + const s = organizeState(t); + const badge = {s.label}; + return ( +
+ {s.to ? ( + e.stopPropagation()} + title={t.organize?.decision ? "去待处理" : "查看整理记录"} + className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {badge} + + ) : ( + badge + )} + {s.hint ? {s.hint} : null} +
+ ); +} + +/** 完成时间以下载器记录为准(接管的任务在数据库里记的是接管时刻) */ +const completedAtOf = (t: DownloadBoardItem) => t.live?.completedAt ?? t.completedAt ?? null; +const sizeText = (t: DownloadBoardItem) => (t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)); +const ratioOf = (t: DownloadBoardItem) => t.live?.ratio ?? t.ratio; + +export function completedColumns(): DataColumn[] { + return [ + { + key: "name", + header: "下载任务", + cell: (t) => ( + + +

+ {sizeText(t)} + {relTime(completedAtOf(t))}完成 + {ratioOf(t) !== null ? 分享率 {ratioOf(t)?.toFixed(2)} : null} + {t.live?.upSpeed ? ( + + + {formatSpeed(t.live.upSpeed)} + + ) : null} +

+
+ ), + }, + { + key: "size", + header: "大小", + hideBelow: "md", + align: "right", + cell: (t) => {sizeText(t)}, + }, + { + key: "done", + header: "完成时间", + hideBelow: "md", + cell: (t) => ( + + {relTime(completedAtOf(t))} + + ), + }, + { + key: "ratio", + header: "分享率 / 上传", + hideBelow: "lg", + cell: (t) => ( +
+

{ratioOf(t) === null ? "—" : ratioOf(t)?.toFixed(2)}

+ {t.live?.upSpeed ? ( +

+ + {formatSpeed(t.live.upSpeed)} +

+ ) : null} +
+ ), + }, + { key: "organize", header: "整理", cell: (t) => }, + ]; +} + +export const BOARD_EMPTY: Record = { + active: { + icon: Download, + title: "没有进行中的下载", + description: "自动收录或在站点页下载后出现在这里;下载完成的在「已完成」。", + }, + completed: { + icon: CheckCircle2, + title: "还没有下载完成的任务", + description: "下载完成(含做种)后出现在这里。", + }, +}; diff --git a/apps/web/app/components/downloads/download-detail.tsx b/apps/web/app/components/downloads/download-detail.tsx index 9e5ac01d..ead6feb2 100644 --- a/apps/web/app/components/downloads/download-detail.tsx +++ b/apps/web/app/components/downloads/download-detail.tsx @@ -1,4 +1,4 @@ -import type { DownloadTask } from "@umt/contracts"; +import type { DownloadLive, DownloadTask } from "@umt/contracts"; import { Bot, FolderInput, Pause, Play, ShieldAlert } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -23,6 +23,7 @@ import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage } from "~/lib/api.ts"; import { planReasonText } from "~/lib/labels.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts"; +import { liveItems } from "./board-format.ts"; import { BusinessTimeline } from "./business-timeline.tsx"; const PLAN_PURPOSE_LABEL: Record = { fill: "补缺", upgrade: "升级", replace_source: "换源" }; @@ -89,7 +90,7 @@ export function PauseResume({ t, size = "sm" }: { t: DownloadTask; size?: "sm" | } /** 下载任务详情:状态、阈值说明、业务时间线、文件、关联计划 */ -export function DownloadDetail({ task }: { task: DownloadTask }) { +export function DownloadDetail({ task, live }: { task: DownloadTask; live?: DownloadLive | null }) { const plan = usePlan(task.acquisitionPlanId); const names = useDownloaderNames(); const trace = useDownloadTrace(task, plan.data); @@ -141,13 +142,14 @@ export function DownloadDetail({ task }: { task: DownloadTask }) { ) : null} - + [{ title: "下载面板 · UHD 媒体自动化控制台" }]; +// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。 + export default function DownloadsPage() { - const f = useQueryState(["status", "managed"] as const); + // SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段 + const [state, setStateRaw] = useState(readBoardState); + const setState = (s: DownloadBoardState) => { + setStateRaw(s); + writeBoardState(s); + }; const [openId, setOpenId] = useState(null); const qc = useQueryClient(); - const downloads = useDownloadTasks({ - status: (f.values.status || undefined) as DownloadStatus | undefined, - managed: (f.values.managed || undefined) as "true" | "false" | undefined, - }); - const names = useDownloaderNames(); + const board = useDownloadBoard(state); - // 下载进度/状态、阈值事件到达时刷新列表(SSE 为主,轮询兜底) + // 状态变化、阈值事件到达时立即刷新(速度靠轮询) useSse( [ EventType.downloadProgress, @@ -47,45 +49,12 @@ export default function DownloadsPage() { () => void qc.invalidateQueries({ queryKey: ["downloads"] }), ); - const rows = downloads.data?.pages.flatMap((p) => p.items); + const pages = board.data?.pages; + const rows = pages?.flatMap((p) => p.items); + const first = pages?.[0]; const open = rows?.find((t) => t.id === openId) ?? null; - - const columns: DataColumn[] = [ - { - key: "name", - header: "下载任务", - cell: (t) => ( -
-

{t.name}

-

- {names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"} -

-
- ), - }, - { key: "status", header: "状态", cell: (t) => }, - { key: "progress", header: "进度", hideBelow: "sm", cell: (t) => }, - { - key: "size", - header: "大小", - hideBelow: "lg", - align: "right", - cell: (t) => ( - - {t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)} - - ), - }, - { - key: "at", - header: "更新", - hideBelow: "md", - cell: (t) => ( - {relTime(t.lastSyncedAt ?? t.updatedAt)} - ), - }, - { key: "ops", header: "", align: "right", cell: (t) => }, - ]; + const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]); + const empty = BOARD_EMPTY[state]; return ( <> @@ -94,47 +63,30 @@ export default function DownloadsPage() { description="阈值暂停不能手动绕过;无进展只记录,不自动换源。" actions={ } /> - - f.set("status", v)} - options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))} - /> - f.set("managed", v)} - options={[ - { value: "true", label: "纳管任务" }, - { value: "false", label: "未纳管" }, - ]} - /> - +
+ + +
t.id} - loading={downloads.isPending} - error={downloads.error} - onRetry={() => void downloads.refetch()} + loading={board.isPending} + error={board.error} + onRetry={() => void board.refetch()} onRowClick={(t) => setOpenId(t.id)} selectedKey={openId} - hasMore={downloads.hasNextPage} - onLoadMore={() => void downloads.fetchNextPage()} - loadingMore={downloads.isFetchingNextPage} - empty={{ - icon: Download, - title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务", - description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。", - }} + hasMore={board.hasNextPage} + onLoadMore={() => void board.fetchNextPage()} + loadingMore={board.isFetchingNextPage} + empty={empty} /> - {open ? : null} + {open ? : null} ); diff --git a/docs/plan/00-first-version-plan.md b/docs/plan/00-first-version-plan.md index 6f4216e5..9835c355 100644 --- a/docs/plan/00-first-version-plan.md +++ b/docs/plan/00-first-version-plan.md @@ -183,3 +183,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏 - **SEARCH-3 后续(协调者截图复核)**:新筛选条下方仍保留旧的一行(大小/免费/H&R/排序/清除筛选),"免费"与「促销」维度重复、"清除全部"与"清除筛选"并存 → 并为一行:H&R 进分面,大小与排序放右侧,只留一个"清除";元信息行的耗时进「技术信息」。DEAI-2 合并后处理。 - **LIBV-1 后续(协调者截图复核)**:每集都挂一个"技术信息"开关太碎 → 点整行展开;"2 个版本"展开后第二个版本出现在第一个版本的技术信息里,应并列成同级行;"可升级:分辨率 1080p 不在允许列表 [2160p]"改人话("可换成 2160p");统一改用 DEAI-2 的 TechInfo 组件。 - **下载面板(DL-2,用户 2026-09-29)**:每个种子显示下载/上传速度(及总速度);默认只显示未完成的;已完成的单独一个视图(Tab 或分段)。 + - 状态(分支 dl2):`GET /download-tasks/board?state=active|completed`(两组计数、每种子速度/剩余时间/连接数、transfer/info 总速度,实时数据按实例缓存 2 s 并合并并发请求);最近一次整理结果带状态、原因码与未关闭待处理事项。前端分段记在本浏览器,页面可见时 4 s 刷新。**合并 main 后待办**:已完成行的失败原因改用 DEAI-2 的 `reasonLine`(现在只显示非内部码的原因),详情抽屉的实时数据放进 TechInfo 旁。 diff --git a/packages/adapters/src/qbittorrent/client.ts b/packages/adapters/src/qbittorrent/client.ts index 9b07db91..1c9bed35 100644 --- a/packages/adapters/src/qbittorrent/client.ts +++ b/packages/adapters/src/qbittorrent/client.ts @@ -90,6 +90,12 @@ export interface QbTorrent { eta: number | null; ratio: number | null; seedingTimeSec: number | null; + /** 已连接的做种 / 下载用户数(num_seeds / num_leechs) */ + numSeeds: number; + numLeechs: number; + /** Tracker 报告的做种 / 下载总数(num_complete / num_incomplete;未知为 null) */ + numComplete: number | null; + numIncomplete: number | null; } export interface QbFile { @@ -226,6 +232,11 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); function num(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } +/** qB 用 -1 表示未知(num_complete 等) */ +function nonNeg(v: unknown): number | null { + const n = num(v); + return n !== null && n >= 0 ? n : null; +} function str(v: unknown): string | null { return typeof v === "string" && v !== "" ? v : null; } @@ -282,6 +293,10 @@ export function normalizeTorrent(instanceId: string, r: Record) eta: num(r.eta), ratio: num(r.ratio), seedingTimeSec: num(r.seeding_time), + numSeeds: num(r.num_seeds) ?? 0, + numLeechs: num(r.num_leechs) ?? 0, + numComplete: nonNeg(r.num_complete), + numIncomplete: nonNeg(r.num_incomplete), }; } diff --git a/packages/adapters/src/qbittorrent/qbittorrent.test.ts b/packages/adapters/src/qbittorrent/qbittorrent.test.ts index 68ffc011..eb305e87 100644 --- a/packages/adapters/src/qbittorrent/qbittorrent.test.ts +++ b/packages/adapters/src/qbittorrent/qbittorrent.test.ts @@ -359,6 +359,22 @@ describe("QbClient 查询与操作", () => { expect((await qb.findByHash("b".repeat(40)))!.state).toBe("missing_files"); }); + it("速度、剩余时间、连接数字段(DL-2);-1 的总数记为未知;transfer/info 为全局速度", async () => { + const { mock, qb } = await setup(); + Object.assign(mock.seed({ hash: "c".repeat(40), name: "C", progress: 0.3 }), { + dlspeed: 2048, + upspeed: 512, + eta: 90, + num_seeds: 4, + num_leechs: 2, + num_complete: 30, + }); + const c = (await qb.findByHash("c".repeat(40)))!; + expect(c).toMatchObject({ dlspeed: 2048, upspeed: 512, eta: 90, numSeeds: 4, numLeechs: 2, numComplete: 30 }); + expect(c.numIncomplete).toBeNull(); + expect(await qb.transferInfo()).toMatchObject({ dl_info_speed: 2048, up_info_speed: 512 }); + }); + it("文件列表、标签增删、删除必须显式 deleteFiles", async () => { const { mock, qb } = await setup(); const t = makeTorrent({ diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index a1ddfc48..3ff18ad8 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -8,6 +8,7 @@ import { MetadataEnrichOutput, WebSearchTestInput, WebSearchTestResult } from ". import { CapabilityDescriptor, KnownCapabilityId } from "./capability.ts"; import { type ApiScope, CursorQuery, cursorPage, HealthStatus, Id, IdParam } from "./common.ts"; import * as content from "./content.ts"; +import * as board from "./download-board.ts"; import * as events from "./events.ts"; import * as execution from "./execution.ts"; import * as files from "./files.ts"; @@ -1132,6 +1133,15 @@ export const Api = { }, R, ), + board: ep({ + method: "get", + path: "/download-tasks/board", + scope: "read", + summary: + "下载面板:按进行中 / 已完成分组列出下载任务,附每个种子的实时速度、剩余时间、连接数与下载器总速度(实时数据短时缓存)", + query: board.DownloadBoardQuery, + response: board.DownloadBoard, + }), pause: ep({ method: "post", path: "/download-tasks/{id}/pause", diff --git a/packages/contracts/src/download-board.ts b/packages/contracts/src/download-board.ts new file mode 100644 index 00000000..64bf5a77 --- /dev/null +++ b/packages/contracts/src/download-board.ts @@ -0,0 +1,80 @@ +import { z } from "zod"; +import { DecisionKind } from "./agent.ts"; +import { CursorQuery, Id, NonNegInt, Timestamp } from "./common.ts"; +import { type DownloadStatus, DownloadTask } from "./execution.ts"; +import { OrganizationStatus } from "./organization.ts"; + +// 下载面板(DL-2):下载任务 + 下载器实时数据(速度、剩余时间、连接数)+ 分组计数 + 下载器总速度。 +// 实时数据来自 qB torrents/info 与 transfer/info,服务端短 TTL 缓存(轮询不直打 qB)。 + +/** 进行中 = 排队/提交中/下载中/暂停/无进展/出错/缺失;已完成 = 下载完成(含做种)/入库中/已入库;已移除不出现 */ +export const DownloadBoardState = z.enum(["active", "completed"]); +export type DownloadBoardState = z.infer; + +export const DOWNLOAD_BOARD_STATUSES: Record = { + active: ["queued", "submitting", "downloading", "paused", "stalled", "failed", "missing"], + completed: ["completed", "importing", "imported"], +}; + +export const DownloadBoardQuery = CursorQuery.extend({ + state: DownloadBoardState.default("active"), + instanceId: Id.optional(), +}); +export type DownloadBoardQuery = z.infer; + +/** 单个种子的实时数据(qB torrents/info);下载器不可达或已不在下载器里时为 null */ +export const DownloadLive = z.object({ + /** 字节/秒 */ + dlSpeed: NonNegInt, + upSpeed: NonNegInt, + /** 剩余秒数;qB 的 8640000(无穷)与已完成记为 null */ + etaSec: NonNegInt.nullable(), + progress: z.number().min(0).max(1), + rawState: z.string(), + /** 已连接的做种 / 下载用户 */ + seeds: NonNegInt, + leechs: NonNegInt, + /** Tracker 报告的总数(未知为 null) */ + seedsTotal: NonNegInt.nullable(), + leechsTotal: NonNegInt.nullable(), + ratio: z.number().nullable(), + /** 下载器记录的完成时间 */ + completedAt: Timestamp.nullable(), +}); +export type DownloadLive = z.infer; + +export const DownloadBoardItem = DownloadTask.extend({ + live: DownloadLive.nullable(), + /** 已识别的作品 */ + media: z.object({ id: Id, title: z.string(), year: z.number().int().nullable() }).nullable(), + /** 最近一次整理结果:状态 + 原因码(人话见 reasons 表);有未关闭的待处理事项时带上,前端链过去 */ + organize: z + .object({ + recordId: Id, + status: OrganizationStatus, + reason: z.string().nullable(), + decision: z.object({ id: Id, kind: DecisionKind }).nullable(), + }) + .nullable(), +}); +export type DownloadBoardItem = z.infer; + +export const DownloaderTransfer = z.object({ + instanceId: Id, + name: z.string(), + reachable: z.boolean(), + dlSpeed: NonNegInt, + upSpeed: NonNegInt, +}); +export type DownloaderTransfer = z.infer; + +export const DownloadBoard = z.object({ + items: z.array(DownloadBoardItem), + nextCursor: z.string().nullable(), + counts: z.object({ active: NonNegInt, completed: NonNegInt }), + /** 下载器总速度(transfer/info,含非本系统管理的任务);不可达的实例速度记 0 */ + transfer: z.object({ dlSpeed: NonNegInt, upSpeed: NonNegInt, instances: z.array(DownloaderTransfer) }), + /** 实时数据的取样时间(缓存命中时早于请求时间);没有启用的下载器时为 null */ + liveAt: Timestamp.nullable(), +}); +export type DownloadBoard = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 830fc75d..7777d6c0 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,7 @@ export * from "./capabilities-io.ts"; export * from "./capability.ts"; export * from "./common.ts"; export * from "./content.ts"; +export * from "./download-board.ts"; export * from "./events.ts"; export * from "./execution.ts"; export * from "./identity.ts"; diff --git a/packages/testkit/src/qbittorrent/mock-qb.ts b/packages/testkit/src/qbittorrent/mock-qb.ts index 0afd0fa2..e77ece00 100644 --- a/packages/testkit/src/qbittorrent/mock-qb.ts +++ b/packages/testkit/src/qbittorrent/mock-qb.ts @@ -31,6 +31,10 @@ export interface MockQbTorrent { dlspeed: number; upspeed: number; eta: number; + num_seeds: number; + num_leechs: number; + num_complete: number; + num_incomplete: number; ratio: number; seeding_time: number; infohash_v1: string; @@ -171,6 +175,10 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise { dlspeed: 0, upspeed: 0, eta: 8640000, + num_seeds: 0, + num_leechs: 0, + num_complete: -1, + num_incomplete: -1, ratio: 0, seeding_time: 0, infohash_v1: t.hash, @@ -500,7 +508,12 @@ export async function createMockQb(opts: MockQbOptions = {}): Promise { }), "app/setPreferences": () => ({ status: 500, body: "mock: writing global preferences is not allowed" }), "transfer/info": () => ({ - body: JSON.stringify({ dl_info_speed: 0, up_info_speed: 0, connection_status: "connected" }), + // 与真实 qB 一致:全局速度是所有任务速度之和(不只是某个标签) + body: JSON.stringify({ + dl_info_speed: [...torrents.values()].reduce((a, t) => a + t.dlspeed, 0), + up_info_speed: [...torrents.values()].reduce((a, t) => a + t.upspeed, 0), + connection_status: "connected", + }), json: true, }), "torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),