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
171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
// The NZBGet API client — nzbget's own code, living in the module (novox/hq ADR 0044). 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 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 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 ?? 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<T>(method: string, params: unknown[] = []): Promise<T> {
|
|
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<string> {
|
|
return this.rpc<string>("version");
|
|
}
|
|
|
|
async getStatus(): Promise<NzbgetStatus> {
|
|
const s = await this.rpc<Record<string, number | boolean>>("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<NzbgetQueueItem[]> {
|
|
const groups = await this.rpc<Record<string, any>[]>("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<NzbgetHistoryItem[]> {
|
|
const history = await this.rpc<Record<string, any>[]>("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<number> {
|
|
const id = await this.rpc<number>("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<void> {
|
|
await this.rpc("pausedownload");
|
|
}
|
|
|
|
async resumeAll(): Promise<void> {
|
|
await this.rpc("resumedownload");
|
|
}
|
|
|
|
async pauseItem(id: number): Promise<void> {
|
|
await this.rpc("editqueue", ["GroupPause", "", [id]]);
|
|
}
|
|
|
|
async resumeItem(id: number): Promise<void> {
|
|
await this.rpc("editqueue", ["GroupResume", "", [id]]);
|
|
}
|
|
|
|
/** Delete an item from the queue or from history. */
|
|
async delete(id: number, from: "queue" | "history" = "queue"): Promise<void> {
|
|
await this.rpc("editqueue", [from === "history" ? "HistoryDelete" : "GroupDelete", "", [id]]);
|
|
}
|
|
}
|