Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
181 lines
7.3 KiB
TypeScript
181 lines
7.3 KiB
TypeScript
// The qBittorrent API client — qbittorrent's own code, living in the module (novox/hq ADR 0044).
|
|
// 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 (SID) obtained by POSTing credentials, and guards
|
|
// against CSRF by checking the Referer header. Node's fetch keeps no cookie jar, so the SID 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 0051): { 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 {}; }
|
|
}
|
|
|
|
export class QbittorrentClient {
|
|
readonly baseUrl: string;
|
|
private sid: 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 defaults to "admin".
|
|
*/
|
|
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 ?? env.MESH_QBITTORRENT_PASSWORD;
|
|
if (!url || !password) {
|
|
throw new Error("qBittorrent not configured — set MESH_QBITTORRENT_URL and MESH_QBITTORRENT_PASSWORD");
|
|
}
|
|
const user = cfg.user ?? env.MESH_QBITTORRENT_USER ?? "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.ok) throw new Error(`qBittorrent login: ${res.status} ${await res.text()}`);
|
|
if ((await res.text()).trim() !== "Ok.") {
|
|
throw new Error("qBittorrent login rejected — check credentials");
|
|
}
|
|
const match = res.headers.get("set-cookie")?.match(/SID=([^;]+)/);
|
|
if (!match) throw new Error("qBittorrent login returned no SID cookie");
|
|
this.sid = match[1];
|
|
}
|
|
|
|
private async call(method: "GET" | "POST", path: string, form?: Record<string, string>): Promise<Response> {
|
|
if (!this.sid) await this.login();
|
|
const doFetch = (): Promise<Response> => {
|
|
const headers: Record<string, string> = { Referer: this.baseUrl, Cookie: `SID=${this.sid}` };
|
|
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) {
|
|
// The SID 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()}`);
|
|
}
|
|
}
|