Files
stoat-mbot/src/config.ts
T
Leonid PershinandClaude Opus 5 0b17b880d6 Fix video codec, aspect and placement
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>
2026-09-09 02:29:29 +03:00

121 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;