mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-11 17:32:49 +08:00
fix: drain FFmpeg stderr and sanitize playback diagnostics
This commit is contained in:
1 parent
5258ba951b
commit
a4c0239a59
4 files changed
+489
-11
No files matched your search
+53
-10
@@ -7,6 +7,7 @@ import { join } from "node:path";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { collectFfmpegDiagnostics, type FfmpegDiagnostics } from "./ffmpeg-diagnostics.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ffmpegPath: string | null = require("ffmpeg-static");
|
||||
@@ -52,6 +53,17 @@ export function getFfmpegCommand(): string {
|
||||
return resolvedFfmpeg;
|
||||
}
|
||||
|
||||
function safeFfmpegSpawnError(err: Error): Error {
|
||||
// Node spawn errors include spawnargs; Pino's Error serializer copies them,
|
||||
// including the signed input URL. Preserve a known OS category only.
|
||||
const allowedCodes = new Set(["ENOENT", "EACCES", "EPERM", "ENOEXEC", "EMFILE", "ENFILE", "ENOMEM", "EAGAIN", "EINVAL"]);
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
const safeCode = typeof code === "string" && allowedCodes.has(code) ? code : undefined;
|
||||
const safeError = new Error(`FFmpeg failed to start${safeCode ? ` (${safeCode})` : ""}`);
|
||||
if (safeCode) Object.assign(safeError, { code: safeCode });
|
||||
return safeError;
|
||||
}
|
||||
|
||||
const BROWSER_UA =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
@@ -74,7 +86,7 @@ export function cleanupTempDir(dir: string): void {
|
||||
}
|
||||
|
||||
export function buildFfmpegArgs(url: string, seekSeconds: number): string[] {
|
||||
const args: string[] = [];
|
||||
const args: string[] = ["-nostats"];
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
const isBilibili = isHttp && (url.includes("bilivideo") || url.includes("bilibili"));
|
||||
|
||||
@@ -167,6 +179,8 @@ const FRAME_DURATION_MS = 20;
|
||||
|
||||
export class AudioPlayer extends EventEmitter {
|
||||
private ffmpeg: ChildProcess | null = null;
|
||||
private ffmpegDiagnostics: FfmpegDiagnostics | null = null;
|
||||
private readonly intentionalCleanup = new WeakSet<ChildProcess>();
|
||||
private encoder: Encoder;
|
||||
private state: PlayerState = "idle";
|
||||
private volume = 75;
|
||||
@@ -258,6 +272,9 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = this.ffmpeg;
|
||||
const diagnostics = collectFfmpegDiagnostics(child.stderr);
|
||||
this.ffmpegDiagnostics = diagnostics;
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
@@ -280,7 +297,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
|
||||
// 只有当前会话的进程结束才置空变量
|
||||
if (this.sessionId === currentSessionId) {
|
||||
@@ -288,11 +304,21 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// close follows stderr's end, so final unterminated diagnostics are ready.
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
const context = { pid: currentPid, sessionId: currentSessionId, code, signal };
|
||||
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
|
||||
} else {
|
||||
this.logger.info(context, "FFmpeg exited");
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
if (this.sessionId === currentSessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
this.emit("error", safeFfmpegSpawnError(err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -332,10 +358,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
);
|
||||
this.downloader = ps;
|
||||
|
||||
let stderrTail = "";
|
||||
ps.stderr!.on("data", (chunk: Buffer) => {
|
||||
stderrTail = (stderrTail + chunk.toString()).slice(-500);
|
||||
});
|
||||
const diagnostics = collectFfmpegDiagnostics(ps.stderr);
|
||||
|
||||
ps.on("exit", (code, signal) => {
|
||||
if (this.sessionId !== sessionId) {
|
||||
@@ -344,7 +367,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
this.downloader = null;
|
||||
if (code !== 0) {
|
||||
this.logger.warn({ code, signal, stderr: stderrTail }, "PowerShell download failed");
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.state = "idle";
|
||||
@@ -356,6 +378,12 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.spawnFfmpegFromFile(tempFile, seekSeconds, sessionId);
|
||||
});
|
||||
|
||||
ps.on("close", (code, signal) => {
|
||||
if (!this.intentionalCleanup.has(ps) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ pid: ps.pid, sessionId, code, signal, stderr: diagnostics.getTail() }, "PowerShell download failed");
|
||||
}
|
||||
});
|
||||
|
||||
ps.on("error", (err) => {
|
||||
if (this.sessionId !== sessionId) return;
|
||||
this.downloader = null;
|
||||
@@ -386,6 +414,9 @@ export class AudioPlayer extends EventEmitter {
|
||||
const args = buildFfmpegArgs(tempFile, seekSeconds);
|
||||
const ffmpegBin = getFfmpegCommand();
|
||||
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
const child = this.ffmpeg;
|
||||
const diagnostics = collectFfmpegDiagnostics(child.stderr);
|
||||
this.ffmpegDiagnostics = diagnostics;
|
||||
|
||||
const currentPid = this.ffmpeg.pid;
|
||||
if (currentPid) {
|
||||
@@ -405,7 +436,6 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
this.ffmpeg.on("exit", (code, signal) => {
|
||||
if (currentPid) globalActivePids.delete(currentPid);
|
||||
this.logger.info({ pid: currentPid, code, signal }, "FFmpeg exited");
|
||||
if (this.sessionId === sessionId) {
|
||||
this.ffmpeg = null;
|
||||
if (this.currentTempDir === tempDirToCleanup) this.currentTempDir = null;
|
||||
@@ -413,11 +443,20 @@ export class AudioPlayer extends EventEmitter {
|
||||
if (tempDirToCleanup) cleanupTempDir(tempDirToCleanup);
|
||||
});
|
||||
|
||||
this.ffmpeg.on("close", (code, signal) => {
|
||||
const context = { pid: currentPid, sessionId, code, signal };
|
||||
if (!this.intentionalCleanup.has(child) && ((typeof code === "number" && code !== 0) || signal !== null)) {
|
||||
this.logger.warn({ ...context, stderr: diagnostics.getTail() }, "FFmpeg exited");
|
||||
} else {
|
||||
this.logger.info(context, "FFmpeg exited");
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
if (this.sessionId === sessionId) {
|
||||
this.spawnFailed = true;
|
||||
this.consecutiveFailures++;
|
||||
this.emit("error", err);
|
||||
this.emit("error", safeFfmpegSpawnError(err));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -543,6 +582,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
|
||||
// 立即清空缓冲区,确保切歌瞬间静音 (
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.ffmpegDiagnostics = null;
|
||||
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
@@ -557,6 +597,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
if (this.downloader) {
|
||||
const ps = this.downloader;
|
||||
this.downloader = null;
|
||||
this.intentionalCleanup.add(ps);
|
||||
try { ps.kill("SIGTERM"); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
@@ -580,6 +621,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
private forceCleanup(proc: ChildProcess, pid: number): void {
|
||||
this.intentionalCleanup.add(proc);
|
||||
if (!globalActivePids.has(pid)) return;
|
||||
|
||||
try {
|
||||
@@ -664,6 +706,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
duration: this.currentSongDuration,
|
||||
remaining: Math.round(this.currentSongDuration - elapsed),
|
||||
nearEnd: isNearEnd,
|
||||
stderr: this.ffmpegDiagnostics?.getTail() ?? "",
|
||||
}, "FFmpeg stopped outputting data, ending track");
|
||||
this.frameLoopRunning = false;
|
||||
// The outer gate guarantees state==="playing" here, so no !=="idle"
|
||||
|
||||
Reference in new issue
Block a user