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>
53 lines
2.1 KiB
TypeScript
53 lines
2.1 KiB
TypeScript
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 "выключен";
|
|
}
|