Files
stoat-mbot/src/core/player.ts
T
Leonid PershinandClaude Opus 5 a9b7ccdd16 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>
2026-09-08 23:12:43 +03:00

479 lines
14 KiB
TypeScript

import { EventEmitter } from "node:events";
import { config } from "../config.js";
import { logger } from "../logger.js";
import { openPlayback, type PlaybackInput } from "../sources/index.js";
import {
UserFacingError,
type LoopMode,
type PlayerSnapshot,
type PlayerStatus,
type Track,
} from "../types.js";
import {
MediaPlayer,
parseFfmpegDuration,
type MediaPlayerLike,
type RevoiceLike,
type VoiceConnectionLike,
} from "./revoice.js";
const HISTORY_LIMIT = 50;
const JOIN_TIMEOUT_MS = 20_000;
export interface PositionUpdate {
serverId: string;
position: number;
duration: number;
status: PlayerStatus;
}
export interface Notice {
serverId: string;
textChannelId: string | null;
text: string;
}
export interface GuildPlayerEvents {
update: [PlayerSnapshot];
position: [PositionUpdate];
notice: [Notice];
destroyed: [{ serverId: string }];
}
export interface GuildPlayerOptions {
serverId: string;
serverName: string | null;
revoice: RevoiceLike;
}
/**
* Owns everything about music playback for one Stoat server: the voice
* connection, the queue and the ffmpeg-backed media player. Chat commands and
* the web panel both drive playback exclusively through this class, so the two
* can never drift apart.
*/
export class GuildPlayer extends EventEmitter<GuildPlayerEvents> {
readonly serverId: string;
serverName: string | null;
textChannelId: string | null = null;
voiceChannelId: string | null = null;
voiceChannelName: string | null = null;
queue: Track[] = [];
history: Track[] = [];
current: Track | null = null;
volume = config.DEFAULT_VOLUME;
loop: LoopMode = "off";
shuffleUsed = false;
private status: PlayerStatus = "idle";
private readonly revoice: RevoiceLike;
private connection: VoiceConnectionLike | null = null;
private media: MediaPlayerLike | null = null;
private currentInput: PlaybackInput | null = null;
private seekOffset = 0;
/** Set while we tear playback down ourselves, so the resulting `finish` is ignored. */
private expectingStop = false;
private idleTimer: NodeJS.Timeout | null = null;
private ticker: NodeJS.Timeout | null = null;
private readonly log;
constructor(options: GuildPlayerOptions) {
super();
this.serverId = options.serverId;
this.serverName = options.serverName;
this.revoice = options.revoice;
this.log = logger.child({ mod: "player", server: options.serverId });
}
// ---------------------------------------------------------------- state ---
get position(): number {
if (!this.media) return 0;
return this.seekOffset + this.media.seconds;
}
snapshot(): PlayerSnapshot {
return {
serverId: this.serverId,
serverName: this.serverName,
voiceChannelId: this.voiceChannelId,
voiceChannelName: this.voiceChannelName,
textChannelId: this.textChannelId,
status: this.status,
current: this.current,
position: Math.round(this.position * 10) / 10,
queue: this.queue,
history: this.history.slice(0, 10),
volume: this.volume,
loop: this.loop,
shuffleUsed: this.shuffleUsed,
updatedAt: Date.now(),
};
}
private setStatus(status: PlayerStatus): void {
if (this.status === status) return;
this.status = status;
this.publish();
}
publish(): void {
this.emit("update", this.snapshot());
}
private notify(text: string): void {
this.emit("notice", { serverId: this.serverId, textChannelId: this.textChannelId, text });
}
// ------------------------------------------------------------ connection ---
isConnected(): boolean {
return Boolean(this.connection?.connected);
}
async connect(channelId: string, channelName: string | null): Promise<void> {
if (this.connection?.connected && this.voiceChannelId === channelId) {
this.voiceChannelName = channelName ?? this.voiceChannelName;
return;
}
if (this.connection) await this.leaveVoice();
this.setStatus("connecting");
this.log.info({ channelId }, "joining voice channel");
const connection = await this.revoice.join(channelId);
if (!connection.connected) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")),
JOIN_TIMEOUT_MS,
);
connection.on("join", () => {
clearTimeout(timer);
resolve();
});
});
}
this.connection = connection;
this.voiceChannelId = channelId;
this.voiceChannelName = channelName;
connection.on("userleave", () => this.checkEmptyChannel());
connection.on("userLeave", () => this.checkEmptyChannel());
connection.on("userJoin", () => this.clearIdleTimer());
const media = new MediaPlayer(true);
media.on("startplay", () => {
this.setStatus(media.paused ? "paused" : "playing");
this.startTicker();
});
media.on("buffer", () => this.setStatus("buffering"));
media.on("pause", () => this.setStatus("paused"));
media.on("unpause", () => this.setStatus("playing"));
media.on("finish", () => {
void this.handleFinish();
});
this.media = media;
await connection.play(media);
this.setStatus("idle");
this.log.info({ channelId }, "voice connection established");
}
async leaveVoice(): Promise<void> {
this.clearIdleTimer();
this.stopTicker();
this.teardownPlayback();
this.current = null;
const connection = this.connection;
this.connection = null;
this.media?.removeAllListeners();
this.media = null;
this.voiceChannelId = null;
this.voiceChannelName = null;
if (connection) {
try {
await connection.destroy();
} catch (err) {
this.log.warn({ err }, "failed to leave voice channel cleanly");
}
connection.removeAllListeners();
}
this.setStatus("idle");
this.publish();
}
private checkEmptyChannel(): void {
if (!this.connection) return;
if (this.connection.getUsers().length > 0) return;
this.startIdleTimer("В канале никого не осталось");
}
// -------------------------------------------------------------- playback ---
private assertReady(): MediaPlayerLike {
if (!this.media || !this.connection?.connected) {
throw new UserFacingError("Бот не подключён к голосовому каналу");
}
return this.media;
}
enqueue(tracks: Track[], position?: number): void {
if (this.queue.length + tracks.length > config.MAX_QUEUE_SIZE) {
throw new UserFacingError(`Очередь ограничена ${config.MAX_QUEUE_SIZE} треками`);
}
if (position === undefined) this.queue.push(...tracks);
else this.queue.splice(Math.max(0, position), 0, ...tracks);
this.publish();
}
/** Starts playback if nothing is currently playing. */
async ensurePlaying(): Promise<void> {
if (this.current || this.status === "buffering" || this.status === "connecting") return;
await this.advance(false);
}
private async startPlayback(track: Track, seekSeconds = 0): Promise<void> {
const media = this.assertReady();
this.clearIdleTimer();
this.teardownPlayback();
this.current = track;
this.seekOffset = seekSeconds;
this.setStatus("buffering");
this.publish();
try {
const input = await openPlayback(track, seekSeconds);
this.currentInput = input;
this.expectingStop = false;
await media.playStream(input.input, input.inputOptions);
// stop() rebuilds the volume transformer, so volume is applied per track.
media.setVolume(this.volume / 100);
this.startTicker();
} catch (err) {
this.log.warn({ err, track: track.title }, "playback failed");
const message = err instanceof UserFacingError ? err.message : "неизвестная ошибка";
this.notify(`⚠️ Не удалось воспроизвести **${track.title}** (${message}), пропускаю.`);
this.current = null;
await this.advance(true);
}
}
/** Tears down ffmpeg/yt-dlp for the current track without advancing the queue. */
private teardownPlayback(): void {
if (this.media) {
this.expectingStop = true;
try {
this.media.fProc?.kill("SIGKILL");
} catch {
// ffmpeg may already be gone.
}
try {
this.media.stop();
} catch (err) {
this.log.debug({ err }, "media.stop() threw");
}
}
this.currentInput?.cleanup();
this.currentInput = null;
this.seekOffset = 0;
}
private async handleFinish(): Promise<void> {
if (this.expectingStop) {
this.expectingStop = false;
return;
}
await this.advance(false);
}
/** Moves to the next track. `skipLoop` ignores per-track looping (used by skip). */
private async advance(skipLoop: boolean): Promise<void> {
const finished = this.current;
this.current = null;
this.currentInput?.cleanup();
this.currentInput = null;
this.seekOffset = 0;
if (finished) {
this.history.unshift(finished);
this.history = this.history.slice(0, HISTORY_LIMIT);
if (!skipLoop && this.loop === "track") this.queue.unshift(finished);
else if (this.loop === "queue") this.queue.push(finished);
}
const next = this.queue.shift();
if (!next) {
this.stopTicker();
this.setStatus("idle");
this.publish();
if (finished) this.notify("⏹️ Очередь закончилась.");
this.startIdleTimer();
return;
}
await this.startPlayback(next);
this.notify(`▶️ Сейчас играет: **${next.title}**`);
}
async skip(count = 1): Promise<Track | null> {
if (!this.current && this.queue.length === 0) throw new UserFacingError("Нечего пропускать");
for (let i = 1; i < count; i += 1) this.queue.shift();
this.teardownPlayback();
await this.advance(true);
return this.current;
}
async stop(): Promise<void> {
this.queue = [];
this.loop = "off";
this.teardownPlayback();
this.current = null;
this.stopTicker();
this.setStatus("idle");
this.publish();
this.startIdleTimer();
}
pause(): void {
const media = this.assertReady();
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
media.pause();
this.setStatus("paused");
this.publish();
}
resume(): void {
const media = this.assertReady();
if (!this.current) throw new UserFacingError("Сейчас ничего не играет");
media.resume();
this.setStatus("playing");
this.publish();
}
setVolume(volume: number): void {
const clamped = Math.min(200, Math.max(0, Math.round(volume)));
this.volume = clamped;
this.media?.setVolume(clamped / 100);
this.publish();
}
setLoop(mode: LoopMode): void {
this.loop = mode;
this.publish();
}
shuffle(): void {
for (let i = this.queue.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const a = this.queue[i];
const b = this.queue[j];
if (a && b) {
this.queue[i] = b;
this.queue[j] = a;
}
}
this.shuffleUsed = true;
this.publish();
}
remove(trackId: string): Track {
const index = this.queue.findIndex((track) => track.id === trackId);
if (index === -1) throw new UserFacingError("Трек не найден в очереди");
const [removed] = this.queue.splice(index, 1);
this.publish();
return removed as Track;
}
move(trackId: string, toIndex: number): void {
const from = this.queue.findIndex((track) => track.id === trackId);
if (from === -1) throw new UserFacingError("Трек не найден в очереди");
const target = Math.min(this.queue.length - 1, Math.max(0, toIndex));
const [track] = this.queue.splice(from, 1);
if (track) this.queue.splice(target, 0, track);
this.publish();
}
clearQueue(): void {
this.queue = [];
this.publish();
}
async seek(seconds: number): Promise<void> {
const track = this.current;
if (!track) throw new UserFacingError("Сейчас ничего не играет");
if (track.isLive) throw new UserFacingError("Нельзя перематывать прямой эфир");
if (track.duration > 0 && seconds >= track.duration) {
throw new UserFacingError("Позиция за пределами трека");
}
this.teardownPlayback();
await this.startPlayback(track, Math.max(0, seconds));
}
async playNow(tracks: Track[]): Promise<void> {
if (tracks.length === 0) return;
this.queue.unshift(...tracks);
this.teardownPlayback();
await this.advance(true);
}
// ---------------------------------------------------------- housekeeping ---
private startTicker(): void {
if (this.ticker) return;
this.ticker = setInterval(() => {
if (!this.current || !this.media) return;
// ffmpeg reports the real duration once it has probed the input, which is
// the only way we learn how long a local file or a direct URL is.
if (this.current.duration === 0 && !this.current.isLive) {
const probed = parseFfmpegDuration(this.media.codecData?.duration);
if (probed > 0) {
this.current.duration = Math.round(probed);
this.publish();
}
}
this.emit("position", {
serverId: this.serverId,
position: Math.round(this.position * 10) / 10,
duration: this.current.duration,
status: this.status,
});
}, 1000);
this.ticker.unref?.();
}
private stopTicker(): void {
if (!this.ticker) return;
clearInterval(this.ticker);
this.ticker = null;
}
private clearIdleTimer(): void {
if (!this.idleTimer) return;
clearTimeout(this.idleTimer);
this.idleTimer = null;
}
private startIdleTimer(reason?: string): void {
this.clearIdleTimer();
if (config.IDLE_TIMEOUT_SECONDS <= 0 || !this.connection) return;
this.idleTimer = setTimeout(() => {
if (this.current) return;
this.notify(`👋 ${reason ?? "Нет активности"}, выхожу из голосового канала.`);
void this.leaveVoice();
}, config.IDLE_TIMEOUT_SECONDS * 1000);
this.idleTimer.unref?.();
}
async destroy(): Promise<void> {
await this.leaveVoice();
this.emit("destroyed", { serverId: this.serverId });
this.removeAllListeners();
}
}