Files
mesh-catalog/modules/qbittorrent/client.ts
T
jschoubben 5012f61b00 qbittorrent: placed config, the build ace runs, a login 5.2 accepts, its ports as announced
The manifest named /services/qbittorrent/config and /var/lib/mesh/qbittorrent/
config.json, host paths ADR 0112 takes out of definitions. The config dir is now
pathless (${dir:config}); the runtime's config and route binding live in a
placed state dir.

The image is pinned to 5.2.3_v2.0.14-ls477, the digest ace runs; the old pin
(ls474) was older than the running build.

The tools could not log in to qBittorrent 5.2: it answers a good login with 204
and no body (not 200 "Ok.") and names its cookie QBT_SID_<port> (not SID). The
client now accepts both shapes and sends the cookie back under the name it was
set. It also read the user as "admin" always; it now reads WebUI\Username from
qBittorrent.conf on the read-only config mount.

qBittorrent refuses a request whose Host header names a port other than the one
it listens on. With the mesh publishing 8080 on another machine port, the tools
and every consumer dialling that port were refused. The WebUI now listens on
8112 (WEBUI_PORT, ace's and HAL's number, and clear of unifi's 8080) and is
published on the same number. The torrent port 6881 tcp+udp was not declared at
all; it is now, long-form, because the client announces it to peers.

sonarr, radarr and lidarr reached it by container name on HAL's shared network.
qbittorrent now provides qbittorrent-api (node scope: a download client must share
the consumer's spool) and serves scheme, port, url-base and username; the
password is the operator-accepted pair credential, as for #156. The web endpoint
is routed (label qbittorrent). The runtime dials ${port:8112}, as #154 does.

Verified: catalogue tests with MESH_CATALOGUE set (not skipped); rendered for ace
with pins and without (8112:8112, 6881:6881, 6881:6881/udp either way); a
throwaway of the pinned image on an ace-shaped conf showed the port-mismatch
refusal, then on a same-number port the compiled client logged in, read the user
from qBittorrent.conf, listed torrents and was refused a wrong password and the
default user; strict typecheck and the Dockerfile build pass.
2026-09-30 12:00:41 +02:00

214 lines
9.4 KiB
TypeScript

// The qBittorrent API client — qbittorrent's own code, living in the module (novox/hq ADR 0039).
// Written against the WebUI API (/api/v2/...), self-contained so a change to it rebuilds only
// qbittorrent. Both this module's tools and its events entrypoint import it, and nothing outside
// qbittorrent does.
//
// The WebUI authenticates with a session cookie obtained by POSTing credentials, and guards
// against CSRF by checking the Referer header. The cookie was `SID` before qBittorrent 5.2 and is
// `QBT_SID_<port>` since, and a successful login answers 200 "Ok." before and 204 with no body
// since — both are accepted. Node's fetch keeps no cookie jar, so the cookie is
// captured on login and carried by hand on every later call, with a single re-login on expiry.
import { readFileSync } from "node:fs";
export interface QbTransferInfo {
dlSpeedBytesPerSec: number;
upSpeedBytesPerSec: number;
dlData: number;
upData: number;
connectionStatus: string;
}
export interface QbTorrent {
hash: string;
name: string;
/** qBittorrent's state, e.g. "downloading", "stalledUP", "uploading", "pausedUP", "error". */
state: string;
/** 0..1 — 1 means the download is complete. */
progress: number;
sizeBytes: number;
dlSpeed: number;
upSpeed: number;
category: string;
ratio: number;
savePath: string;
}
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
function meshConfig(file?: string): Record<string, string> {
if (!file) return {};
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
catch { return {}; }
}
/** The WebUI username from qBittorrent's own qBittorrent.conf, in the config directory the mesh
* mounts read-only (MESH_QBITTORRENT_CONFIG_DIR, which is the container's /config). The software's
* file is the truth about who may log in, so the tools ask it rather than a setting that could
* disagree. Absent, unreadable or unset yields undefined. */
function confUsername(dir: string | undefined): string | undefined {
if (!dir) return undefined;
try {
const line = readFileSync(`${dir.replace(/\/$/, "")}/qBittorrent/qBittorrent.conf`, "utf8")
.split(/\r?\n/)
.find((l) => l.startsWith("WebUI\\Username="));
const value = line?.slice("WebUI\\Username=".length).trim();
return value ? value : undefined;
} catch { return undefined; }
}
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
* absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined {
if (!file) return undefined;
try { return readFileSync(file, "utf8").trim(); }
catch { return undefined; }
}
export class QbittorrentClient {
readonly baseUrl: string;
/** The session cookie as `name=value`, sent back exactly as it was set. */
private session: string | null = null;
constructor(
baseUrl: string,
private readonly user: string,
private readonly password: string,
) {
this.baseUrl = baseUrl.replace(/\/$/, "");
}
/**
* Build from the module's resolved environment. URL and password are read from
* MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD; both must be present — an unconfigured
* qBittorrent throws rather than pretend to be reachable, so the tools/events simply do not load
* (the harness treats the throw as "exposes nothing"). The user is read from qBittorrent.conf,
* falling back to "admin", the image's default. The password cannot be read there — qBittorrent
* keeps only a PBKDF2 hash — so it is the own-secret the operator accepts.
*/
static fromEnv(env: NodeJS.ProcessEnv = process.env): QbittorrentClient {
const cfg = meshConfig(env.MESH_QBITTORRENT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_QBITTORRENT_URL;
const password = cfg.password ?? readSecret(env.MESH_QBITTORRENT_PASSWORD_FILE) ?? env.MESH_QBITTORRENT_PASSWORD;
if (!url || !password) {
throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD");
}
// The WebUI username: a setting or the environment if one says so, else whatever
// qBittorrent.conf holds (an adopted machine keeps its own), else the image's "admin".
const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? confUsername(env.MESH_QBITTORRENT_CONFIG_DIR) ?? "admin";
return new QbittorrentClient(url, user, password);
}
private async login(): Promise<void> {
const res = await fetch(`${this.baseUrl}/api/v2/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Referer: this.baseUrl },
body: new URLSearchParams({ username: this.user, password: this.password }),
});
if (res.status === 401) throw new Error("qBittorrent login rejected — check credentials");
if (!res.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`);
// 4.x/5.0/5.1 answer 200 "Ok." or 200 "Fails."; 5.2 answers 204 with no body, or 401.
const body = (await res.text()).trim();
if (res.status !== 204 && body !== "Ok.") {
throw new Error("qBittorrent login rejected — check credentials");
}
const match = res.headers.get("set-cookie")?.match(/((?:QBT_)?SID(?:_\d+)?)=([^;]+)/);
if (!match) throw new Error("qBittorrent login returned no session cookie");
this.session = `${match[1]}=${match[2]}`;
}
private async call(method: "GET" | "POST", path: string, form?: Record<string, string>): Promise<Response> {
if (!this.session) await this.login();
const doFetch = (): Promise<Response> => {
const headers: Record<string, string> = { Referer: this.baseUrl, Cookie: this.session ?? "" };
const init: RequestInit = { method, headers };
if (form) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
init.body = new URLSearchParams(form);
}
return fetch(`${this.baseUrl}/api/v2/${path}`, init);
};
let res = await doFetch();
if (res.status === 403 || res.status === 401) {
// The session expired — re-authenticate once and retry, rather than fail a routine call.
await this.login();
res = await doFetch();
}
return res;
}
private async getJson<T>(path: string): Promise<T> {
const res = await this.call("GET", path);
if (!res.ok) throw new Error(`qBittorrent GET ${path}: ${res.status} ${await res.text()}`);
return (await res.json()) as T;
}
async getVersion(): Promise<string> {
const res = await this.call("GET", "app/version");
if (!res.ok) throw new Error(`qBittorrent app/version: ${res.status}`);
return (await res.text()).trim();
}
async getTransferInfo(): Promise<QbTransferInfo> {
const d = await this.getJson<Record<string, any>>("transfer/info");
return {
dlSpeedBytesPerSec: Number(d.dl_info_speed ?? 0),
upSpeedBytesPerSec: Number(d.up_info_speed ?? 0),
dlData: Number(d.dl_info_data ?? 0),
upData: Number(d.up_info_data ?? 0),
connectionStatus: String(d.connection_status ?? "unknown"),
};
}
async getTorrents(filter?: string): Promise<QbTorrent[]> {
const path = filter ? `torrents/info?filter=${encodeURIComponent(filter)}` : "torrents/info";
const list = await this.getJson<Record<string, any>[]>(path);
return list.map((t) => ({
hash: String(t.hash),
name: String(t.name ?? "Unknown"),
state: String(t.state ?? "unknown"),
progress: Number(t.progress ?? 0),
sizeBytes: Number(t.size ?? 0),
dlSpeed: Number(t.dlspeed ?? 0),
upSpeed: Number(t.upspeed ?? 0),
category: String(t.category ?? ""),
ratio: Number(t.ratio ?? 0),
savePath: String(t.save_path ?? ""),
}));
}
/** Add a torrent by magnet or http(s) .torrent URL, optionally into a category / save path. */
async add(url: string, category = "", savepath = "", paused = false): Promise<void> {
const form: Record<string, string> = { urls: url, paused: paused ? "true" : "false" };
if (category) form.category = category;
if (savepath) form.savepath = savepath;
const res = await this.call("POST", "torrents/add", form);
const text = (await res.text()).trim();
if (!res.ok || text.toLowerCase() === "fails.") {
throw new Error(`qBittorrent refused the torrent: ${res.status} ${text}`);
}
}
// qBittorrent 5.x renamed pause/resume to stop/start; try the modern name and fall back to the
// legacy one on a 404, so the client works against both.
private async command(modern: string, legacy: string, hashes: string): Promise<void> {
let res = await this.call("POST", `torrents/${modern}`, { hashes });
if (res.status === 404) res = await this.call("POST", `torrents/${legacy}`, { hashes });
if (!res.ok) throw new Error(`qBittorrent torrents/${modern}: ${res.status} ${await res.text()}`);
}
/** Pause torrents — a pipe-separated hash list, or "all" (the default). */
async pause(hashes = "all"): Promise<void> {
await this.command("stop", "pause", hashes);
}
/** Resume torrents — a pipe-separated hash list, or "all" (the default). */
async resume(hashes = "all"): Promise<void> {
await this.command("start", "resume", hashes);
}
async delete(hashes: string, deleteFiles = false): Promise<void> {
const res = await this.call("POST", "torrents/delete", { hashes, deleteFiles: deleteFiles ? "true" : "false" });
if (!res.ok) throw new Error(`qBittorrent torrents/delete: ${res.status} ${await res.text()}`);
}
}