Files
mesh-catalog/modules/icecast/client.ts
T
jschoubben 9e156a5b9e Roll out the tool runtime to the remaining tools+events modules (ADR 0052/0051)
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
2026-09-04 23:08:40 +02:00

98 lines
3.6 KiB
TypeScript

// Icecast's API client — icecast's own code, living in the module (novox/hq ADR 0044). 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 <source> 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 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 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"}`);
return new IcecastClient(url, cfg.user ?? env.MESH_ICECAST_ADMIN_USER, cfg.password ?? env.MESH_ICECAST_ADMIN_PASSWORD);
}
async getStatus(): Promise<IcecastStatus> {
const headers: Record<string, string> = { 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;
}
}
}