Files
UHD_Media_Tool/packages/adapters/src/qbittorrent/client.ts
T
rootandClaude Opus 5.5 3b340745bf 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]>
2026-09-29 21:44:34 +08:00

868 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* qBittorrent WebUI API v2 客户端。
* 迁移来源:MoviePilot app/modules/qbittorrent/qbittorrent.py(e6820a8),逐函数对照见
* docs/porting/moviepilot-downloader-and-sites.md「W1-3a 实际迁移对照」。
* 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。
*/
import { randomBytes } from "node:crypto";
import { DownloaderError, type DownloaderErrorKind } from "./errors.ts";
import { assertSafeToDeleteData, type SafeDeleteCheck } from "./safe-delete.ts";
import { isDownloadComplete, mapQbState, type QbObservedState } from "./state.ts";
import { BencodeError, parseMagnet, parseTorrent } from "./torrent.ts";
export interface QbInstanceConfig {
/** 本系统内的下载器实例 ID(downloader_instance.id) */
instanceId: string;
/** 例:http://127.0.0.1:8080(不带 /api/v2) */
baseUrl: string;
username?: string;
password?: string;
/** qB 5.2+ WebUI API Key(WebUI 生成,形如 qbt_…);有则走 Authorization: Bearer,不调 /auth/login */
apiKey?: string;
/**
* 认证方式(对照 MP / qbittorrent-api):password 走 /auth/login 会话 cookie;api_key 只发 Bearer 头;
* none 不认证(qB 设了"对白名单子网跳过认证"的内网)。缺省按是否给了 apiKey 推断。
*/
authMode?: QbAuthMode;
/** 单次请求超时,默认 15000 */
timeoutMs?: number;
/** 幂等读请求的重试次数(不含首次),默认 2;写请求不自动重试 */
retries?: number;
/** 重试退避基数,指数增长,默认 300ms */
retryBaseMs?: number;
/** 提交后按 infohash/探针标签回查的次数与间隔,默认 9 次 × 3000ms(对照 MP get_torrent_id_by_tag;首查不等待) */
lookup?: { attempts: number; intervalMs: number };
/** 探针标签前缀,默认 uhd-probe- */
probeTagPrefix?: string;
/** 实例级默认:顺序下载 / 首尾块优先(对照 MP sequentail / first_last_piece 配置) */
sequentialDownload?: boolean;
firstLastPiecePrio?: boolean;
/** 注入 fetch(测试/代理) */
fetch?: typeof fetch;
}
export type QbAuthMode = "password" | "api_key" | "none";
/**
* 实例配置 → 认证参数(服务端各处构造客户端共用)。
* authKind 显式给出时按它;缺省兼容旧数据:有用户名 → 账密,无用户名但有凭证 → API Key,否则无认证。
*/
export function qbAuthConfig(i: {
authKind?: unknown;
username?: string | null;
secret?: string | null;
}): Pick<QbInstanceConfig, "authMode" | "username" | "password" | "apiKey"> {
const kind: QbAuthMode =
i.authKind === "password" || i.authKind === "api_key" || i.authKind === "none"
? i.authKind
: i.username
? "password"
: i.secret
? "api_key"
: "none";
if (kind === "api_key") return { authMode: kind, ...(i.secret ? { apiKey: i.secret } : {}) };
if (kind === "none") return { authMode: kind };
return { authMode: kind, username: i.username ?? "", ...(i.secret ? { password: i.secret } : {}) };
}
export interface QbTorrent {
instanceId: string;
hash: string;
infohashV1: string | null;
infohashV2: string | null;
name: string;
rawState: string;
state: QbObservedState;
/** 严格完成判定结果(progress>=1 && amountLeft<=0 && 终态) */
complete: boolean;
progress: number;
amountLeft: number;
size: number;
totalSize: number | null;
savePath: string;
contentPath: string | null;
category: string;
tags: string[];
addedOn: number | null;
completionOn: number | null;
dlspeed: number;
upspeed: number;
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 {
index: number;
name: string;
size: number;
progress: number;
/** 0 不下载,1 普通,6 高,7 最高 */
priority: number;
isSeed: boolean | null;
}
export type TorrentSource =
| { kind: "magnet"; uri: string }
| { kind: "url"; url: string }
| { kind: "file"; bytes: Uint8Array; filename?: string };
export interface AddTorrentRequest {
source: TorrentSource;
savePath: string;
category?: string;
tags?: string[];
paused?: boolean;
rename?: string;
contentLayout?: "Original" | "Subfolder" | "NoSubfolder";
sequential?: boolean;
firstLastPiecePrio?: boolean;
}
export interface AddTorrentResult {
accepted: boolean;
addedIds: string[];
raw: string;
}
export type SubmitOutcome =
| { kind: "added"; hash: string; via: "confirmed" | "added_ids" | "probe_tag" | "recovered_after_timeout" }
| { kind: "already_exists"; hash: string; existing: QbTorrent }
/** 下载器已接收(或可能已接收),但回查窗口内未找到任务;调用方稍后按 hash/probeTag 对账,不得盲目重提 */
| { kind: "accepted_unconfirmed"; hash: string | null; probeTag: string | null }
| { kind: "rejected"; reason: string; hash: string | null }
/** 确认未提交成功(回查未命中且无歧义)或下载器不可达;有 hash 时用同一 hash 重试是幂等的 */
| { kind: "unreachable"; error: DownloaderError; hash: string | null };
export interface TorrentQuery {
hashes?: string[];
tag?: string;
/** 须同时具备的标签(对照 MP get_torrents 的 tags 子集判断);第一个走 qB 原生 tag 参数 */
tags?: string[];
category?: string;
filter?:
| "all"
| "downloading"
| "seeding"
| "completed"
| "paused"
| "stopped"
| "active"
| "inactive"
| "stalled"
| "errored";
}
export interface QbMainData {
rid: number;
fullUpdate: boolean;
torrents: Record<string, Record<string, unknown>>;
torrentsRemoved: string[];
serverState: Record<string, unknown> | null;
}
/** 下载器端口(实例维度)。上层 tasks/agent 只依赖此接口。 */
export interface DownloaderClient {
readonly instanceId: string;
version(): Promise<{ app: string; webApi: string }>;
add(req: AddTorrentRequest): Promise<AddTorrentResult>;
submit(req: AddTorrentRequest): Promise<SubmitOutcome>;
list(q?: TorrentQuery): Promise<QbTorrent[]>;
/** 严格完成判定后的已完成任务(对照 get_completed_torrents) */
getCompleted(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
getDownloading(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
findByHash(hash: string): Promise<QbTorrent | null>;
files(hash: string, opts?: { retries?: number; intervalMs?: number }): Promise<QbFile[]>;
/** priority 0 不下载;fileIds 为空返回 false 且不发请求(对照 set_files) */
setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean>;
setForceStart(hashes: string[], enable?: boolean): Promise<void>;
recheck(hashes: string[]): Promise<void>;
setLocation(hashes: string[], location: string): Promise<void>;
setCategory(hashes: string[], category: string): Promise<void>;
changeTorrent(hash: string, opts: TorrentLimits): Promise<void>;
/** 只读下载器全局偏好(不改写,见 QA6) */
preferences(): Promise<Record<string, unknown>>;
contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder">;
transferInfo(): Promise<Record<string, unknown>>;
pause(hashes: string[]): Promise<void>;
resume(hashes: string[]): Promise<void>;
addTags(hashes: string[], tags: string[]): Promise<void>;
removeTags(hashes: string[], tags: string[]): Promise<void>;
deleteTagDefinitions(tags: string[]): Promise<void>;
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void>;
/**
* deleteFiles 必须显式给出。deleteFiles=true 时每个 hash 都要在 safety 里给出检查输入,
* 任一未通过 assertSafeToDeleteData 即抛 DownloaderError('unsafe_delete'),请求不会发出。
*/
delete(hashes: string[], opts: DeleteOptions): Promise<void>;
mainData(rid?: number): Promise<QbMainData>;
}
/** 删除选项:删数据必须附带逐个 hash 的安全检查输入(键为 hash,大小写不敏感) */
export type DeleteOptions =
| { deleteFiles: false }
| { deleteFiles: true; safety: Readonly<Record<string, SafeDeleteCheck>> };
export interface TorrentLimits {
/** KB/s,0 不限速 */
uploadLimitKBps?: number;
downloadLimitKBps?: number;
ratioLimit?: number;
/** 分钟 */
seedingTimeLimitMin?: number;
}
type Body = { kind: "form"; data: Record<string, string> } | { kind: "multipart"; data: FormData };
interface ReqOpts {
query?: Record<string, string | undefined>;
body?: Body;
idempotent: boolean;
}
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;
}
/**
* 从登录响应的 Set-Cookie 中取会话 cookie,返回可直接放进 Cookie 头的 "name=value"。
* 优先 SID / QBT_SID_<port> / 名字含 SID 的;否则取第一个有值的 cookie。
*/
export function pickSessionCookie(setCookies: readonly string[]): string | null {
const pairs = setCookies
.map((c) => /^\s*([^=;\s]+)=([^;]*)/.exec(c))
.filter((m): m is RegExpExecArray => m !== null && m[2] !== "")
.map((m) => ({ name: m[1]!, value: m[2]! }));
const hit =
pairs.find((p) => p.name === "SID") ??
pairs.find((p) => /^QBT_SID(_\d+)?$/i.test(p.name)) ??
pairs.find((p) => /sid/i.test(p.name)) ??
pairs[0];
return hit ? `${hit.name}=${hit.value}` : null;
}
export function normalizeTorrent(instanceId: string, r: Record<string, unknown>): QbTorrent {
const rawState = typeof r.state === "string" ? r.state : "";
const progress = num(r.progress) ?? 0;
const amountLeft = num(r.amount_left) ?? Number.POSITIVE_INFINITY;
const tags =
typeof r.tags === "string"
? r.tags
.split(",")
.map((t) => t.trim())
.filter(Boolean)
: [];
return {
instanceId,
hash: String(r.hash ?? "").toLowerCase(),
infohashV1: str(r.infohash_v1),
infohashV2: str(r.infohash_v2),
name: String(r.name ?? ""),
rawState,
state: mapQbState(rawState),
complete: isDownloadComplete({ progress, amountLeft, state: rawState }),
progress,
amountLeft,
size: num(r.size) ?? 0,
totalSize: num(r.total_size),
savePath: String(r.save_path ?? ""),
contentPath: str(r.content_path),
category: String(r.category ?? ""),
tags,
addedOn: num(r.added_on),
completionOn: (num(r.completion_on) ?? -1) > 0 ? num(r.completion_on) : null,
dlspeed: num(r.dlspeed) ?? 0,
upspeed: num(r.upspeed) ?? 0,
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),
};
}
export class QbClient implements DownloaderClient {
readonly instanceId: string;
private readonly base: string;
private readonly timeoutMs: number;
private readonly retries: number;
private readonly retryBaseMs: number;
private readonly lookup: { attempts: number; intervalMs: number };
private readonly probePrefix: string;
private readonly fetchImpl: typeof fetch;
private sid: string | null = null;
private loginInFlight: Promise<void> | null = null;
/** qB 5.x 把 pause/resume 改名为 stop/start;探测到后缓存 */
private legacyPauseApi: boolean | null = null;
private readonly authMode: QbAuthMode;
constructor(private readonly cfg: QbInstanceConfig) {
this.instanceId = cfg.instanceId;
this.authMode = cfg.authMode ?? (cfg.apiKey ? "api_key" : "password");
this.base = cfg.baseUrl.replace(/\/+$/, "");
this.timeoutMs = cfg.timeoutMs ?? 15_000;
this.retries = cfg.retries ?? 2;
this.retryBaseMs = cfg.retryBaseMs ?? 300;
this.lookup = cfg.lookup ?? { attempts: 9, intervalMs: 3000 };
this.probePrefix = cfg.probeTagPrefix ?? "uhd-probe-";
this.fetchImpl = cfg.fetch ?? fetch;
}
private err(kind: DownloaderErrorKind, message: string, endpoint: string, status?: number, cause?: unknown) {
return new DownloaderError(kind, `[qb:${this.instanceId}] ${message}`, {
instanceId: this.instanceId,
endpoint,
...(status !== undefined ? { status } : {}),
...(cause !== undefined ? { cause } : {}),
});
}
private async rawFetch(endpoint: string, init: RequestInit): Promise<Response> {
try {
return await this.fetchImpl(`${this.base}/api/v2/${endpoint}`, {
...init,
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (e) {
const name = (e as { name?: string })?.name;
if (name === "TimeoutError" || name === "AbortError") {
throw this.err("timeout", `request timed out after ${this.timeoutMs}ms`, endpoint, undefined, e);
}
throw this.err("network", `request failed: ${(e as Error)?.message ?? String(e)}`, endpoint, undefined, e);
}
}
/** 登录;并发调用合并为一次。 */
async login(): Promise<void> {
if (this.authMode !== "password") return;
if (!this.loginInFlight) {
this.loginInFlight = this.doLogin().finally(() => {
this.loginInFlight = null;
});
}
return this.loginInFlight;
}
/**
* 登录判定(对照 qbittorrent-api auth_log_in,覆盖 v4 / v5.0–5.2):
* - 成功:HTTP 200 且体为 "Ok."(≤5.1),或 204 空体(5.2+);
* - 会话 cookie 名不固定:≤5.1 为 SID,5.2+ 为 QBT_SID_<WebUI 端口>(容器内端口,不一定等于映射端口),
* 也可能被自定义 —— 一律取 Set-Cookie 里的会话 cookie 原名原值回传;
* - 失败:200 "Fails." / 401 → 凭据错误(auth);403 被封禁 / 429 → 限流(rate);5xx → protocol。
*/
private async doLogin(): Promise<void> {
const endpoint = "auth/login";
const res = await this.rawFetch(endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded", referer: this.base },
body: new URLSearchParams({ username: this.cfg.username ?? "", password: this.cfg.password ?? "" }),
});
const text = (await res.text()).trim();
const s = res.status;
if (s === 204 || (s === 200 && /^ok\.?$/i.test(text))) {
// 关闭鉴权或白名单 IP 时 qB 可能不下发 cookie,仍视为成功
this.sid = pickSessionCookie(res.headers.getSetCookie()) ?? "";
return;
}
if (s === 200 && /^fails\.?$/i.test(text))
throw this.err("auth", "login rejected: bad username or password", endpoint, s);
if (s === 429) throw this.err("rate", "login rate limited", endpoint, s);
if (s === 403) {
if (/ban/i.test(text))
throw this.err("rate", "login blocked: IP banned after too many failed attempts", endpoint, s);
throw this.err("auth", `login forbidden (HTTP 403${text ? `: ${text.slice(0, 80)}` : ""})`, endpoint, s);
}
if (s === 401)
throw this.err(
"auth",
"login rejected (HTTP 401): bad username or password; for a port-mapped container also check that WEBUI_PORT equals the published port (qB rejects mismatched Host)",
endpoint,
s,
);
if (s >= 500) throw this.err("protocol", `login HTTP ${s}`, endpoint, s);
throw this.err("protocol", `unexpected login response: HTTP ${s} ${text.slice(0, 80)}`, endpoint, s);
}
private async send(endpoint: string, opts: ReqOpts): Promise<Response> {
const attempts = opts.idempotent ? this.retries + 1 : 1;
let lastErr: DownloaderError | null = null;
for (let i = 0; i < attempts; i++) {
if (i > 0) await sleep(this.retryBaseMs * 2 ** (i - 1));
try {
return await this.sendOnce(endpoint, opts, false);
} catch (e) {
if (!(e instanceof DownloaderError)) throw e;
lastErr = e;
const retryable = e.kind === "network" || e.kind === "timeout" || e.kind === "rate" || e.kind === "protocol";
if (!retryable) throw e;
}
}
throw lastErr!;
}
private async sendOnce(endpoint: string, opts: ReqOpts, relogged: boolean): Promise<Response> {
const session = this.authMode === "password";
if (session && this.sid === null) await this.login();
const headers: Record<string, string> = { referer: this.base };
if (this.authMode === "api_key") {
if (!this.cfg.apiKey) throw this.err("auth", "API Key auth selected but no API Key configured", endpoint);
headers.authorization = `Bearer ${this.cfg.apiKey}`;
} else if (session && this.sid) headers.cookie = this.sid;
let body: RequestInit["body"] | undefined;
if (opts.body?.kind === "form") {
headers["content-type"] = "application/x-www-form-urlencoded";
body = new URLSearchParams(opts.body.data);
} else if (opts.body?.kind === "multipart") {
body = opts.body.data;
}
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) qs.set(k, v);
const path = qs.size ? `${endpoint}?${qs}` : endpoint;
const res = await this.rawFetch(path, { method: body ? "POST" : "GET", headers, ...(body ? { body } : {}) });
const s = res.status;
if (s === 401 || s === 403) {
const t = (await res.text().catch(() => "")).trim();
if (s === 403 && /ban/i.test(t))
throw this.err("rate", "IP banned after too many failed authentication attempts", endpoint, s);
if (session && !relogged) {
this.sid = null;
await this.login();
return this.sendOnce(endpoint, opts, true);
}
const why =
this.authMode === "api_key"
? "API Key rejected (check the key; API Key needs qBittorrent 5.2+)"
: this.authMode === "none"
? "WebUI requires authentication (use username/password or API Key, or whitelist this subnet in qB)"
: "session rejected after re-login";
throw this.err("auth", `unauthorized (HTTP ${s}): ${why}`, endpoint, s);
}
if (s === 404) {
const t = await res.text();
throw this.err("notfound", t || "not found", endpoint, s);
}
if (s === 429) {
await res.body?.cancel();
throw this.err("rate", "rate limited", endpoint, s);
}
if (s === 400 || s === 409 || s === 415) {
const t = await res.text();
throw this.err("rejected", t || `HTTP ${s}`, endpoint, s);
}
if (!res.ok) {
await res.body?.cancel();
throw this.err("protocol", `unexpected HTTP ${s}`, endpoint, s);
}
return res;
}
private async json<T>(endpoint: string, query?: Record<string, string | undefined>): Promise<T> {
const res = await this.send(endpoint, { idempotent: true, ...(query ? { query } : {}) });
const text = await res.text();
try {
return JSON.parse(text) as T;
} catch (e) {
throw this.err("protocol", "invalid JSON response", endpoint, res.status, e);
}
}
private async post(endpoint: string, data: Record<string, string>, idempotent = true): Promise<string> {
const res = await this.send(endpoint, { idempotent, body: { kind: "form", data } });
return res.text();
}
async version(): Promise<{ app: string; webApi: string }> {
const app = await (await this.send("app/version", { idempotent: true })).text();
const webApi = await (await this.send("app/webapiVersion", { idempotent: true })).text();
return { app: app.trim(), webApi: webApi.trim() };
}
async add(req: AddTorrentRequest): Promise<AddTorrentResult> {
const form = new FormData();
const src = req.source;
if (src.kind === "file") {
form.append(
"torrents",
new Blob([new Uint8Array(src.bytes)], { type: "application/x-bittorrent" }),
src.filename ?? "upload.torrent",
);
} else {
form.append("urls", src.kind === "magnet" ? src.uri : src.url);
}
form.append("savepath", req.savePath);
if (req.category !== undefined) form.append("category", req.category);
if (req.tags?.length) form.append("tags", req.tags.join(","));
const paused = req.paused ? "true" : "false";
form.append("paused", paused); // <= 4.x
form.append("stopped", paused); // >= 5.x
if (req.rename) form.append("rename", req.rename);
form.append("contentLayout", req.contentLayout ?? "Original");
form.append("autoTMM", "false");
const seq = req.sequential ?? this.cfg.sequentialDownload;
const flp = req.firstLastPiecePrio ?? this.cfg.firstLastPiecePrio;
if (seq !== undefined) form.append("sequentialDownload", String(seq));
if (flp !== undefined) form.append("firstLastPiecePrio", String(flp));
const res = await this.send("torrents/add", { idempotent: false, body: { kind: "multipart", data: form } });
const raw = (await res.text()).trim();
if (raw.startsWith("{")) {
try {
const j = JSON.parse(raw) as {
success_count?: number;
pending_count?: number;
added_torrent_ids?: string[];
};
const ids = (j.added_torrent_ids ?? []).map((x) => x.toLowerCase());
return { accepted: ids.length > 0 || (j.success_count ?? 0) + (j.pending_count ?? 0) > 0, addedIds: ids, raw };
} catch {
// 落到文本判定
}
}
return { accepted: raw.startsWith("Ok"), addedIds: [], raw };
}
async list(q: TorrentQuery = {}): Promise<QbTorrent[]> {
const need = [...(q.tag !== undefined ? [q.tag] : []), ...(q.tags ?? [])];
const rows = await this.json<Record<string, unknown>[]>("torrents/info", {
filter: q.filter,
category: q.category,
tag: need[0],
hashes: q.hashes?.length ? q.hashes.map((h) => h.toLowerCase()).join("|") : undefined,
});
if (!Array.isArray(rows)) throw this.err("protocol", "torrents/info did not return an array", "torrents/info");
const list = rows.map((r) => normalizeTorrent(this.instanceId, r));
return need.length > 1 ? list.filter((t) => need.every((tag) => t.tags.includes(tag))) : list;
}
async getCompleted(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
return (await this.list({ ...q, filter: "completed" })).filter((t) => t.complete);
}
async getDownloading(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
return this.list({ ...q, filter: "downloading" });
}
/** 按 hash 精确查询,不受管理标签限制。查不到返回 null;下载器不可达抛错(不能当作不存在)。 */
async findByHash(hash: string): Promise<QbTorrent | null> {
const h = hash.toLowerCase();
const rows = await this.list({ hashes: [h] });
return rows.find((t) => t.hash === h || t.infohashV1 === h || t.infohashV2?.startsWith(h)) ?? null;
}
/** 文件列表;空列表(磁力元数据未就绪)按 retries/intervalMs 重试(对照 get_files)。 */
async files(hash: string, opts: { retries?: number; intervalMs?: number } = {}): Promise<QbFile[]> {
const tries = Math.max(1, opts.retries ?? 1);
let rows: Record<string, unknown>[] = [];
for (let i = 0; i < tries; i++) {
if (i > 0 && opts.intervalMs) await sleep(opts.intervalMs);
rows = await this.json<Record<string, unknown>[]>("torrents/files", { hash: hash.toLowerCase() });
if (rows.length) break;
}
return rows.map((r, i) => ({
index: num(r.index) ?? i,
name: String(r.name ?? ""),
size: num(r.size) ?? 0,
progress: num(r.progress) ?? 0,
priority: num(r.priority) ?? 1,
isSeed: typeof r.is_seed === "boolean" ? r.is_seed : null,
}));
}
private async pauseLike(hashes: string[], legacy: string, modern: string): Promise<void> {
if (!hashes.length) return;
const data = { hashes: hashes.join("|") };
if (this.legacyPauseApi !== false) {
try {
await this.post(legacy, data);
this.legacyPauseApi = true;
return;
} catch (e) {
if (!(e instanceof DownloaderError && e.kind === "notfound" && this.legacyPauseApi === null)) throw e;
this.legacyPauseApi = false;
}
}
await this.post(modern, data);
}
pause(hashes: string[]): Promise<void> {
return this.pauseLike(hashes, "torrents/pause", "torrents/stop");
}
resume(hashes: string[]): Promise<void> {
return this.pauseLike(hashes, "torrents/resume", "torrents/start");
}
async addTags(hashes: string[], tags: string[]): Promise<void> {
await this.post("torrents/addTags", { hashes: hashes.join("|"), tags: tags.join(",") });
}
async removeTags(hashes: string[], tags: string[]): Promise<void> {
await this.post("torrents/removeTags", { hashes: hashes.join("|"), tags: tags.join(",") });
}
async deleteTagDefinitions(tags: string[]): Promise<void> {
await this.post("torrents/deleteTags", { tags: tags.join(",") });
}
async deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void> {
if (hashes?.length) await this.removeTags(hashes, tags);
await this.deleteTagDefinitions(tags);
}
async setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean> {
if (!hash || !fileIds.length) return false;
await this.post("torrents/filePrio", {
hash: hash.toLowerCase(),
id: fileIds.join("|"),
priority: String(priority),
});
return true;
}
async setForceStart(hashes: string[], enable = true): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/setForceStart", { hashes: hashes.join("|"), value: String(enable) });
}
async recheck(hashes: string[]): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/recheck", { hashes: hashes.join("|") });
}
async setLocation(hashes: string[], location: string): Promise<void> {
if (!hashes.length || !location) return;
await this.post("torrents/setLocation", { hashes: hashes.join("|"), location });
}
async setCategory(hashes: string[], category: string): Promise<void> {
if (!hashes.length) return;
await this.post("torrents/setCategory", { hashes: hashes.join("|"), category });
}
/** 对照 change_torrent:限速单位 KB/s → B/s;只给 seedingTime 时 ratioLimit=-2(沿用全局)。 */
async changeTorrent(hash: string, o: TorrentLimits): Promise<void> {
const h = hash.toLowerCase();
if (o.uploadLimitKBps !== undefined) {
await this.post("torrents/setUploadLimit", { hashes: h, limit: String(Math.trunc(o.uploadLimitKBps * 1024)) });
}
if (o.downloadLimitKBps !== undefined) {
await this.post("torrents/setDownloadLimit", {
hashes: h,
limit: String(Math.trunc(o.downloadLimitKBps * 1024)),
});
}
if (o.ratioLimit !== undefined || o.seedingTimeLimitMin !== undefined) {
await this.post("torrents/setShareLimits", {
hashes: h,
ratioLimit: o.ratioLimit !== undefined ? String(Math.round(o.ratioLimit * 100) / 100) : "-2",
seedingTimeLimit: String(Math.trunc(o.seedingTimeLimitMin ?? -1)),
inactiveSeedingTimeLimit: "-1",
});
}
}
async preferences(): Promise<Record<string, unknown>> {
return this.json<Record<string, unknown>>("app/preferences");
}
async contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder"> {
const v = (await this.preferences()).torrent_content_layout;
return v === "Subfolder" || v === "NoSubfolder" ? v : "Original";
}
async transferInfo(): Promise<Record<string, unknown>> {
return this.json<Record<string, unknown>>("transfer/info");
}
async delete(hashes: string[], opts: DeleteOptions): Promise<void> {
if (typeof opts?.deleteFiles !== "boolean") throw new TypeError("deleteFiles must be explicit");
if (!hashes.length) return;
if (opts.deleteFiles) {
const safety = new Map(Object.entries(opts.safety ?? {}).map(([h, c]) => [h.toLowerCase(), c]));
for (const h of hashes) {
const check = safety.get(h.toLowerCase());
if (!check) {
throw this.err("unsafe_delete", `拒绝删除数据: ${h} 缺少安全检查输入`, "torrents/delete");
}
assertSafeToDeleteData(check, this.instanceId);
}
}
await this.post("torrents/delete", { hashes: hashes.join("|"), deleteFiles: String(opts.deleteFiles) });
}
async mainData(rid = 0): Promise<QbMainData> {
const j = await this.json<Record<string, unknown>>("sync/maindata", { rid: String(rid) });
return {
rid: num(j.rid) ?? 0,
fullUpdate: j.full_update === true,
torrents: (j.torrents as Record<string, Record<string, unknown>>) ?? {},
torrentsRemoved: (j.torrents_removed as string[]) ?? [],
serverState: (j.server_state as Record<string, unknown>) ?? null,
};
}
private async pollHash(hash: string): Promise<QbTorrent | null> {
for (let i = 0; i < this.lookup.attempts; i++) {
if (i > 0) await sleep(this.lookup.intervalMs);
try {
const t = await this.findByHash(hash);
if (t) return t;
} catch (e) {
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
}
}
return null;
}
private async pollTag(tag: string): Promise<QbTorrent | null> {
for (let i = 0; i < this.lookup.attempts; i++) {
if (i > 0) await sleep(this.lookup.intervalMs);
try {
const rows = await this.list({ tag });
if (rows[0]) return rows[0];
} catch (e) {
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
}
}
return null;
}
private async dropProbe(tag: string, hash: string | null): Promise<void> {
try {
if (hash) await this.removeTags([hash], [tag]);
await this.deleteTagDefinitions([tag]);
} catch {
// 残留探针标签由对账任务按前缀清理
}
}
/**
* 幂等提交:本地算 infohash → 预查是否已存在 → add → 按 hash(或探针标签)确认。
* add 超时/断连时不重提,先回查(REQ §8.4)。
*/
async submit(req: AddTorrentRequest): Promise<SubmitOutcome> {
let hash: string | null = null;
const src = req.source;
if (src.kind === "file") {
try {
hash = parseTorrent(src.bytes).hash;
} catch (e) {
if (e instanceof BencodeError) return { kind: "rejected", reason: `invalid torrent: ${e.message}`, hash: null };
throw e;
}
} else if (src.kind === "magnet") {
const m = parseMagnet(src.uri);
if (!m) return { kind: "rejected", reason: "invalid magnet uri", hash: null };
hash = m.hash;
} else {
hash = parseMagnet(src.url)?.hash ?? null;
}
if (hash) {
try {
const existing = await this.findByHash(hash);
if (existing) return { kind: "already_exists", hash: existing.hash, existing };
} catch (e) {
if (e instanceof DownloaderError) return { kind: "unreachable", error: e, hash };
throw e;
}
}
const probeTag = hash ? null : `${this.probePrefix}${Date.now().toString(36)}${randomBytes(5).toString("hex")}`;
const tags = probeTag ? [...(req.tags ?? []), probeTag] : req.tags;
let result: AddTorrentResult;
try {
result = await this.add({ ...req, ...(tags ? { tags } : {}) });
} catch (e) {
if (!(e instanceof DownloaderError)) throw e;
if (e.ambiguous) {
if (hash) {
const t = await this.pollHash(hash).catch(() => null);
if (t) return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
return { kind: "unreachable", error: e, hash };
}
const t = await this.pollTag(probeTag!).catch(() => null);
if (t) {
await this.dropProbe(probeTag!, t.hash);
return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
}
return { kind: "accepted_unconfirmed", hash: null, probeTag };
}
if (e.kind === "rejected") {
if (hash) {
const t = await this.findByHash(hash).catch(() => null);
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
}
if (probeTag) await this.dropProbe(probeTag, null);
return { kind: "rejected", reason: e.message, hash };
}
return { kind: "unreachable", error: e, hash };
}
if (!result.accepted) {
// 4.x:已存在/非法链接都返回 Fails.;先查是否已存在
if (hash) {
const t = await this.findByHash(hash).catch(() => null);
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
}
if (probeTag) await this.dropProbe(probeTag, null);
return { kind: "rejected", reason: `downloader refused: ${result.raw.slice(0, 200)}`, hash };
}
const knownHash = hash ?? result.addedIds[0] ?? null;
if (knownHash) {
const t = await this.pollHash(knownHash).catch(() => null);
if (probeTag) await this.dropProbe(probeTag, t?.hash ?? knownHash);
if (t) return { kind: "added", hash: t.hash, via: hash ? "confirmed" : "added_ids" };
return { kind: "accepted_unconfirmed", hash: knownHash, probeTag: null };
}
const t = await this.pollTag(probeTag!).catch(() => null);
if (t) {
await this.dropProbe(probeTag!, t.hash);
return { kind: "added", hash: t.hash, via: "probe_tag" };
}
return { kind: "accepted_unconfirmed", hash: null, probeTag };
}
}
export function createQbClient(cfg: QbInstanceConfig): DownloaderClient & { login(): Promise<void> } {
return new QbClient(cfg);
}
/** 多实例注册表:按 instanceId 取客户端。 */
export class DownloaderRegistry {
private readonly clients = new Map<string, DownloaderClient>();
constructor(configs: QbInstanceConfig[] = []) {
for (const c of configs) this.register(c);
}
register(cfg: QbInstanceConfig): DownloaderClient {
const c = new QbClient(cfg);
this.clients.set(cfg.instanceId, c);
return c;
}
get(instanceId: string): DownloaderClient | undefined {
return this.clients.get(instanceId);
}
remove(instanceId: string): boolean {
return this.clients.delete(instanceId);
}
list(): DownloaderClient[] {
return [...this.clients.values()];
}
}