feat(streamer): env config parsing + validation

This commit is contained in:
devilreef 2026-04-30 15:14:48 +06:00
parent cc1f278fb8
commit 23d98320df
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
2 changed files with 132 additions and 0 deletions

84
streamer/src/config.ts Normal file
View file

@ -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<string, string | undefined>, 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<string, string | undefined> = 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,
};
}

View file

@ -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);
});
});