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
+52
View File
@@ -0,0 +1,52 @@
import type { LoopMode, Track } from "../types.js";
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "LIVE";
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const secs = total % 60;
const pad = (value: number) => value.toString().padStart(2, "0");
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(secs)}` : `${minutes}:${pad(secs)}`;
}
export function parseTimecode(input: string): number | null {
const trimmed = input.trim();
if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10);
const match = /^(?:(\d+):)?(\d{1,2}):(\d{1,2})$/.exec(trimmed);
if (!match) return null;
const [, hours, minutes, seconds] = match;
return (
Number.parseInt(hours ?? "0", 10) * 3600 +
Number.parseInt(minutes ?? "0", 10) * 60 +
Number.parseInt(seconds ?? "0", 10)
);
}
export function progressBar(position: number, duration: number, width = 22): string {
if (duration <= 0) return "🔴 прямой эфир";
const ratio = Math.min(1, Math.max(0, position / duration));
const filled = Math.round(ratio * (width - 1));
const bar = `${"─".repeat(filled)}${"─".repeat(Math.max(0, width - 1 - filled))}`;
return `\`${formatDuration(position)}\` ${bar} \`${formatDuration(duration)}\``;
}
const SOURCE_LABEL: Record<Track["source"], string> = {
youtube: "YouTube",
soundcloud: "SoundCloud",
direct: "Ссылка",
local: "Медиатека",
};
export function trackLine(track: Track, index?: number): string {
const prefix = index === undefined ? "" : `**${index}.** `;
const author = track.author ? `${track.author}` : "";
const link = /^https?:/i.test(track.url) ? `[${track.title}](${track.url})` : track.title;
return `${prefix}${link}${author} \`[${formatDuration(track.duration)}]\` · ${SOURCE_LABEL[track.source]} · <@${track.requestedBy.id}>`;
}
export function loopLabel(mode: LoopMode): string {
if (mode === "track") return "трек";
if (mode === "queue") return "очередь";
return "выключен";
}