Merge pull request #15 from ZHANGTIANYAO1/claude/start-music-api-servers-o3HHa

Fix ffmpeg spawn failure causing infinite retry loop
This commit is contained in:
TIANYAO ZHANG authored and GitHub committed 2026-04-07 20:09:13 +08:00
commit b97f4a1d86
3 files changed
+47 -5

No files matched your search

+42 -5
View File
@@ -37,15 +37,23 @@ function ffmpegWorks(bin: string): boolean {
/** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */ /** Resolved once at module load — prefer bundled ffmpeg-static, fall back to system. */
const resolvedFfmpeg: string = (() => { const resolvedFfmpeg: string = (() => {
if (ffmpegPath && isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) { // On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
return ffmpegPath; // 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 // Fall back to system ffmpeg
if (ffmpegWorks("ffmpeg")) { if (ffmpegWorks("ffmpeg")) {
return "ffmpeg"; return "ffmpeg";
} }
// Last resort: return whatever we have, will fail at runtime with clear error // Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
return ffmpegPath ?? "ffmpeg"; return "ffmpeg";
})(); })();
/** Resolve ffmpeg binary: prefer bundled ffmpeg-static, fall back to system PATH. */ /** 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_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
private ffmpegPaused = false; 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) { constructor(logger: Logger) {
super(); super();
@@ -94,6 +105,18 @@ export class AudioPlayer extends EventEmitter {
this.seekOffset = seekSeconds; this.seekOffset = seekSeconds;
this.framesPlayed = 0; this.framesPlayed = 0;
this.ffmpegPaused = false; 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"); 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.ffmpeg.on("error", (err) => {
this.logger.error({ err }, "FFmpeg error"); this.logger.error({ err }, "FFmpeg error");
if (this.sessionId === playSessionId) { if (this.sessionId === playSessionId) {
this.spawnFailed = true;
this.consecutiveFailures++;
this.emit("error", err); this.emit("error", err);
} }
}); });
@@ -210,7 +235,13 @@ export class AudioPlayer extends EventEmitter {
this.frameLoopRunning = false; this.frameLoopRunning = false;
if (this.state !== "idle") { if (this.state !== "idle") {
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; return;
} }
@@ -303,12 +334,18 @@ export class AudioPlayer extends EventEmitter {
} }
this.pcmBuffer = Buffer.alloc(0); this.pcmBuffer = Buffer.alloc(0);
this.ffmpegPaused = false; this.ffmpegPaused = false;
this.spawnFailed = false;
this.state = "idle"; this.state = "idle";
this.currentUrl = ""; this.currentUrl = "";
this.seekOffset = 0; this.seekOffset = 0;
this.framesPlayed = 0; this.framesPlayed = 0;
} }
/** Reset the consecutive failure counter (e.g. after user action) */
resetFailures(): void {
this.consecutiveFailures = 0;
}
setVolume(vol: number): void { setVolume(vol: number): void {
this.volume = Math.max(0, Math.min(100, vol)); this.volume = Math.max(0, Math.min(100, vol));
} }
+2
View File
@@ -263,6 +263,8 @@ export class BotInstance extends EventEmitter {
this.queue.add({ ...song, platform: provider.platform }); this.queue.add({ ...song, platform: provider.platform });
this.queue.play(); this.queue.play();
// Reset failure counter on user-initiated play
this.player.resetFailures();
const ok = await this.resolveAndPlay(this.queue.current()!); const ok = await this.resolveAndPlay(this.queue.current()!);
if (!ok) return `Cannot play: ${song.name}`; if (!ok) return `Cannot play: ${song.name}`;
return `Now playing: ${song.name} - ${song.artist}`; return `Now playing: ${song.name} - ${song.artist}`;
+3
View File
@@ -154,6 +154,7 @@ export function createPlayerRouter(
} }
const queue = bot.getQueueManager(); const queue = bot.getQueueManager();
bot.getPlayer().stop(); // Stop current playback first bot.getPlayer().stop(); // Stop current playback first
bot.getPlayer().resetFailures(); // Reset on user-initiated play
const song = queue.playAt(index); const song = queue.playAt(index);
if (!song) { if (!song) {
res.status(400).json({ error: "Invalid queue index" }); res.status(400).json({ error: "Invalid queue index" });
@@ -200,6 +201,7 @@ export function createPlayerRouter(
// Stop current playback // Stop current playback
bot.getPlayer().stop(); bot.getPlayer().stop();
bot.getPlayer().resetFailures();
const songs = await provider.getPlaylistSongs(playlistId); const songs = await provider.getPlaylistSongs(playlistId);
if (songs.length === 0) { if (songs.length === 0) {
@@ -256,6 +258,7 @@ export function createPlayerRouter(
queue.add({ ...song, platform: provider.platform }); queue.add({ ...song, platform: provider.platform });
queue.play(); queue.play();
bot.getPlayer().resetFailures();
const ok = await bot.resolveAndPlay(queue.current()!); const ok = await bot.resolveAndPlay(queue.current()!);
if (!ok) { if (!ok) {
res.json({ message: `Cannot play: ${song.name}` }); res.json({ message: `Cannot play: ${song.name}` });