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,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 "выключен";
|
||||
}
|
||||
Reference in New Issue
Block a user