Merge branch 'dl2'
Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/web/app/routes/console/downloads.tsx # docs/plan/00-first-version-plan.md
This commit is contained in:
commit
8315ece4d1
17 files changed
+1113
-93
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, DownloadLive>;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
stamp: string;
|
||||
at: number;
|
||||
done: boolean;
|
||||
p: Promise<InstanceLive>;
|
||||
}
|
||||
|
||||
const caches = new WeakMap<AoDeps, Map<string, Entry>>();
|
||||
|
||||
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<InstanceLive> {
|
||||
const base = { instanceId: inst.id, name: inst.name };
|
||||
let client: Awaited<ReturnType<AoDeps["ports"]["downloader"]>>;
|
||||
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<InstanceLive> {
|
||||
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<DownloadBoard> {
|
||||
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<DownloadTask>(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(),
|
||||
};
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<DownloadBoardItem, "status" | "organize">): 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<DownloadBoardItem, "progress" | "live">): 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) },
|
||||
];
|
||||
}
|
||||
@@ -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 (
|
||||
<span className={cn("inline-flex items-center gap-2.5 tabular-nums whitespace-nowrap", className)}>
|
||||
<span className="inline-flex items-center gap-0.5" title="下载速度">
|
||||
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(dl)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="上传速度">
|
||||
<ArrowUp aria-label="上传" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 总速度一行;不可达的下载器单独列出 */
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span title={perInstance} className="text-foreground">
|
||||
<SpeedPair dl={transfer.dlSpeed} up={transfer.upSpeed} />
|
||||
</span>
|
||||
{down.length ? (
|
||||
<span className="text-meta text-warning">{down.map((i) => `「${i.name}」`).join("")}连接失败</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="下载任务分组"
|
||||
className="inline-flex gap-0.5 rounded-xl border border-border p-0.5"
|
||||
>
|
||||
{options.map((o) => (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1 text-caption transition-colors duration-micro",
|
||||
value === o.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
{counts ? <span className="tabular-nums opacity-80">{counts[o.value]}</span> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkLine({ t, extra }: { t: DownloadBoardItem; extra?: ReactNode }) {
|
||||
if (!t.media && !extra) return null;
|
||||
return (
|
||||
<p className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 text-meta text-muted-foreground">
|
||||
{t.media ? (
|
||||
<Link
|
||||
to={consolePath("library", t.media.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
{t.media.title}
|
||||
{t.media.year ? ` (${t.media.year})` : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
{extra}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function NameCell({ t, children }: { t: DownloadBoardItem; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0 max-w-md">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveStatus({ t }: { t: DownloadBoardItem }) {
|
||||
const live = liveStateLabel(t.live?.rawState);
|
||||
return (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<DownloadStatusBadges t={t} />
|
||||
{live ? <StatusBadge>{live}</StatusBadge> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function activeColumns(): DataColumn<DownloadBoardItem>[] {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
{/* 窄屏:进度与速度并进名称列 */}
|
||||
<div className="mt-1.5 space-y-1 md:hidden">
|
||||
<ActiveStatus t={t} />
|
||||
<ProgressLine t={{ ...t, progress: progressOf(t) }} />
|
||||
{t.live?.dlSpeed || t.live?.upSpeed ? (
|
||||
<p className="flex flex-wrap items-center gap-x-3 text-meta text-muted-foreground">
|
||||
<SpeedPair dl={t.live.dlSpeed} up={t.live.upSpeed} />
|
||||
{t.live.etaSec !== null ? <span>剩余 {formatEta(t.live.etaSec)}</span> : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</NameCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "progress",
|
||||
header: "进度",
|
||||
hideBelow: "md",
|
||||
className: "w-40",
|
||||
cell: (t) => <ProgressLine t={{ ...t, progress: progressOf(t) }} />,
|
||||
},
|
||||
{
|
||||
key: "speed",
|
||||
header: "速度",
|
||||
hideBelow: "md",
|
||||
cell: (t) =>
|
||||
!t.live?.dlSpeed && !t.live?.upSpeed ? (
|
||||
<span className="text-meta text-muted-foreground">—</span>
|
||||
) : (
|
||||
<div className="space-y-0.5 text-meta tabular-nums whitespace-nowrap">
|
||||
<p className="flex items-center gap-0.5 text-foreground">
|
||||
<ArrowDown aria-label="下载" className="size-3.5 text-muted-foreground" />
|
||||
{formatSpeed(t.live?.dlSpeed)}
|
||||
</p>
|
||||
<p className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live?.upSpeed)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "eta",
|
||||
header: "剩余时间",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => (
|
||||
<div className="text-meta whitespace-nowrap">
|
||||
<p className="text-foreground tabular-nums">{formatEta(t.live?.etaSec)}</p>
|
||||
{peersText(t.live) ? <p className="text-muted-foreground tabular-nums">{peersText(t.live)}</p> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", hideBelow: "md", cell: (t) => <ActiveStatus t={t} /> },
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
}
|
||||
|
||||
function OrganizeBadge({ t }: { t: DownloadBoardItem }) {
|
||||
const s = organizeState(t);
|
||||
const badge = <StatusBadge tone={s.tone}>{s.label}</StatusBadge>;
|
||||
return (
|
||||
<div className="flex max-w-56 flex-col items-start gap-0.5">
|
||||
{s.to ? (
|
||||
<Link
|
||||
to={s.to}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={t.organize?.decision ? "去待处理" : "查看整理记录"}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{badge}
|
||||
</Link>
|
||||
) : (
|
||||
badge
|
||||
)}
|
||||
{s.hint ? <span className="line-clamp-2 text-micro text-muted-foreground">{s.hint}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 完成时间以下载器记录为准(接管的任务在数据库里记的是接管时刻) */
|
||||
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<DownloadBoardItem>[] {
|
||||
return [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<NameCell t={t}>
|
||||
<WorkLine t={t} />
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 text-meta text-muted-foreground tabular-nums md:hidden">
|
||||
<span>{sizeText(t)}</span>
|
||||
<span>{relTime(completedAtOf(t))}完成</span>
|
||||
{ratioOf(t) !== null ? <span>分享率 {ratioOf(t)?.toFixed(2)}</span> : null}
|
||||
{t.live?.upSpeed ? (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live.upSpeed)}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
</NameCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "md",
|
||||
align: "right",
|
||||
cell: (t) => <span className="whitespace-nowrap tabular-nums">{sizeText(t)}</span>,
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
header: "完成时间",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground" title={formatDateTime(completedAtOf(t))}>
|
||||
{relTime(completedAtOf(t))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "ratio",
|
||||
header: "分享率 / 上传",
|
||||
hideBelow: "lg",
|
||||
cell: (t) => (
|
||||
<div className="text-meta whitespace-nowrap tabular-nums">
|
||||
<p className="text-foreground">{ratioOf(t) === null ? "—" : ratioOf(t)?.toFixed(2)}</p>
|
||||
{t.live?.upSpeed ? (
|
||||
<p className="flex items-center gap-0.5 text-muted-foreground">
|
||||
<ArrowUp aria-label="上传" className="size-3.5" />
|
||||
{formatSpeed(t.live.upSpeed)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "organize", header: "整理", cell: (t) => <OrganizeBadge t={t} /> },
|
||||
];
|
||||
}
|
||||
|
||||
export const BOARD_EMPTY: Record<DownloadBoardState, { title: string; description: string; icon: typeof Download }> = {
|
||||
active: {
|
||||
icon: Download,
|
||||
title: "没有进行中的下载",
|
||||
description: "自动收录或在站点页下载后出现在这里;下载完成的在「已完成」。",
|
||||
},
|
||||
completed: {
|
||||
icon: CheckCircle2,
|
||||
title: "还没有下载完成的任务",
|
||||
description: "下载完成(含做种)后出现在这里。",
|
||||
},
|
||||
};
|
||||
@@ -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 { Link } from "react-router";
|
||||
@@ -26,6 +26,7 @@ import { errorMessage } from "~/lib/api.ts";
|
||||
import { planReasonText } from "~/lib/labels.ts";
|
||||
import { humanizeReason, reasonLine } from "~/lib/reasons.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<string, string> = { fill: "补缺", upgrade: "升级", replace_source: "换源" };
|
||||
@@ -91,7 +92,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);
|
||||
@@ -157,13 +158,14 @@ export function DownloadDetail({ task }: { task: DownloadTask }) {
|
||||
) : null}
|
||||
|
||||
<DetailSection title="进度">
|
||||
<ProgressLine t={task} />
|
||||
<ProgressLine t={live ? { ...task, progress: live.progress } : task} />
|
||||
<KvList
|
||||
className="mt-3"
|
||||
items={[
|
||||
{ label: "下载器", value: names.data?.get(task.instanceId) ?? task.instanceId.slice(0, 8) },
|
||||
{ label: "大小", value: task.sizeBytes === null ? "未知" : formatBytes(task.sizeBytes, 2) },
|
||||
{ label: "剩余", value: task.remainingBytes === null ? "未知" : formatBytes(task.remainingBytes, 2) },
|
||||
...liveItems(live),
|
||||
{ label: "分享率", value: task.ratio === null ? "—" : task.ratio.toFixed(2) },
|
||||
{ label: "最近进展", value: relTime(task.lastProgressAt) },
|
||||
{ label: "保存路径", value: task.savePath, mono: true, wide: true },
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DownloadStatus, DownloadTask } from "@umt/contracts";
|
||||
import type { DownloadBoardState } from "@umt/contracts";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import { Download, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { DOWNLOAD_STATUS_LABEL, useDownloaderNames, useDownloadTasks } from "~/api/downloads.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { useDownloadBoard } from "~/api/downloads.ts";
|
||||
import { DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { readBoardState, writeBoardState } from "~/components/downloads/board-format.ts";
|
||||
import { CacheVolumes } from "~/components/downloads/cache-volumes.tsx";
|
||||
import {
|
||||
DownloadDetail,
|
||||
DownloadStatusBadges,
|
||||
PauseResume,
|
||||
ProgressLine,
|
||||
} from "~/components/downloads/download-detail.tsx";
|
||||
activeColumns,
|
||||
BOARD_EMPTY,
|
||||
BoardSegments,
|
||||
completedColumns,
|
||||
TransferLine,
|
||||
} from "~/components/downloads/download-board.tsx";
|
||||
import { DownloadDetail } from "~/components/downloads/download-detail.tsx";
|
||||
import { DownloadsTabs } from "~/components/downloads/downloads-tabs.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
export const meta = () => [{ title: "下载面板 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// DL-2:默认只看进行中;已完成单独一段(本浏览器记住选择)。页面可见时每 4 秒刷新速度,切到后台停。
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const f = useQueryState(["status", "managed"] as const);
|
||||
// SPA 模式:路由组件只在浏览器渲染,可直接读本地记住的分段
|
||||
const [state, setStateRaw] = useState<DownloadBoardState>(readBoardState);
|
||||
const setState = (s: DownloadBoardState) => {
|
||||
setStateRaw(s);
|
||||
writeBoardState(s);
|
||||
};
|
||||
const [openId, setOpenId] = useState<string | null>(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<DownloadTask>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "下载任务",
|
||||
cell: (t) => (
|
||||
<div className="min-w-0 max-w-sm">
|
||||
<p className="line-clamp-2 break-all text-foreground">{t.name}</p>
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">
|
||||
{names.data?.get(t.instanceId) ?? "下载器"} · {t.tags.join(", ") || "无标签"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: "status", header: "状态", cell: (t) => <DownloadStatusBadges t={t} /> },
|
||||
{ key: "progress", header: "进度", hideBelow: "sm", cell: (t) => <ProgressLine t={t} /> },
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "lg",
|
||||
align: "right",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap tabular-nums">
|
||||
{t.sizeBytes === null ? "未知" : formatBytes(t.sizeBytes, 1)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "at",
|
||||
header: "更新",
|
||||
hideBelow: "md",
|
||||
cell: (t) => (
|
||||
<span className="whitespace-nowrap text-muted-foreground">{relTime(t.lastSyncedAt ?? t.updatedAt)}</span>
|
||||
),
|
||||
},
|
||||
{ key: "ops", header: "", align: "right", cell: (t) => <PauseResume t={t} size="xs" /> },
|
||||
];
|
||||
const columns = useMemo(() => (state === "active" ? activeColumns() : completedColumns()), [state]);
|
||||
const empty = BOARD_EMPTY[state];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -93,47 +62,30 @@ export default function DownloadsPage() {
|
||||
title="下载面板"
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={() => void qc.invalidateQueries({ queryKey: ["downloads"] })}>
|
||||
<RefreshCw className={downloads.isFetching ? "animate-spin" : undefined} />
|
||||
<RefreshCw className={board.isFetching ? "animate-spin" : undefined} />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DownloadsTabs />
|
||||
<CacheVolumes />
|
||||
<FilterBar onReset={f.active ? f.reset : undefined}>
|
||||
<FilterSelect
|
||||
label="状态"
|
||||
value={f.values.status}
|
||||
onChange={(v) => f.set("status", v)}
|
||||
options={Object.entries(DOWNLOAD_STATUS_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="来源"
|
||||
value={f.values.managed}
|
||||
onChange={(v) => f.set("managed", v)}
|
||||
options={[
|
||||
{ value: "true", label: "本工具" },
|
||||
{ value: "false", label: "其他" },
|
||||
]}
|
||||
/>
|
||||
</FilterBar>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<BoardSegments value={state} counts={first?.counts} onChange={setState} />
|
||||
<TransferLine transfer={first?.transfer} />
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(t) => 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}
|
||||
/>
|
||||
|
||||
<DetailSheet
|
||||
@@ -149,7 +101,7 @@ export default function DownloadsPage() {
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{open ? <DownloadDetail task={open} /> : null}
|
||||
{open ? <DownloadDetail task={open} live={open.live} /> : null}
|
||||
</DetailSheet>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -183,5 +183,6 @@ 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 旁。
|
||||
- **文件管理(FM-2,用户 2026-09-29)**:左侧目录树再往左靠,页面主体做大(文件管理页用宽布局);调研除视频播放外可集成的基础预览,先出调研结论。→ 已做(分支 fm2):文件管理宽布局(目录树贴侧栏、可拖宽/折叠、手机抽屉);首批预览(文本/NFO 编码探测、字幕前 200 条、.torrent、图片尺寸);调研见 `docs/design/file-preview-research.md`。
|
||||
- **Worker 分道(WK-1,用户 2026-09-29 讨论后认可,排在本轮测试之后)**:不再拆进程;IO Worker 内分三条道——文件(整理/硬链接/ffprobe/校验,并发 2)、网络(站点/RSS/TMDB/搜索,3–4,单站限流另计)、维护(清理/回填/巡检,1,空闲时跑),并发可在设置调。拆进程的触发条件:心跳事件循环延迟在整理期间持续 >200ms,或整理需要贴近存储部署到另一台机器。
|
||||
@@ -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