Three problems visible at once on a running instance: the picture stuttered and drifted behind the sound, it was letterboxed oddly, and it appeared as a separate tile instead of coming from the bot. - YouTube handed us AV1 (format 398). Software-decoding AV1 at 720p does not sustain real time on a small server, which explains both the stutter and the drift; H.264 is now requested first, VP9 second. - The frame was padded into a fixed box, so a clip whose proportions differed got black bars baked in and then more from the client. Size is now a bounding box and the frame keeps the clip's own proportions. - Video was published as a screen share, which every client renders as its own tile. VIDEO_SOURCE=camera (the new default) puts it inside the bot's tile; "screen" keeps the old behaviour. VIDEO_SYNC_OFFSET_MS is there for the residual drift, since audio and video travel as two separately published tracks. Measured loudnorm first to rule it out as the cause of the desync: it adds 0 ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
import { readFileSync, existsSync } from "node:fs";
|
||
import { z } from "zod";
|
||
|
||
// Minimal .env loader so we don't need an extra dependency.
|
||
function loadDotEnv(path = ".env"): void {
|
||
if (!existsSync(path)) return;
|
||
for (const rawLine of readFileSync(path, "utf8").split(/\r?\n/)) {
|
||
const line = rawLine.trim();
|
||
if (!line || line.startsWith("#")) continue;
|
||
const eq = line.indexOf("=");
|
||
if (eq === -1) continue;
|
||
const key = line.slice(0, eq).trim();
|
||
let value = line.slice(eq + 1).trim();
|
||
if (
|
||
(value.startsWith('"') && value.endsWith('"')) ||
|
||
(value.startsWith("'") && value.endsWith("'"))
|
||
) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
if (process.env[key] === undefined) process.env[key] = value;
|
||
}
|
||
}
|
||
|
||
loadDotEnv();
|
||
|
||
const schema = z.object({
|
||
STOAT_API_URL: z.string().url(),
|
||
STOAT_BOT_TOKEN: z.string().min(1),
|
||
COMMAND_PREFIX: z.string().min(1).default("!"),
|
||
/** LiveKit node name from Revolt.toml ([hosts.livekit]); self-hosted default is "worldwide". */
|
||
VOICE_NODE: z.string().min(1).default("worldwide"),
|
||
/** Remove the invoking message after a command is recognised. Needs ManageMessages. */
|
||
DELETE_COMMAND_MESSAGES: z
|
||
.enum(["true", "false"])
|
||
.default("true")
|
||
.transform((value) => value === "true"),
|
||
|
||
PORT: z.coerce.number().int().positive().default(3005),
|
||
HOST: z.string().default("0.0.0.0"),
|
||
PUBLIC_URL: z.string().url(),
|
||
JWT_SECRET: z.string().min(16),
|
||
SESSION_TTL_HOURS: z.coerce.number().positive().default(168),
|
||
|
||
YTDLP_PATH: z.string().default("yt-dlp"),
|
||
YTDLP_COOKIES: z.string().optional(),
|
||
/**
|
||
* Where the proxy applies: "all" for every request, "search" to keep the audio
|
||
* stream direct — useful when a proxy exit IP triggers YouTube's bot checks.
|
||
*/
|
||
YTDLP_PROXY_SCOPE: z.enum(["all", "search"]).default("all"),
|
||
/** Proxy for every yt-dlp request: http://, https://, socks5:// or socks5h://. */
|
||
YTDLP_PROXY: z
|
||
.string()
|
||
.optional()
|
||
.refine(
|
||
(value) => !value || /^(https?|socks[45]h?):\/\//i.test(value),
|
||
"должен начинаться с http://, https://, socks5:// или socks5h://",
|
||
),
|
||
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
|
||
YTDLP_EXTRACTOR_ARGS: z.string().optional(),
|
||
/** JS runtime yt-dlp uses for YouTube's player challenges; "" disables the flag. */
|
||
YTDLP_JS_RUNTIME: z.string().default("node"),
|
||
LOCAL_MEDIA_DIR: z.string().optional(),
|
||
|
||
/** Publish the clip as a screen share alongside the audio (YouTube only). */
|
||
VIDEO_ENABLED: z
|
||
.enum(["true", "false"])
|
||
.default("false")
|
||
.transform((value) => value === "true"),
|
||
// YouTube only serves 360p as a single progressive file, and only such files
|
||
// can be streamed, so that is the realistic default.
|
||
// Treated as a bounding box: the frame keeps the clip's own proportions.
|
||
VIDEO_WIDTH: z.coerce.number().int().positive().default(640),
|
||
VIDEO_HEIGHT: z.coerce.number().int().positive().default(360),
|
||
VIDEO_FPS: z.coerce.number().int().min(1).max(60).default(24),
|
||
/** "camera" shows up inside the bot's tile, "screen" as a separate one. */
|
||
VIDEO_SOURCE: z.enum(["camera", "screen"]).default("camera"),
|
||
/** Nudge the picture against the sound, in milliseconds; positive delays video. */
|
||
VIDEO_SYNC_OFFSET_MS: z.coerce.number().int().default(0),
|
||
|
||
DEFAULT_VOLUME: z.coerce.number().min(0).max(200).default(60),
|
||
MAX_QUEUE_SIZE: z.coerce.number().int().positive().default(500),
|
||
/** Upper bound on how many tracks one pasted playlist may add. */
|
||
MAX_PLAYLIST_TRACKS: z.coerce.number().int().positive().default(100),
|
||
SEARCH_RESULT_LIMIT: z.coerce.number().int().positive().max(25).default(10),
|
||
/** Seconds to wait after the last human leaves the voice channel (0 — never leave). */
|
||
EMPTY_TIMEOUT_SECONDS: z.coerce.number().int().min(0).default(120),
|
||
DJ_ROLE_NAME: z.string().default("DJ"),
|
||
/** Only let people summon the bot into the voice channel they are sitting in. */
|
||
REQUIRE_LISTENER: z
|
||
.enum(["true", "false"])
|
||
.default("true")
|
||
.transform((value) => value === "true"),
|
||
REQUIRE_DJ_ROLE: z
|
||
.enum(["true", "false"])
|
||
.default("false")
|
||
.transform((value) => value === "true"),
|
||
|
||
LOG_LEVEL: z.string().default("info"),
|
||
NODE_ENV: z.string().default("development"),
|
||
});
|
||
|
||
const parsed = schema.safeParse(process.env);
|
||
|
||
if (!parsed.success) {
|
||
const issues = parsed.error.issues
|
||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||
.join("\n");
|
||
console.error(`Invalid configuration, check your .env file:\n${issues}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
export const config = {
|
||
...parsed.data,
|
||
STOAT_API_URL: parsed.data.STOAT_API_URL.replace(/\/+$/, ""),
|
||
PUBLIC_URL: parsed.data.PUBLIC_URL.replace(/\/+$/, ""),
|
||
isProduction: parsed.data.NODE_ENV === "production",
|
||
};
|
||
|
||
export type Config = typeof config;
|