// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0039). Ported from // hal's shared nzbget tools, but self-contained: a change to NZBGet's JSON-RPC now rebuilds only // nzbget and nothing else. Both this module's tools and its events entrypoint import it, and // nothing outside nzbget does. NZBGet speaks JSON-RPC at /jsonrpc, behind HTTP Basic auth. import { readFileSync } from "node:fs"; export interface NzbgetStatus { /** Bytes/sec — NZBGet reports it split across two 32-bit halves, rejoined here. */ speedBytesPerSec: number; remainingMB: number; downloadedTodayMB: number; downloadedMonthMB: number; freeDiskMB: number; paused: boolean; postJobs: number; uptimeSec: number; } export interface NzbgetQueueItem { /** The NZBID — stable while the item is queued, so events can diff on it. */ id: number; name: string; status: string; category: string; sizeMB: number; remainingMB: number; percent: number; } export interface NzbgetHistoryItem { /** The NZBID — the same id the item carried in the queue. */ id: number; name: string; /** NZBGet's own status string, e.g. "SUCCESS/ALL", "FAILURE/PAR", "DELETED/MANUAL". */ status: string; category: string; sizeMB: number; /** A genuine completion (status starts "SUCCESS") vs a failed or deleted entry — the difference * between something to announce as done and something that merely left the queue. */ success: boolean; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } catch { return {}; } } /** 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 NzbgetClient { readonly rpcUrl: string; private readonly auth: string; constructor(url: string, user: string, password: string) { this.rpcUrl = `${url.replace(/\/$/, "")}/jsonrpc`; this.auth = Buffer.from(`${user}:${password}`).toString("base64"); } /** * Build from the module's resolved environment. URL and password are read from MESH_NZBGET_URL * and MESH_NZBGET_PASSWORD; both must be present — an unconfigured NZBGet throws rather than * pretend to be reachable, so the tools/events simply do not load (the harness treats the throw * as "exposes nothing"). The control username defaults to "nzbget", NZBGet's own default. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): NzbgetClient { const cfg = meshConfig(env.MESH_NZBGET_CONFIG_FILE); const url = cfg.url ?? env.MESH_NZBGET_URL; const password = cfg.password ?? readSecret(env.MESH_NZBGET_PASSWORD_FILE) ?? env.MESH_NZBGET_PASSWORD; if (!url || !password) { throw new Error("NZBGet not configured — set MESH_NZBGET_URL and MESH_NZBGET_PASSWORD"); } const user = cfg.user ?? env.MESH_NZBGET_USER ?? "nzbget"; return new NzbgetClient(url, user, password); } private async rpc(method: string, params: unknown[] = []): Promise { const res = await fetch(this.rpcUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Basic ${this.auth}` }, body: JSON.stringify({ method, params, id: 1 }), }); if (!res.ok) throw new Error(`NZBGet API ${method}: ${res.status} ${await res.text()}`); const data = (await res.json()) as { result?: T; error?: unknown }; if (data.error) throw new Error(`NZBGet RPC ${method}: ${JSON.stringify(data.error)}`); return data.result as T; } async getVersion(): Promise { return this.rpc("version"); } async getStatus(): Promise { const s = await this.rpc>("status"); const lo = Number(s.DownloadRateLo ?? 0); const hi = Number(s.DownloadRateHi ?? 0); return { speedBytesPerSec: lo + hi * 4294967296, remainingMB: Number(s.RemainingSizeMB ?? 0), downloadedTodayMB: Number(s.DaySizeMB ?? 0), downloadedMonthMB: Number(s.MonthSizeMB ?? 0), freeDiskMB: Number(s.FreeDiskSpaceMB ?? 0), paused: Boolean(s.DownloadPaused), postJobs: Number(s.PostJobCount ?? 0), uptimeSec: Number(s.UpTimeSec ?? 0), }; } async getQueue(): Promise { const groups = await this.rpc[]>("listgroups", [0]); return groups.map((g) => { const size = Number(g.FileSizeMB ?? 0); const remaining = Number(g.RemainingSizeMB ?? 0); return { id: Number(g.NZBID), name: String(g.NZBName ?? "Unknown"), status: String(g.Status ?? "unknown"), category: String(g.Category ?? ""), sizeMB: size, remainingMB: remaining, percent: size > 0 ? Math.round(((size - remaining) / size) * 100) : 0, }; }); } async getHistory(limit = 20): Promise { const history = await this.rpc[]>("history", [false]); return history.slice(0, limit).map((h) => { const status = String(h.Status ?? ""); return { id: Number(h.NZBID), name: String(h.Name ?? "Unknown"), status, category: String(h.Category ?? ""), sizeMB: Number(h.FileSizeMB ?? 0), success: status.startsWith("SUCCESS"), }; }); } /** Queue an NZB by URL. Returns the new NZBID; a non-positive id means NZBGet refused it. */ async add(url: string, category = "", priority = 0, paused = false): Promise { const id = await this.rpc("append", [ "", url, category, priority, false, paused, "", 0, "SCORE", false, [], ]); if (!id || id <= 0) throw new Error("NZBGet refused the NZB (append returned 0)"); return id; } async pauseAll(): Promise { await this.rpc("pausedownload"); } async resumeAll(): Promise { await this.rpc("resumedownload"); } async pauseItem(id: number): Promise { await this.rpc("editqueue", ["GroupPause", "", [id]]); } async resumeItem(id: number): Promise { await this.rpc("editqueue", ["GroupResume", "", [id]]); } /** Delete an item from the queue or from history. */ async delete(id: number, from: "queue" | "history" = "queue"): Promise { await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]); } }