feat(streamer): env config parsing + validation
This commit is contained in:
parent
cc1f278fb8
commit
23d98320df
2 changed files with 132 additions and 0 deletions
84
streamer/src/config.ts
Normal file
84
streamer/src/config.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
48
streamer/tests/config.test.ts
Normal file
48
streamer/tests/config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue