feat(downloads): live speeds and active/completed split on the download panel (DL-2)
- GET /download-tasks/board?state=active|completed: per-torrent dl/up speed, ETA, seeds/leechs, downloader total speed (transfer/info), counts for both groups, latest organize result (status, reason code, open decision) - live qB data cached per instance for 2 s with in-flight coalescing - panel: total speed line, 进行中 / 已完成 segments remembered locally, 4 s polling while the page is visible, mobile layout, "—" for zero speed Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
8086250ec2
commit
3b340745bf
17 files changed
+1113
-93
No files matched your search
@@ -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<string, unknown>)
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<typeof DownloadBoardState>;
|
||||
|
||||
export const DOWNLOAD_BOARD_STATUSES: Record<DownloadBoardState, readonly DownloadStatus[]> = {
|
||||
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<typeof DownloadBoardQuery>;
|
||||
|
||||
/** 单个种子的实时数据(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<typeof DownloadLive>;
|
||||
|
||||
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<typeof DownloadBoardItem>;
|
||||
|
||||
export const DownloaderTransfer = z.object({
|
||||
instanceId: Id,
|
||||
name: z.string(),
|
||||
reachable: z.boolean(),
|
||||
dlSpeed: NonNegInt,
|
||||
upSpeed: NonNegInt,
|
||||
});
|
||||
export type DownloaderTransfer = z.infer<typeof DownloaderTransfer>;
|
||||
|
||||
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<typeof DownloadBoard>;
|
||||
@@ -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";
|
||||
|
||||
@@ -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<MockQb> {
|
||||
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<MockQb> {
|
||||
}),
|
||||
"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 }),
|
||||
|
||||
Reference in new issue
Block a user