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, VOICE_STATE_OFFLINE, 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 { 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; /** Tracked from connection events: revoice's own `connected` getter is broken. */ private voiceReady = false; 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 this.voiceReady; } async connect(channelId: string, channelName: string | null): Promise { if (this.voiceReady && 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); try { // The room connects asynchronously inside revoice's constructor, so we wait // for it to report readiness rather than polling a state getter. await new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new UserFacingError("Не удалось подключиться к голосовому каналу")), JOIN_TIMEOUT_MS, ); const done = () => { clearTimeout(timer); resolve(); }; connection.once("join", done); connection.once("roomfetched", done); }); } catch (err) { // Leave no orphan: an abandoned connection keeps the bot registered in the // channel, and Stoat then refuses the next join with AlreadyConnected. await connection.destroy().catch(() => {}); connection.removeAllListeners(); this.setStatus("idle"); throw err; } this.connection = connection; this.voiceReady = true; this.voiceChannelId = channelId; this.voiceChannelName = channelName; connection.on("state", (state) => { if (state !== VOICE_STATE_OFFLINE) return; this.voiceReady = false; this.log.warn({ channelId }, "voice connection went offline"); }); connection.on("userleave", () => this.checkEmptyChannel()); connection.on("userLeave", () => this.checkEmptyChannel()); connection.on("userJoin", () => this.clearIdleTimer()); const media = new MediaPlayer(true); // revoice's #cleanUp() dereferences this.fProc unconditionally, so a second // stop() (which its own ffmpeg error handler triggers) throws and would take // the whole process down. Everything it calls goes through this instance // method, so guarding it here covers its internal paths too. const stop = media.stop.bind(media); media.stop = (init?: boolean) => { try { stop(init); } catch (err) { this.log.debug({ err }, "revoice cleanup threw, ignoring"); } }; 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 { this.clearIdleTimer(); this.stopTicker(); this.teardownPlayback(); this.current = null; const connection = this.connection; this.connection = null; this.voiceReady = false; 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.voiceReady) { 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 { if (this.current || this.status === "buffering" || this.status === "connecting") return; await this.advance(false); } private async startPlayback(track: Track, seekSeconds = 0): Promise { 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(); // A downloader that dies mid-stream just looks like a very short track, so // say why instead of silently moving on. void input.failure?.then((reason) => { if (!reason || this.current?.id !== track.id) return; this.log.warn({ reason, track: track.title }, "source failed while streaming"); this.notify(`⚠️ **${track.title}** — источник отдал ошибку: ${reason}`); }); } 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 { // Detach revoice's own handlers first: killing ffmpeg makes it emit // "error", and its handler would call stop() again on a half-reset player. const proc = this.media.fProc; if (proc) { proc.removeAllListeners("error"); proc.removeAllListeners("end"); proc.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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.leaveVoice(); this.emit("destroyed", { serverId: this.serverId }); this.removeAllListeners(); } }