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>
90 lines
3.3 KiB
TypeScript
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;
|