Skip to content

Commit fc22bc8

Browse files
committed
Switch audio extraction to MP3 and improve health checks
1 parent ce0cfef commit fc22bc8

File tree

12 files changed

+116
-110
lines changed

12 files changed

+116
-110
lines changed

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ describe("ffmpeg integration tests", () => {
2626
expect(audioData).toBeInstanceOf(Uint8Array);
2727
expect(audioData.length).toBeGreaterThan(0);
2828

29-
const hasFtypBox =
30-
audioData[4] === 0x66 &&
31-
audioData[5] === 0x74 &&
32-
audioData[6] === 0x79 &&
33-
audioData[7] === 0x70;
34-
expect(hasFtypBox).toBe(true);
29+
const hasId3Tag =
30+
audioData[0] === 0x49 &&
31+
audioData[1] === 0x44 &&
32+
audioData[2] === 0x33;
33+
const hasMpegSync =
34+
audioData[0] === 0xff && (audioData[1] & 0xe0) === 0xe0;
35+
expect(hasId3Tag || hasMpegSync).toBe(true);
3536
});
3637

3738
test("throws error for video without audio track", async () => {

apps/media-server/src/__tests__/routes/audio.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("POST /audio/extract", () => {
151151
);
152152

153153
expect(response.status).toBe(200);
154-
expect(response.headers.get("Content-Type")).toBe("audio/mp4");
154+
expect(response.headers.get("Content-Type")).toBe("audio/mpeg");
155155
expect(response.headers.get("Content-Length")).toBe(
156156
mockAudioData.length.toString(),
157157
);

apps/media-server/src/__tests__/routes/health.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { describe, expect, test } from "bun:test";
22
import app from "../../app";
33

44
describe("GET /health", () => {
5-
test("returns status ok", async () => {
5+
test("returns status ok with ffmpeg info", async () => {
66
const response = await app.fetch(new Request("http://localhost/health"));
77

88
expect(response.status).toBe(200);
99
const data = await response.json();
10-
expect(data).toEqual({ status: "ok" });
10+
expect(data.status).toBe("ok");
11+
expect(data.ffmpeg).toBeDefined();
12+
expect(data.ffmpeg.available).toBe(true);
13+
expect(typeof data.ffmpeg.version).toBe("string");
1114
});
1215
});

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

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { spawn } from "bun";
22

33
export interface AudioExtractionOptions {
4-
format?: "m4a";
5-
codec?: "aac";
4+
format?: "mp3";
5+
codec?: "libmp3lame";
66
bitrate?: string;
77
}
88

99
const DEFAULT_OPTIONS: Required<AudioExtractionOptions> = {
10-
format: "m4a",
11-
codec: "aac",
10+
format: "mp3",
11+
codec: "libmp3lame",
1212
bitrate: "128k",
1313
};
1414

@@ -22,9 +22,7 @@ export async function checkHasAudioTrack(videoUrl: string): Promise<boolean> {
2222
const stderrText = await new Response(proc.stderr).text();
2323
await proc.exited;
2424

25-
const hasAudio = /Stream #\d+:\d+.*Audio:/.test(stderrText);
26-
console.log(`[ffmpeg] Video has audio track: ${hasAudio}`);
27-
return hasAudio;
25+
return /Stream #\d+:\d+.*Audio:/.test(stderrText);
2826
}
2927

3028
export async function extractAudio(
@@ -43,14 +41,10 @@ export async function extractAudio(
4341
"-b:a",
4442
opts.bitrate,
4543
"-f",
46-
"ipod",
47-
"-movflags",
48-
"+frag_keyframe+empty_moov",
44+
"mp3",
4945
"pipe:1",
5046
];
5147

52-
console.log(`[ffmpeg] Starting audio extraction: ${ffmpegArgs.join(" ")}`);
53-
5448
const proc = spawn({
5549
cmd: ffmpegArgs,
5650
stdout: "pipe",
@@ -64,13 +58,9 @@ export async function extractAudio(
6458
]);
6559

6660
if (exitCode !== 0) {
67-
console.error(`[ffmpeg] Audio extraction failed:\n${stderrText}`);
68-
throw new Error(`FFmpeg exited with code ${exitCode}`);
61+
throw new Error(`FFmpeg exited with code ${exitCode}: ${stderrText}`);
6962
}
7063

71-
console.log(
72-
`[ffmpeg] Audio extraction complete, size: ${stdout.byteLength} bytes`,
73-
);
7464
return new Uint8Array(stdout);
7565
}
7666

@@ -90,27 +80,15 @@ export async function extractAudioStream(
9080
"-b:a",
9181
opts.bitrate,
9282
"-f",
93-
"ipod",
94-
"-movflags",
95-
"+frag_keyframe+empty_moov",
83+
"mp3",
9684
"pipe:1",
9785
];
9886

99-
console.log(
100-
`[ffmpeg] Starting audio extraction (stream): ${ffmpegArgs.join(" ")}`,
101-
);
102-
10387
const proc = spawn({
10488
cmd: ffmpegArgs,
10589
stdout: "pipe",
10690
stderr: "pipe",
10791
});
10892

109-
proc.exited.then((code) => {
110-
if (code !== 0) {
111-
console.error(`[ffmpeg] Stream extraction failed with code ${code}`);
112-
}
113-
});
114-
11593
return proc.stdout as ReadableStream<Uint8Array>;
11694
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ audio.post("/extract", async (c) => {
6767

6868
return new Response(Buffer.from(audioData), {
6969
headers: {
70-
"Content-Type": "audio/mp4",
70+
"Content-Type": "audio/mpeg",
7171
"Content-Length": audioData.length.toString(),
7272
},
7373
});
Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
1+
import { spawn } from "bun";
12
import { Hono } from "hono";
23

34
const health = new Hono();
45

5-
health.get("/", (c) => {
6-
return c.json({ status: "ok" });
6+
health.get("/", async (c) => {
7+
let ffmpegVersion = "unknown";
8+
let ffmpegAvailable = false;
9+
10+
try {
11+
const proc = spawn({
12+
cmd: ["ffmpeg", "-version"],
13+
stdout: "pipe",
14+
stderr: "pipe",
15+
});
16+
17+
const stdout = await new Response(proc.stdout).text();
18+
const exitCode = await proc.exited;
19+
20+
if (exitCode === 0) {
21+
ffmpegAvailable = true;
22+
const versionMatch = stdout.match(/ffmpeg version (\S+)/);
23+
if (versionMatch) {
24+
ffmpegVersion = versionMatch[1];
25+
}
26+
}
27+
} catch {}
28+
29+
return c.json({
30+
status: ffmpegAvailable ? "ok" : "degraded",
31+
ffmpeg: {
32+
available: ffmpegAvailable,
33+
version: ffmpegVersion,
34+
},
35+
});
736
});
837

938
export default health;

apps/web/__tests__/unit/audio-extract.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,12 @@ describe("audio-extract", () => {
132132
expect(args).toContain("https://example.com/video.mp4");
133133
expect(args).toContain("-vn");
134134
expect(args).toContain("-acodec");
135-
expect(args).toContain("aac");
135+
expect(args).toContain("libmp3lame");
136136
expect(args).toContain("-b:a");
137137
expect(args).toContain("128k");
138138
});
139139

140-
it("returns audio/mp4 mime type", async () => {
140+
it("returns audio/mpeg mime type", async () => {
141141
const { extractAudioFromUrl } = await import("@/lib/audio-extract");
142142

143143
const resultPromise = extractAudioFromUrl(
@@ -149,10 +149,10 @@ describe("audio-extract", () => {
149149
}, 10);
150150

151151
const result = await resultPromise;
152-
expect(result.mimeType).toBe("audio/mp4");
152+
expect(result.mimeType).toBe("audio/mpeg");
153153
});
154154

155-
it("generates .m4a file in temp directory", async () => {
155+
it("generates .mp3 file in temp directory", async () => {
156156
const { extractAudioFromUrl } = await import("@/lib/audio-extract");
157157

158158
const resultPromise = extractAudioFromUrl(
@@ -165,7 +165,7 @@ describe("audio-extract", () => {
165165

166166
const result = await resultPromise;
167167
expect(result.filePath).toContain("audio-");
168-
expect(result.filePath).toContain(".m4a");
168+
expect(result.filePath).toContain(".mp3");
169169
});
170170

171171
it("provides cleanup function", async () => {

apps/web/__tests__/unit/media-client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ describe("media-client", () => {
183183

184184
await expect(
185185
extractAudioViaMediaServer("https://example.com/video.mp4"),
186-
).rejects.toThrow("Failed to extract audio");
186+
).rejects.toThrow("FFmpeg process exited with code 1");
187187
});
188188
});
189189
});

apps/web/lib/audio-extract.ts

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ function getFfmpegPath(): string {
2525

2626
for (const path of candidatePaths) {
2727
if (existsSync(path)) {
28-
console.log(`[audio-extract] Found FFmpeg at: ${path}`);
2928
cachedFfmpegPath = path;
3029
return path;
3130
}
@@ -46,31 +45,23 @@ export async function extractAudioFromUrl(
4645
videoUrl: string,
4746
): Promise<AudioExtractionResult> {
4847
const ffmpeg = getFfmpegPath();
49-
const outputPath = join(tmpdir(), `audio-${randomUUID()}.m4a`);
48+
const outputPath = join(tmpdir(), `audio-${randomUUID()}.mp3`);
5049

5150
const ffmpegArgs = [
5251
"-i",
5352
videoUrl,
5453
"-vn",
5554
"-acodec",
56-
"aac",
55+
"libmp3lame",
5756
"-b:a",
5857
"128k",
5958
"-f",
60-
"ipod",
61-
"-movflags",
62-
"+faststart",
59+
"mp3",
6360
"-y",
6461
outputPath,
6562
];
6663

6764
return new Promise((resolve, reject) => {
68-
console.log(
69-
"[audio-extract] FFmpeg started:",
70-
ffmpeg,
71-
ffmpegArgs.join(" "),
72-
);
73-
7465
const proc = spawn(ffmpeg, ffmpegArgs, { stdio: ["pipe", "pipe", "pipe"] });
7566

7667
let stderr = "";
@@ -80,28 +71,24 @@ export async function extractAudioFromUrl(
8071
});
8172

8273
proc.on("error", (err: Error) => {
83-
console.error("[audio-extract] FFmpeg error:", err);
8474
fs.unlink(outputPath).catch(() => {});
8575
reject(new Error(`Audio extraction failed: ${err.message}`));
8676
});
8777

8878
proc.on("close", (code: number | null) => {
8979
if (code === 0) {
90-
console.log("[audio-extract] Audio extraction complete");
9180
resolve({
9281
filePath: outputPath,
93-
mimeType: "audio/mp4",
82+
mimeType: "audio/mpeg",
9483
cleanup: async () => {
9584
try {
9685
await fs.unlink(outputPath);
97-
console.log("[audio-extract] Cleaned up temp file:", outputPath);
9886
} catch {}
9987
},
10088
});
10189
} else {
102-
console.error("[audio-extract] FFmpeg stderr:", stderr);
10390
fs.unlink(outputPath).catch(() => {});
104-
reject(new Error(`Audio extraction failed with code ${code}`));
91+
reject(new Error(`Audio extraction failed with code ${code}: ${stderr}`));
10592
}
10693
});
10794
});
@@ -114,13 +101,11 @@ export async function extractAudioToBuffer(videoUrl: string): Promise<Buffer> {
114101
videoUrl,
115102
"-vn",
116103
"-acodec",
117-
"aac",
104+
"libmp3lame",
118105
"-b:a",
119106
"128k",
120107
"-f",
121-
"ipod",
122-
"-movflags",
123-
"+frag_keyframe+empty_moov",
108+
"mp3",
124109
"-pipe:1",
125110
];
126111

@@ -139,17 +124,14 @@ export async function extractAudioToBuffer(videoUrl: string): Promise<Buffer> {
139124
});
140125

141126
proc.on("error", (err: Error) => {
142-
console.error("[audio-extract] FFmpeg error:", err);
143127
reject(new Error(`Audio extraction failed: ${err.message}`));
144128
});
145129

146130
proc.on("close", (code: number | null) => {
147131
if (code === 0) {
148-
console.log("[audio-extract] Audio extraction to buffer complete");
149132
resolve(Buffer.concat(chunks));
150133
} else {
151-
console.error("[audio-extract] FFmpeg stderr:", stderr);
152-
reject(new Error(`Audio extraction failed with code ${code}`));
134+
reject(new Error(`Audio extraction failed with code ${code}: ${stderr}`));
153135
}
154136
});
155137
});
@@ -159,8 +141,7 @@ export async function checkHasAudioTrack(videoUrl: string): Promise<boolean> {
159141
let ffmpeg: string;
160142
try {
161143
ffmpeg = getFfmpegPath();
162-
} catch (err) {
163-
console.error("[audio-extract] FFmpeg binary not found:", err);
144+
} catch {
164145
return false;
165146
}
166147
const ffmpegArgs = ["-i", videoUrl, "-hide_banner"];
@@ -176,15 +157,12 @@ export async function checkHasAudioTrack(videoUrl: string): Promise<boolean> {
176157
stderr += data.toString();
177158
});
178159

179-
proc.on("error", (err: Error) => {
180-
console.error("[audio-extract] FFmpeg error:", err);
160+
proc.on("error", () => {
181161
resolve(false);
182162
});
183163

184164
proc.on("close", () => {
185-
const hasAudio = /Stream #\d+:\d+.*Audio:/.test(stderr);
186-
console.log(`[audio-extract] Video has audio track: ${hasAudio}`);
187-
resolve(hasAudio);
165+
resolve(/Stream #\d+:\d+.*Audio:/.test(stderr));
188166
});
189167
});
190168
}

0 commit comments

Comments
 (0)