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:
co-authored by
Claude Opus 5
parent
d9d0e9f6bf
commit
a9b7ccdd16
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user