From 23d98320df74e296bbb5c9ceb5468a7e16bbc513 Mon Sep 17 00:00:00 2001 From: devilreef Date: Thu, 30 Apr 2026 15:14:48 +0600 Subject: [PATCH] feat(streamer): env config parsing + validation --- streamer/src/config.ts | 84 +++++++++++++++++++++++++++++++++++ streamer/tests/config.test.ts | 48 ++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 streamer/src/config.ts create mode 100644 streamer/tests/config.test.ts diff --git a/streamer/src/config.ts b/streamer/src/config.ts new file mode 100644 index 0000000..ca0a363 --- /dev/null +++ b/streamer/src/config.ts @@ -0,0 +1,84 @@ +export class ConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "ConfigError"; + } +} + +export interface Config { + station: string; + rtmpUrl: string; + pcmHost: string; + pcmPort: number; + nowPlayingDir: string; + libraryDir: string; + style: "denpa" | "modern"; + tz: string; + videoBitrate: string; + audioBitrate: string; + framerate: number; + resolution: { width: number; height: number }; + stationDisplayName?: string; + stationTagline?: string; + stationTuneInUrl: string; + healthPort: number; + logLevel: "debug" | "info" | "warn" | "error"; + icecastStatusUrl?: string; +} + +function require_(env: Record, key: string): string { + const v = env[key]; + if (!v) throw new ConfigError(`missing required env ${key}`); + return v; +} + +function parsePcm(url: string): { host: string; port: number } { + const m = url.match(/^tcp:\/\/([^:/]+):(\d+)$/); + if (!m) throw new ConfigError(`invalid LIQUIDSOAP_PCM (expected tcp://host:port): ${url}`); + return { host: m[1]!, port: Number(m[2]) }; +} + +function parseRes(s: string): { width: number; height: number } { + const m = s.match(/^(\d+)x(\d+)$/); + if (!m) throw new ConfigError(`invalid RESOLUTION: ${s}`); + return { width: Number(m[1]), height: Number(m[2]) }; +} + +export function loadConfig(env: Record = process.env): Config { + const style = require_(env, "STYLE"); + if (style !== "denpa" && style !== "modern") { + throw new ConfigError(`STYLE must be 'denpa' or 'modern', got ${style}`); + } + + const rtmpUrl = require_(env, "RTMP_URL"); + if (!/^rtmps?:\/\//.test(rtmpUrl)) { + throw new ConfigError(`RTMP_URL must start with rtmp:// or rtmps://`); + } + + const { host: pcmHost, port: pcmPort } = parsePcm(require_(env, "LIQUIDSOAP_PCM")); + const logLevel = (env.LOG_LEVEL ?? "info") as Config["logLevel"]; + if (!["debug", "info", "warn", "error"].includes(logLevel)) { + throw new ConfigError(`invalid LOG_LEVEL: ${logLevel}`); + } + + return { + station: require_(env, "STATION"), + rtmpUrl, + pcmHost, + pcmPort, + nowPlayingDir: env.NOW_PLAYING_DIR ?? "/now-playing", + libraryDir: env.LIBRARY_DIR ?? "/library", + style, + tz: env.TZ ?? "UTC", + videoBitrate: env.VIDEO_BITRATE ?? "4500k", + audioBitrate: env.AUDIO_BITRATE ?? "160k", + framerate: Number(env.FRAMERATE ?? 30), + resolution: parseRes(env.RESOLUTION ?? "1920x1080"), + stationDisplayName: env.STATION_DISPLAY_NAME || undefined, + stationTagline: env.STATION_TAGLINE || undefined, + stationTuneInUrl: env.STATION_TUNE_IN_URL ?? "denpa.femboy.page", + healthPort: Number(env.HEALTH_PORT ?? 12010), + logLevel, + icecastStatusUrl: env.ICECAST_STATUS_URL || undefined, + }; +} diff --git a/streamer/tests/config.test.ts b/streamer/tests/config.test.ts new file mode 100644 index 0000000..3f00584 --- /dev/null +++ b/streamer/tests/config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { loadConfig, ConfigError } from "../src/config.ts"; + +const baseEnv = { + STATION: "minecraft", + RTMP_URL: "rtmp://example/live/key", + LIQUIDSOAP_PCM: "tcp://liquidsoap:9100", + NOW_PLAYING_DIR: "/now-playing", + LIBRARY_DIR: "/library", + STYLE: "denpa", + TZ: "Asia/Tokyo", + VIDEO_BITRATE: "4500k", + AUDIO_BITRATE: "160k", + FRAMERATE: "30", + RESOLUTION: "1920x1080", + STATION_TUNE_IN_URL: "denpa.femboy.page", + HEALTH_PORT: "12010", + LOG_LEVEL: "info", +}; + +describe("loadConfig", () => { + it("parses required fields", () => { + const cfg = loadConfig(baseEnv); + expect(cfg.station).toBe("minecraft"); + expect(cfg.rtmpUrl).toBe("rtmp://example/live/key"); + expect(cfg.style).toBe("denpa"); + expect(cfg.framerate).toBe(30); + }); + + it("rejects invalid style", () => { + expect(() => loadConfig({ ...baseEnv, STYLE: "weird" })).toThrow(ConfigError); + }); + + it("rejects malformed rtmp url", () => { + expect(() => loadConfig({ ...baseEnv, RTMP_URL: "not-a-url" })).toThrow(ConfigError); + }); + + it("rejects missing required field", () => { + const { STATION: _omit, ...rest } = baseEnv; + expect(() => loadConfig(rest)).toThrow(ConfigError); + }); + + it("parses pcm tcp url", () => { + const cfg = loadConfig(baseEnv); + expect(cfg.pcmHost).toBe("liquidsoap"); + expect(cfg.pcmPort).toBe(9100); + }); +});