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