Files
mesh-catalog/modules/icecast/client.ts
T

98 lines
3.6 KiB
TypeScript

// 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 <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 0046): { 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;
}
}
}