import { execa } from "execa"; import { classifyFfmpegProbe, type StreamVerdict, } from "./ffmpegStreamClassify"; export type ProbeAudioStreamOptions = { ffmpegBin: string; file: string; signal: AbortSignal; onLog?: (s: string) => void; // Target audio codec for the probe transcode. We mirror the channel's // output codec so the probe exercises the same encode path the final // transcode will use. Defaults to libmp3lame. codecArg?: string; }; export type ProbeAudioStreamResult = { verdict: StreamVerdict; stderr: string; exitCode: number | null; }; // Probe by performing a real audio transcode to /dev/null. The exit code is // the authoritative signal: // - 0 → clean (or partial-but-decodable; either is fine for our flow) // - != 0 → malformed (a real codec error tripped ffmpeg) // ffmpeg treats a truncated container as a recoverable warning ("partial // file" stderr line, exit 0). Only true mid-stream corruption — e.g. AAC's // "Sample rate index in program config element does not match" — produces a // non-zero exit. -xerror with -f null underreports because the null muxer // swallows decoder errors during write. export async function probeAudioStream( opts: ProbeAudioStreamOptions, ): Promise { const codecArg = opts.codecArg ?? "libmp3lame"; const args = [ "-y", "-v", "error", "-nostdin", "-i", opts.file, "-vn", "-c:a", codecArg, "-f", "mp3", "/dev/null", ]; opts.onLog?.(`$ ${opts.ffmpegBin} ${args.join(" ")}\n`); const child = execa(opts.ffmpegBin, args, { cancelSignal: opts.signal, all: false, buffer: false, reject: false, }); let stderr = ""; child.stderr?.on("data", (c: Buffer) => { const chunk = c.toString("utf8"); stderr += chunk; opts.onLog?.(chunk); }); child.stdout?.on("data", () => {}); const result = await child; const exitCode = result.exitCode ?? null; return { verdict: classifyFfmpegProbe(exitCode, stderr), stderr, exitCode, }; }