// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0039). Icecast is an // audio streaming server: sources push mountpoints in, listeners pull them out. Its `/status-json.xsl` // endpoint reports the live mountpoints and their listener counts — the one thing worth watching, and // the basis for both the status tool and the stream started/stopped events. import { readFileSync } from "node:fs"; export interface IcecastMount { /** The mountpoint path, e.g. "/stream.mp3", derived from the source's listen URL. */ mount: string; listeners: number; name?: string; description?: string; streamStart?: string; bitrate?: number; serverType?: string; } export interface IcecastStatus { mounts: IcecastMount[]; totalListeners: number; mountCount: number; } // The raw shape of one in status-json.xsl. `source` is absent with no mounts, a lone object // with one, and an array with several — normalised below. interface RawSource { listenurl?: string; listeners?: number; server_name?: string; server_description?: string; stream_start_iso8601?: string; stream_start?: string; bitrate?: number; server_type?: string; } /** 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 {}; } } /** * A password, token or API key is the vault's, never a setting (novox/hq issue 342): a value for one of * them in the settings file is ignored, and that is said once — by its key, never with its value. */ const saidIgnored = new Set(); function ignoreSecretSetting(cfg: Record, key: string): void { if (cfg[key] === undefined || saidIgnored.has(key)) return; saidIgnored.add(key); console.error(`the setting ${key} is ignored: it comes from the vault`); } /** A secret delivered as a file, trimmed; undefined when there is none. */ function readSecret(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim() || undefined; } catch { return undefined; } } export class IcecastClient { readonly baseUrl: string; private readonly authHeader?: string; constructor(url: string, adminUser?: string, adminPassword?: string) { this.baseUrl = url.replace(/\/$/, ""); // status-json.xsl is public on most instances; basic auth is used only where admin locked it down. if (adminUser && adminPassword) { this.authHeader = "Basic " + Buffer.from(`${adminUser}:${adminPassword}`).toString("base64"); } } static fromEnv(env: NodeJS.ProcessEnv = process.env): IcecastClient { const cfg = meshConfig(env.MESH_ICECAST_CONFIG_FILE); const url = cfg.url ?? (env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`); ignoreSecretSetting(cfg, "password"); const password = readSecret(env.MESH_ICECAST_ADMIN_PASSWORD_FILE) ?? env.MESH_ICECAST_ADMIN_PASSWORD; return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER ?? "admin", password); } async getStatus(): Promise { const headers: Record = { Accept: "application/json" }; if (this.authHeader) headers.Authorization = this.authHeader; const res = await fetch(`${this.baseUrl}/status-json.xsl`, { headers }); if (!res.ok) throw new Error(`Icecast status: ${res.status} ${await res.text()}`); const data = (await res.json()) as { icestats?: { source?: RawSource | RawSource[] } }; const raw = data.icestats?.source; const sources: RawSource[] = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; const mounts = sources.map((s) => ({ mount: this.mountFromUrl(s.listenurl), listeners: s.listeners ?? 0, name: s.server_name, description: s.server_description, streamStart: s.stream_start_iso8601 ?? s.stream_start, bitrate: s.bitrate, serverType: s.server_type, })); return { mounts, totalListeners: mounts.reduce((n, m) => n + m.listeners, 0), mountCount: mounts.length, }; } /** Icecast names the mount only inside the listen URL's path; pull it back out (fall back to raw). */ private mountFromUrl(listenurl?: string): string { if (!listenurl) return "unknown"; try { return new URL(listenurl).pathname; } catch { return listenurl; } } }