Skip to content

Commit 0b95eee

Browse files
committed
fix: parse audio probe streams without CSV side data ambiguity
1 parent 0cb42df commit 0b95eee

3 files changed

Lines changed: 65 additions & 5 deletions

File tree

apps/media-server/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/au
1515
&& bun test src/__tests__/lib/audio-quality-formats.integration.test.ts src/__tests__/lib/audio-quality-benchmark.test.ts \
1616
&& bun test src/__tests__/lib/drive-resumable-upload.test.ts src/__tests__/lib/storage-upload.test.ts src/__tests__/lib/container-memory.test.ts \
1717
&& bun test src/__tests__/lib/media-size.test.ts src/__tests__/lib/media-probe.integration.test.ts \
18+
&& bun test src/__tests__/lib/media-audio.integration.test.ts \
1819
&& bun test src/__tests__/lib/recording-verification.integration.test.ts src/__tests__/lib/job-manager.test.ts \
1920
&& bun test src/__tests__/lib/media-transfer.test.ts \
2021
&& bun test src/__tests__/routes/recording-verification.test.ts \

apps/media-server/src/__tests__/lib/media-audio.integration.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,48 @@ describe("mediaAudio integration tests", () => {
104104
expect(hasAudio).toBe(false);
105105
});
106106

107+
test.each([
108+
[90, true],
109+
[-90, true],
110+
[180, true],
111+
[90, false],
112+
] as const)(
113+
"detects audio with display rotation %i and audio=%s",
114+
async (rotation, hasAudio) => {
115+
const dirPath = await mkdtemp(join(tmpdir(), "cap-audio-rotation-"));
116+
const outputPath = join(dirPath, "rotated.mp4");
117+
try {
118+
const proc = Bun.spawn({
119+
cmd: [
120+
"ffmpeg",
121+
"-v",
122+
"error",
123+
"-display_rotation",
124+
String(rotation),
125+
"-i",
126+
hasAudio ? TEST_VIDEO_WITH_AUDIO : TEST_VIDEO_NO_AUDIO,
127+
"-c",
128+
"copy",
129+
outputPath,
130+
],
131+
stdout: "ignore",
132+
stderr: "pipe",
133+
});
134+
const [stderr, exitCode] = await Promise.all([
135+
new Response(proc.stderr).text(),
136+
proc.exited,
137+
]);
138+
expect(stderr).toBe("");
139+
expect(exitCode).toBe(0);
140+
expect(await checkHasAudioTrack(`file://${outputPath}`)).toBe(
141+
hasAudio,
142+
);
143+
} finally {
144+
await rm(dirPath, { recursive: true, force: true });
145+
}
146+
},
147+
);
148+
107149
test("inherits signed queries for relative HLS segments", async () => {
108150
const dirPath = await createHlsFixture();
109151
const requests: string[] = [];
@@ -134,6 +176,21 @@ describe("mediaAudio integration tests", () => {
134176
}
135177
});
136178

179+
test("still rejects audio-only inputs without leaking an operation", async () => {
180+
const beforeCount = getActiveProcessCount();
181+
const dirPath = await mkdtemp(join(tmpdir(), "cap-audio-only-"));
182+
const outputPath = join(dirPath, "audio.mp3");
183+
try {
184+
await Bun.write(outputPath, await extractAudio(TEST_VIDEO_WITH_AUDIO));
185+
await expect(
186+
checkHasAudioTrack(`file://${outputPath}`),
187+
).rejects.toThrow("No video stream found");
188+
await waitForAudioOperations(beforeCount);
189+
} finally {
190+
await rm(dirPath, { recursive: true, force: true });
191+
}
192+
});
193+
137194
test("contains upstream failures without leaking the active operation", async () => {
138195
const beforeCount = getActiveProcessCount();
139196
const server = Bun.serve({

apps/media-server/src/lib/media-audio.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mkdtemp, rm } from "node:fs/promises";
22
import { join } from "node:path";
33
import { type Subprocess, spawn } from "bun";
4+
import { z } from "zod";
45
import { withTimeout } from "./media-common";
56
import {
67
canAcceptNewAudioOperation,
@@ -31,6 +32,9 @@ const MAX_AUDIO_SIZE_BYTES = 100 * 1024 * 1024;
3132
const MAX_STDERR_BYTES = 64 * 1024;
3233
const AUDIO_PROBE_MAX_ATTEMPTS = 3;
3334
const AUDIO_PROBE_RETRY_BASE_MS = 250;
35+
const AUDIO_PROBE_SCHEMA = z.object({
36+
streams: z.array(z.object({ codec_type: z.string().optional() })),
37+
});
3438

3539
const DEFAULT_OPTIONS: Required<AudioExtractionOptions> = {
3640
format: "mp3",
@@ -144,7 +148,7 @@ function getAudioProbeArgs(inputPath: string): string[] {
144148
"0",
145149
);
146150
}
147-
args.push("-show_entries", "stream=codec_type", "-of", "csv=p=0", inputPath);
151+
args.push("-show_entries", "stream=codec_type", "-of", "json", inputPath);
148152
return args;
149153
}
150154

@@ -224,10 +228,8 @@ async function probeAudioTracks(
224228
const safeStderrText = redactProcessOutput(stderrText, sourceUrl);
225229

226230
if (exitCode === 0) {
227-
const trackTypes = stdoutText
228-
.split(/\r?\n/)
229-
.map((value) => value.trim())
230-
.filter(Boolean);
231+
const { streams } = AUDIO_PROBE_SCHEMA.parse(JSON.parse(stdoutText));
232+
const trackTypes = streams.map((stream) => stream.codec_type);
231233
if (!trackTypes.includes("video")) {
232234
throw new Error("No video stream found");
233235
}

0 commit comments

Comments
 (0)