Files
stoat-mbot/src/config.ts
T
Leonid PershinandClaude Opus 5 6d65134f8e Add only the track when a link carries both a video and a list
A link copied from an open mix (watch?v=…&list=RD…) expanded into the
whole radio station — 500 entries from one paste. Any URL with a `v=`
parameter now resolves to that single track, and only a /playlist?list=…
URL expands. The rule is deliberately blunt rather than keyed on
start_radio, so pasting a link behaves the same way every time.

Playlist expansion is also capped separately from the queue limit
(MAX_PLAYLIST_TRACKS, 100 by default) and the chat says when a playlist
hit that ceiling.

Verified against the real yt-dlp: both mix links resolve to one track, a
plain video link to one, and a playlist URL to its 13 entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:20:35 +03:00

90 lines
3.3 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(),
/** Extra `--extractor-args` values, separated by ";" — e.g. youtube:player_client=default,web_safari */
YTDLP_EXTRACTOR_ARGS: z.string().optional(),
LOCAL_MEDIA_DIR: z.string().optional(),
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;