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
+83
View File
@@ -0,0 +1,83 @@
import type { Me, ServerStateResponse, Track } from "./types";
export class ApiError extends Error {}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: "same-origin",
headers: init.body ? { "content-type": "application/json" } : undefined,
...init,
});
const text = await res.text();
const body = text ? JSON.parse(text) : null;
if (!res.ok) throw new ApiError(body?.error ?? `Ошибка ${res.status}`);
return body as T;
}
export interface LoginResponse {
user?: { id: string; username: string };
mfaRequired?: boolean;
ticket?: string;
methods?: string[];
}
export const api = {
me: () => request<Me>("/api/me"),
login: (payload: {
email?: string;
password?: string;
mfaTicket?: string;
totpCode?: string;
recoveryCode?: string;
}) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
loginWithLink: (token: string) =>
request<{ user: { id: string; username: string }; serverId: string | null }>("/api/auth/link", {
method: "POST",
body: JSON.stringify({ token }),
}),
logout: () => request<{ ok: true }>("/api/auth/logout", { method: "POST" }),
state: (serverId: string) => request<ServerStateResponse>(`/api/servers/${serverId}/state`),
search: (serverId: string, query: string) =>
request<{ tracks: Track[] }>(`/api/servers/${serverId}/search?q=${encodeURIComponent(query)}`),
play: (
serverId: string,
payload: { query?: string; trackIds?: string[]; mode?: "append" | "next" | "now"; voiceChannelId?: string | null },
) => request<{ ok: true }>(`/api/servers/${serverId}/play`, { method: "POST", body: JSON.stringify(payload) }),
action: (serverId: string, action: string, payload: Record<string, unknown> = {}) =>
request<{ ok: true }>(`/api/servers/${serverId}/actions/${action}`, {
method: "POST",
body: JSON.stringify(payload),
}),
join: (serverId: string, voiceChannelId: string | null) =>
request<{ ok: true }>(`/api/servers/${serverId}/join`, {
method: "POST",
body: JSON.stringify({ voiceChannelId }),
}),
removeTrack: (serverId: string, trackId: string) =>
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}`, { method: "DELETE" }),
moveTrack: (serverId: string, trackId: string, index: number) =>
request<{ ok: true }>(`/api/servers/${serverId}/queue/${trackId}/move`, {
method: "POST",
body: JSON.stringify({ index }),
}),
};
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)}`;
}