diff --git a/src/audio/player.ts b/src/audio/player.ts index 03be44f..f0bcb53 100644 --- a/src/audio/player.ts +++ b/src/audio/player.ts @@ -37,15 +37,23 @@ function ffmpegWorks(bin: string): boolean { /** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */ const resolvedFfmpeg: string = (() => { - if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { - return ffmpegPath; + // On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS + // it may return a Windows .exe path if node_modules was copied cross-platform. + const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false; + const onWindows = process.platform === "win32"; + + // Only try bundled binary if platform matches + if (ffmpegPath && (onWindows === isWinPath)) { + if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { + return ffmpegPath; + } } // Fall back to system ffmpeg if (ffmpegWorks("ffmpeg")) { return "ffmpeg"; } - // Last resort: return whatever we have, will fail at runtime with clear error - return ffmpegPath ?? "ffmpeg"; + // Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path + return "ffmpeg"; })(); /** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */ @@ -79,6 +87,9 @@ export class AudioPlayer extends EventEmitter { private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s private ffmpegPaused = false; + private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade) + private consecutiveFailures = 0; + private static readonly MAX_CONSECUTIVE_FAILURES = 3; constructor(logger: Logger) { super(); @@ -94,6 +105,18 @@ export class AudioPlayer extends EventEmitter { this.seekOffset = seekSeconds; this.framesPlayed = 0; this.ffmpegPaused = false; + this.spawnFailed = false; + + // Prevent rapid-fire spawn attempts when ffmpeg is broken + if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) { + this.logger.error( + { failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() }, + "Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback." + ); + this.state = "idle"; + this.emit("error", new Error("ffmpeg unavailable after repeated failures")); + return; + } this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback"); @@ -160,6 +183,8 @@ export class AudioPlayer extends EventEmitter { this.ffmpeg.on("error", (err) => { this.logger.error({ err }, "FFmpeg error"); if (this.sessionId === playSessionId) { + this.spawnFailed = true; + this.consecutiveFailures++; this.emit("error", err); } }); @@ -210,7 +235,13 @@ export class AudioPlayer extends EventEmitter { this.frameLoopRunning = false; if (this.state !== "idle") { this.state = "idle"; - this.emit("trackEnd"); + // Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade + if (this.spawnFailed) { + this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure"); + } else { + this.consecutiveFailures = 0; // Reset on successful track completion + this.emit("trackEnd"); + } } return; } @@ -303,12 +334,18 @@ export class AudioPlayer extends EventEmitter { } this.pcmBuffer = Buffer.alloc(0); this.ffmpegPaused = false; + this.spawnFailed = false; this.state = "idle"; this.currentUrl = ""; this.seekOffset = 0; this.framesPlayed = 0; } + /** Reset the consecutive failure counter (e.g. after user action) */ + resetFailures(): void { + this.consecutiveFailures = 0; + } + setVolume(vol: number): void { this.volume = Math.max(0, Math.min(100, vol)); } diff --git a/src/bot/instance.ts b/src/bot/instance.ts index 0058824..23261ef 100644 --- a/src/bot/instance.ts +++ b/src/bot/instance.ts @@ -263,6 +263,8 @@ export class BotInstance extends EventEmitter { this.queue.add({ ...song, platform: provider.platform }); this.queue.play(); + // Reset failure counter on user-initiated play + this.player.resetFailures(); const ok = await this.resolveAndPlay(this.queue.current()!); if (!ok) return `Cannot play: ${song.name}`; return `Now playing: ${song.name} - ${song.artist}`; diff --git a/src/web/api/player.ts b/src/web/api/player.ts index 305c133..bd82b61 100644 --- a/src/web/api/player.ts +++ b/src/web/api/player.ts @@ -154,6 +154,7 @@ export function createPlayerRouter( } const queue = bot.getQueueManager(); bot.getPlayer().stop(); // Stop current playback first + bot.getPlayer().resetFailures(); // Reset on user-initiated play const song = queue.playAt(index); if (!song) { res.status(400).json({ error: "Invalid queue index" }); @@ -200,6 +201,7 @@ export function createPlayerRouter( // Stop current playback bot.getPlayer().stop(); + bot.getPlayer().resetFailures(); const songs = await provider.getPlaylistSongs(playlistId); if (songs.length === 0) { @@ -256,6 +258,7 @@ export function createPlayerRouter( queue.add({ ...song, platform: provider.platform }); queue.play(); + bot.getPlayer().resetFailures(); const ok = await bot.resolveAndPlay(queue.current()!); if (!ok) { res.json({ message: `Cannot play: ${song.name}` });