Add music bot for self-hosted Stoat with web control panel

Plays audio into Stoat voice channels over LiveKit and exposes the same
player through both chat commands and a browser panel, so the two never
drift apart: everything routes through a single MusicManager.

- core: per-server GuildPlayer (queue, loop, shuffle, seek, volume,
  idle auto-leave) driving revoice.js/@livekit/rtc-node and ffmpeg
- sources: yt-dlp for YouTube/SoundCloud, direct media URLs and internet
  radio, optional local library with path-traversal guards
- bot: 18 chat commands with aliases, plus !panel one-time login links
- api: Fastify REST + WebSocket, sessions authenticated against the
  instance's own /auth/session/login (TOTP supported), permissions
  re-checked against Stoat membership and roles on every request
- web: React panel with search, queue editing, seek and volume
- deploy: Dockerfile, compose.override.yml and Caddyfile snippets for
  dropping the service into an existing /opt/stoat stack

Verified with npm run typecheck, both builds, and scripts/smoke-api.mjs
(9 API checks). Voice playback itself needs a live instance to test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-09-08 23:12:43 +03:00
co-authored by Claude Opus 5
parent d9d0e9f6bf
commit a9b7ccdd16
43 changed files with 12436 additions and 0 deletions
+221
View File
@@ -0,0 +1,221 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import type { Readable } from "node:stream";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { UserFacingError, type Requester, type SearchResult, type SourceKind, type Track } from "../types.js";
const log = logger.child({ mod: "yt-dlp" });
/** Raw shape of the fields we consume from yt-dlp's JSON output. */
interface YtDlpEntry {
id?: string;
title?: string;
duration?: number | null;
uploader?: string | null;
channel?: string | null;
artist?: string | null;
webpage_url?: string | null;
url?: string | null;
original_url?: string | null;
thumbnail?: string | null;
thumbnails?: Array<{ url?: string }> | null;
is_live?: boolean | null;
live_status?: string | null;
extractor_key?: string | null;
ie_key?: string | null;
_type?: string;
entries?: YtDlpEntry[] | null;
}
function baseArgs(): string[] {
const args = ["--no-warnings", "--no-playlist-reverse", "--ignore-config", "--no-color"];
if (config.YTDLP_COOKIES) args.push("--cookies", config.YTDLP_COOKIES);
return args;
}
function runYtDlp(args: string[], timeoutMs = 45_000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(config.YTDLP_PATH, args, { windowsHide: true });
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new UserFacingError("yt-dlp не ответил вовремя"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => (stdout += chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => (stderr += chunk));
child.on("error", (err: NodeJS.ErrnoException) => {
clearTimeout(timer);
if (err.code === "ENOENT") {
reject(new UserFacingError(`yt-dlp не найден (${config.YTDLP_PATH}). Проверьте YTDLP_PATH.`));
return;
}
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 || stdout.trim().length > 0) {
resolve(stdout);
return;
}
log.warn({ code, stderr: stderr.slice(0, 800) }, "yt-dlp failed");
reject(new UserFacingError(firstUsefulError(stderr)));
});
});
}
function firstUsefulError(stderr: string): string {
const line = stderr
.split(/\r?\n/)
.map((l) => l.trim())
.find((l) => l.toUpperCase().startsWith("ERROR"));
if (!line) return "Не удалось получить трек";
return line.replace(/^ERROR:\s*/i, "").slice(0, 300);
}
function sourceOf(entry: YtDlpEntry): SourceKind {
const key = (entry.extractor_key ?? entry.ie_key ?? "").toLowerCase();
if (key.includes("soundcloud")) return "soundcloud";
if (key.includes("youtube")) return "youtube";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
if (url.includes("soundcloud.com")) return "soundcloud";
if (url.includes("youtube.com") || url.includes("youtu.be")) return "youtube";
return "direct";
}
function pickThumbnail(entry: YtDlpEntry): string | null {
if (entry.thumbnail) return entry.thumbnail;
const list = entry.thumbnails ?? [];
const last = list.at(-1);
return last?.url ?? null;
}
export function toTrack(entry: YtDlpEntry, requestedBy: Requester, fallbackSource?: SourceKind): Track {
const isLive = Boolean(entry.is_live) || entry.live_status === "is_live";
const url = entry.webpage_url ?? entry.original_url ?? entry.url ?? "";
return {
id: randomUUID(),
title: entry.title?.trim() || "Без названия",
author: entry.artist ?? entry.uploader ?? entry.channel ?? null,
duration: isLive ? 0 : Math.max(0, Math.round(entry.duration ?? 0)),
isLive,
url,
thumbnail: pickThumbnail(entry),
source: fallbackSource ?? sourceOf(entry),
requestedBy,
};
}
function parseNdjson(stdout: string): YtDlpEntry[] {
const entries: YtDlpEntry[] = [];
for (const line of stdout.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try {
entries.push(JSON.parse(trimmed) as YtDlpEntry);
} catch {
// yt-dlp occasionally interleaves non-JSON noise; skip it.
}
}
return entries;
}
export async function search(
query: string,
kind: "youtube" | "soundcloud",
limit: number,
requestedBy: Requester,
): Promise<Track[]> {
const prefix = kind === "soundcloud" ? "scsearch" : "ytsearch";
const stdout = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-json",
`${prefix}${limit}:${query}`,
]);
return parseNdjson(stdout).map((entry) => toTrack(entry, requestedBy, kind));
}
/** Resolves a URL that may point at a single track, a playlist, or an album. */
export async function resolveUrl(url: string, requestedBy: Requester, maxTracks: number): Promise<SearchResult> {
const stdout = await runYtDlp([
...baseArgs(),
"--flat-playlist",
"--dump-single-json",
"--playlist-end",
String(maxTracks),
url,
]);
const root = parseNdjson(stdout)[0];
if (!root) throw new UserFacingError("Не удалось разобрать ответ yt-dlp");
if (root._type === "playlist" && Array.isArray(root.entries)) {
const entries = root.entries.filter((e): e is YtDlpEntry => Boolean(e));
if (entries.length === 0) throw new UserFacingError("Плейлист пуст или недоступен");
return {
tracks: entries.map((entry) => toTrack(entry, requestedBy)),
playlist: {
title: root.title?.trim() || "Плейлист",
url: root.webpage_url ?? url,
trackCount: entries.length,
},
};
}
return { tracks: [toTrack(root, requestedBy)], playlist: null };
}
/** Resolves a direct, time-limited media URL for a page URL (used when seeking). */
export async function resolveStreamUrl(pageUrl: string): Promise<string> {
const stdout = await runYtDlp([...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "-g", pageUrl]);
const url = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
if (!url) throw new UserFacingError("Не удалось получить ссылку на аудиопоток");
return url;
}
export interface AudioProcess {
stream: Readable;
kill(): void;
}
/** Spawns yt-dlp writing the best audio to stdout, for piping straight into ffmpeg. */
export function openAudioStream(pageUrl: string): AudioProcess {
const child = spawn(
config.YTDLP_PATH,
[...baseArgs(), "-f", "bestaudio/best", "--no-playlist", "--quiet", "-o", "-", pageUrl],
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (chunk: string) => {
stderr = (stderr + chunk).slice(-2000);
});
child.on("close", (code) => {
if (code !== 0 && code !== null && stderr.trim()) {
log.warn({ code, stderr: stderr.slice(0, 500) }, "yt-dlp stream exited with error");
}
});
return {
stream: child.stdout,
kill: () => {
if (child.exitCode === null) child.kill("SIGKILL");
},
};
}
export async function checkAvailable(): Promise<string | null> {
try {
const out = await runYtDlp(["--version"], 15_000);
return out.trim() || null;
} catch {
return null;
}
}