diff --git a/modules/icecast/client.ts b/modules/icecast/client.ts new file mode 100644 index 0000000..d074b55 --- /dev/null +++ b/modules/icecast/client.ts @@ -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 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 { + 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; + } + } +} diff --git a/modules/icecast/index.ts b/modules/icecast/index.ts new file mode 100644 index 0000000..38c2080 --- /dev/null +++ b/modules/icecast/index.ts @@ -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(); +let primed = false; + +async function pollMounts(): Promise { + 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"); diff --git a/modules/icecast/module.json b/modules/icecast/module.json index 77d208e..017a6e8 100644 --- a/modules/icecast/module.json +++ b/modules/icecast/module.json @@ -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": [ { diff --git a/modules/icecast/package.json b/modules/icecast/package.json new file mode 100644 index 0000000..fa2b925 --- /dev/null +++ b/modules/icecast/package.json @@ -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" + } +} diff --git a/modules/icecast/tools/index.ts b/modules/icecast/tools/index.ts new file mode 100644 index 0000000..86b5828 --- /dev/null +++ b/modules/icecast/tools/index.ts @@ -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 []; + } +}); diff --git a/modules/icecast/tsconfig.json b/modules/icecast/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/icecast/tsconfig.json @@ -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"] +} diff --git a/modules/photos/client.ts b/modules/photos/client.ts new file mode 100644 index 0000000..d0a8b66 --- /dev/null +++ b/modules/photos/client.ts @@ -0,0 +1,100 @@ +// The photo app's API client — photos' own code, living in the module (novox/hq ADR 0044). The +// module packages a self-hosted photo library (immich-shaped: a REST API under `/api`, authenticated +// by an API key sent as the `x-api-key` header). The client speaks only what the tools and the +// item-added event need: server version and statistics, albums, and recent assets. + +export interface PhotosServerInfo { + version: string; + photos?: number; + videos?: number; + usageBytes?: number; +} + +export interface PhotosAlbum { + id: string; + name: string; + assetCount: number; + shared: boolean; +} + +export interface PhotosAsset { + id: string; + fileName?: string; + type?: string; + createdAt?: string; +} + +export class PhotosClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. Unlike an open service, a photo library holds private data: + * the API key is required, and without it the module contributes nothing rather than reaching an + * unauthenticated endpoint. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): PhotosClient { + const url = env.MESH_PHOTOS_URL ?? `http://127.0.0.1:${env.PHOTOS_PORT ?? "2283"}`; + const key = env.MESH_PHOTOS_API_KEY; + if (!key) throw new Error("no photos API key — set MESH_PHOTOS_API_KEY"); + return new PhotosClient(url, key); + } + + private async request(path: string, init: RequestInit = {}): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + ...init, + headers: { + Accept: "application/json", + "x-api-key": this.apiKey, + ...(init.body ? { "Content-Type": "application/json" } : {}), + ...(init.headers ?? {}), + }, + }); + if (!res.ok) throw new Error(`photos API ${path}: ${res.status} ${await res.text()}`); + return (await res.json()) as T; + } + + async getServerInfo(): Promise { + const version = await this.request<{ major: number; minor: number; patch: number }>("/api/server/version"); + const info: PhotosServerInfo = { version: `${version.major}.${version.minor}.${version.patch}` }; + // Statistics needs an admin key; a scoped key still gives version, so treat stats as best-effort. + try { + const stats = await this.request<{ photos: number; videos: number; usage: number }>("/api/server/statistics"); + info.photos = stats.photos; + info.videos = stats.videos; + info.usageBytes = stats.usage; + } catch { + // leave the counts unset + } + return info; + } + + async getAlbums(): Promise { + const albums = await this.request< + { id: string; albumName: string; assetCount: number; shared: boolean }[] + >("/api/albums"); + return albums.map((a) => ({ id: a.id, name: a.albumName, assetCount: a.assetCount, shared: a.shared })); + } + + /** Recent assets, newest first — via the metadata search, which is how this API returns a bounded, + * ordered slice of the library. The poll that emits item.added builds on this. */ + async getRecentAssets(limit = 20): Promise { + const data = await this.request<{ + assets?: { items?: { id: string; originalFileName?: string; type?: string; fileCreatedAt?: string }[] }; + }>("/api/search/metadata", { + method: "POST", + body: JSON.stringify({ size: limit, order: "desc" }), + }); + const items = data.assets?.items ?? []; + return items.map((a) => ({ + id: a.id, + fileName: a.originalFileName, + type: a.type, + createdAt: a.fileCreatedAt, + })); + } +} diff --git a/modules/photos/index.ts b/modules/photos/index.ts new file mode 100644 index 0000000..45cf390 --- /dev/null +++ b/modules/photos/index.ts @@ -0,0 +1,40 @@ +// photos' events. The tool runtime imports this once the broker is bound. It watches the library and +// announces newly added assets. +// +// Emits (novox/hq ADR 0046/0047): +// module.photos.item.added — a new asset appeared in the library +// +// New assets are found by diffing the recent-assets slice by asset id. Primed silently on the first +// look, so a restart does not re-announce the whole recent list as freshly added. + +import { emit } from "@novox/mesh-sdk/events"; +import { PhotosClient } from "./client.js"; + +const photos = PhotosClient.fromEnv(); + +const seen = new Set(); +let primed = false; + +async function pollRecent(): Promise { + const items = await photos.getRecentAssets(50); + for (const asset of items) { + if (!seen.has(asset.id)) { + if (primed) { + await emit("module.photos.item.added", { + id: asset.id, + fileName: asset.fileName, + kind: asset.type, + createdAt: asset.createdAt, + }); + } + seen.add(asset.id); + } + } + primed = true; +} + +const run = (): void => void pollRecent().catch((err) => console.error(`[photos] ${err}`)); +setInterval(run, 60_000); +run(); + +console.log("[photos] watching for newly added assets"); diff --git a/modules/photos/module.json b/modules/photos/module.json index e3bac87..72e6cf6 100644 --- a/modules/photos/module.json +++ b/modules/photos/module.json @@ -15,6 +15,12 @@ "secrets": { "s3-bucket": "/etc/photos/store.secret" }, + "emits": [ + "module.photos.item.added" + ], + "own-secrets": { + "broker": "/etc/photos/broker" + }, "resources": [ { "id": "config", diff --git a/modules/photos/package.json b/modules/photos/package.json new file mode 100644 index 0000000..0bb719b --- /dev/null +++ b/modules/photos/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-photos", + "version": "0.1.0", + "description": "photos — self-hosted photo library. 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" + } +} diff --git a/modules/photos/tools/index.ts b/modules/photos/tools/index.ts new file mode 100644 index 0000000..b8c9619 --- /dev/null +++ b/modules/photos/tools/index.ts @@ -0,0 +1,43 @@ +// photos' tools (novox/hq ADR 0044), importing photos' own client. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { PhotosClient } from "../client.js"; + +export function getPhotosTools(photos: PhotosClient): ToolDefinition[] { + return [ + { + name: "photos_status", + description: "Photo library status: server version and, where the key allows, photo/video counts and storage used.", + input: {}, + run: async () => { + const [server, albums] = await Promise.all([photos.getServerInfo(), photos.getAlbums()]); + return { server, albumCount: albums.length }; + }, + }, + { + name: "photos_albums", + description: "List the albums in the photo library, each with its asset count and whether it is shared.", + input: {}, + run: async () => { + const albums = await photos.getAlbums(); + return { count: albums.length, albums }; + }, + }, + { + name: "photos_recent", + description: "Most recently added assets in the photo library, newest first.", + input: { limit: { type: "number", description: "how many assets (default 20)" } }, + run: async (args) => ({ items: await photos.getRecentAssets(args.limit ? Number(args.limit) : 20) }), + }, + ]; +} + +// The tools exist only when an API key is configured; without one, photos contributes none rather +// than reaching an unauthenticated endpoint or failing the whole runtime. +registerModuleTools("photos", (env) => { + try { + return getPhotosTools(PhotosClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/photos/tsconfig.json b/modules/photos/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/photos/tsconfig.json @@ -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"] +} diff --git a/modules/searxng/client.ts b/modules/searxng/client.ts new file mode 100644 index 0000000..bfeb7a9 --- /dev/null +++ b/modules/searxng/client.ts @@ -0,0 +1,76 @@ +// SearXNG's API client — searxng's own code, living in the module (novox/hq ADR 0044). SearXNG is a +// privacy-respecting metasearch engine: it forwards a query to many upstream engines and returns the +// merged results. Its JSON API (`/search?q=...&format=json`) is what makes a `searxng_search` tool +// useful; the client speaks only that. No credential — the instance is reached inside the mesh. + +export interface SearxResult { + title: string; + url: string; + content?: string; + engine?: string; + category?: string; + score?: number; +} + +export interface SearxSearch { + query: string; + numberOfResults: number; + results: SearxResult[]; + suggestions: string[]; + answers: string[]; +} + +export interface SearxOptions { + categories?: string; + language?: string; + pageno?: number; +} + +export class SearxngClient { + readonly baseUrl: string; + + constructor(url: string) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** Build from the module's environment. No key: SearXNG's search API is open on the mesh, so a URL + * is all it takes — defaulting to the container's own listen port. */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): SearxngClient { + const url = env.MESH_SEARXNG_URL ?? `http://127.0.0.1:${env.SEARXNG_PORT ?? "8080"}`; + return new SearxngClient(url); + } + + async search(query: string, opts: SearxOptions = {}): Promise { + const params = new URLSearchParams({ q: query, format: "json" }); + if (opts.categories) params.set("categories", opts.categories); + if (opts.language) params.set("language", opts.language); + if (opts.pageno) params.set("pageno", String(opts.pageno)); + + const res = await fetch(`${this.baseUrl}/search?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!res.ok) throw new Error(`SearXNG search: ${res.status} ${await res.text()}`); + const data = (await res.json()) as { + results?: SearxResult[]; + suggestions?: string[]; + answers?: string[]; + number_of_results?: number; + }; + const results = data.results ?? []; + return { + query, + // SearXNG's own count is often 0 even with results; fall back to what we actually got. + numberOfResults: data.number_of_results || results.length, + results: results.map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + engine: r.engine, + category: r.category, + score: r.score, + })), + suggestions: data.suggestions ?? [], + answers: data.answers ?? [], + }; + } +} diff --git a/modules/searxng/package.json b/modules/searxng/package.json new file mode 100644 index 0000000..45c1fbd --- /dev/null +++ b/modules/searxng/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-searxng", + "version": "0.1.0", + "description": "searxng — privacy-respecting metasearch. Its API client and tools 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" + } +} diff --git a/modules/searxng/tools/index.ts b/modules/searxng/tools/index.ts new file mode 100644 index 0000000..f30c8c2 --- /dev/null +++ b/modules/searxng/tools/index.ts @@ -0,0 +1,37 @@ +// searxng's tools (novox/hq ADR 0044), importing searxng's own client. A stateless metasearch: one +// query in, merged results out — genuinely useful, and with nothing to observe over time it stays +// tools-only (no events, no broker). + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { SearxngClient } from "../client.js"; + +export function getSearxngTools(searxng: SearxngClient): ToolDefinition[] { + return [ + { + name: "searxng_search", + description: "Search the web through SearXNG's privacy-respecting metasearch — merged results from many engines.", + input: { + query: { type: "string", description: "the search query" }, + categories: { type: "string", description: "comma-separated categories, e.g. 'general', 'news', 'images'" }, + language: { type: "string", description: "language code, e.g. 'en' or 'en-US'" }, + }, + run: async (args) => { + const found = await searxng.search(String(args.query), { + categories: args.categories ? String(args.categories) : undefined, + language: args.language ? String(args.language) : undefined, + }); + return found; + }, + }, + ]; +} + +// A URL always resolves (it defaults), so this normally contributes its tool; the try/catch keeps a +// misconfiguration from taking the whole runtime down. +registerModuleTools("searxng", (env) => { + try { + return getSearxngTools(SearxngClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/searxng/tsconfig.json b/modules/searxng/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/searxng/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +}