searxng, icecast, photos: full nox modules (ADR 0044/0046)
searxng: a search tool — tools-only, a stateless search has nothing to observe. icecast: status tool, emits stream.started/.stopped by diffing live mountpoints. photos: status/albums/recent tools, emits item.added (immich-shaped API, coded defensively since the stub ships a placeholder image — flagged in the code). Typecheck; manifests parse.
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
// 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.
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 url = env.MESH_ICECAST_URL ?? `http://127.0.0.1:${env.ICECAST_PORT ?? "8000"}`;
|
||||
return new IcecastClient(url, env.MESH_ICECAST_ADMIN_USER, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// icecast's events. The tool runtime imports this once the broker is bound. It watches the streaming
|
||||
// server and announces when a mountpoint goes live or drops.
|
||||
//
|
||||
// Emits (novox/hq ADR 0046/0047):
|
||||
// module.icecast.stream.started / .stopped — a mountpoint appeared or disappeared
|
||||
//
|
||||
// A mountpoint exists only while a source is connected, so the set of mounts diffed over time is
|
||||
// exactly the set of live streams. Primed silently on the first look, so streams already running when
|
||||
// this starts are not announced as freshly begun. Polling is unhurried — a stream a few seconds late
|
||||
// is still the event, and hammering the status endpoint buys immediacy nobody asked for.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { IcecastClient, type IcecastMount } from "./client.js";
|
||||
|
||||
const icecast = IcecastClient.fromEnv();
|
||||
|
||||
const live = new Map<string, IcecastMount>();
|
||||
let primed = false;
|
||||
|
||||
async function pollMounts(): Promise<void> {
|
||||
const { mounts } = await icecast.getStatus();
|
||||
const now = new Map(mounts.map((m) => [m.mount, m]));
|
||||
if (primed) {
|
||||
for (const [mount, m] of now) {
|
||||
if (!live.has(mount)) {
|
||||
await emit("module.icecast.stream.started", {
|
||||
mount,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
bitrate: m.bitrate,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [mount, m] of live) {
|
||||
if (!now.has(mount)) {
|
||||
await emit("module.icecast.stream.stopped", { mount, name: m.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
live.clear();
|
||||
for (const [mount, m] of now) live.set(mount, m);
|
||||
primed = true;
|
||||
}
|
||||
|
||||
const run = (): void => void pollMounts().catch((err) => console.error(`[icecast] ${err}`));
|
||||
setInterval(run, 15_000);
|
||||
run();
|
||||
|
||||
console.log("[icecast] watching mountpoints for streams starting and stopping");
|
||||
@@ -4,10 +4,15 @@
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"emits": [
|
||||
"module.icecast.stream.started",
|
||||
"module.icecast.stream.stopped"
|
||||
],
|
||||
"own-secrets": {
|
||||
"source": "/var/lib/icecast-module/source.secret",
|
||||
"admin": "/var/lib/icecast-module/admin.secret",
|
||||
"relay": "/var/lib/icecast-module/relay.secret"
|
||||
"relay": "/var/lib/icecast-module/relay.secret",
|
||||
"broker": "/var/lib/icecast-module/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@novox/module-icecast",
|
||||
"version": "0.1.0",
|
||||
"description": "icecast — audio streaming server. Its API client, tools and events live here (novox/hq ADR 0044).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// icecast's tools (novox/hq ADR 0044), importing icecast's own client.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { IcecastClient } from "../client.js";
|
||||
|
||||
export function getIcecastTools(icecast: IcecastClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "icecast_status",
|
||||
description: "Icecast streaming status: live mountpoints, each with its listener count, plus the total.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
const status = await icecast.getStatus();
|
||||
return status;
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
registerModuleTools("icecast", (env) => {
|
||||
try {
|
||||
return getIcecastTools(IcecastClient.fromEnv(env));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["client.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user