Skip to content

Commit 84dfdf9

Browse files
committed
Add Bun-based media server for audio extraction
1 parent 32b036d commit 84dfdf9

File tree

20 files changed

+923
-53
lines changed

20 files changed

+923
-53
lines changed

apps/media-server/Dockerfile

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
FROM oven/bun:1-alpine
2+
3+
RUN apk add --no-cache ffmpeg
4+
5+
WORKDIR /app
6+
7+
COPY package.json ./
8+
RUN bun install --production
9+
10+
COPY src ./src
11+
12+
ENV PORT=3456
13+
EXPOSE 3456
14+
15+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
16+
CMD wget -qO- http://localhost:${PORT}/health || exit 1
17+
18+
CMD ["bun", "run", "src/index.ts"]

apps/media-server/package.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "@cap/media-server",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev:local": "bun --watch src/index.ts",
7+
"start": "bun src/index.ts",
8+
"build": "echo 'No build step needed for Bun'",
9+
"test": "bun test",
10+
"test:watch": "bun test --watch"
11+
},
12+
"dependencies": {
13+
"hono": "^4.7.1",
14+
"zod": "^3.24.2"
15+
},
16+
"devDependencies": {
17+
"@types/bun": "latest"
18+
}
19+
}

apps/media-server/railway.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[build]
2+
dockerfilePath = "Dockerfile"
3+
4+
[deploy]
5+
healthcheckPath = "/health"
6+
healthcheckTimeout = 10
7+
restartPolicyType = "ON_FAILURE"
8+
restartPolicyMaxRetries = 3
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, test } from "bun:test";
2+
import app from "../app";
3+
4+
describe("GET /", () => {
5+
test("returns server metadata and endpoints", async () => {
6+
const response = await app.fetch(new Request("http://localhost/"));
7+
8+
expect(response.status).toBe(200);
9+
const data = await response.json();
10+
expect(data).toEqual({
11+
name: "@cap/media-server",
12+
version: "1.0.0",
13+
endpoints: ["/health", "/audio/check", "/audio/extract"],
14+
});
15+
});
16+
});
17+
18+
describe("unknown routes", () => {
19+
test("returns 404 for unknown GET routes", async () => {
20+
const response = await app.fetch(
21+
new Request("http://localhost/unknown-route"),
22+
);
23+
24+
expect(response.status).toBe(404);
25+
});
26+
});
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { beforeEach, describe, expect, mock, test } from "bun:test";
2+
import app from "../../app";
3+
import * as ffmpeg from "../../lib/ffmpeg";
4+
5+
describe("POST /audio/check", () => {
6+
beforeEach(() => {
7+
mock.restore();
8+
});
9+
10+
test("returns 400 for missing videoUrl", async () => {
11+
const response = await app.fetch(
12+
new Request("http://localhost/audio/check", {
13+
method: "POST",
14+
headers: { "Content-Type": "application/json" },
15+
body: JSON.stringify({}),
16+
}),
17+
);
18+
19+
expect(response.status).toBe(400);
20+
const data = await response.json();
21+
expect(data.code).toBe("INVALID_REQUEST");
22+
});
23+
24+
test("returns 400 for invalid URL format", async () => {
25+
const response = await app.fetch(
26+
new Request("http://localhost/audio/check", {
27+
method: "POST",
28+
headers: { "Content-Type": "application/json" },
29+
body: JSON.stringify({ videoUrl: "not-a-valid-url" }),
30+
}),
31+
);
32+
33+
expect(response.status).toBe(400);
34+
const data = await response.json();
35+
expect(data.code).toBe("INVALID_REQUEST");
36+
});
37+
38+
test("returns hasAudio true when video has audio track", async () => {
39+
mock.module("../../lib/ffmpeg", () => ({
40+
checkHasAudioTrack: async () => true,
41+
extractAudio: ffmpeg.extractAudio,
42+
}));
43+
44+
const { default: appWithMock } = await import("../../app");
45+
46+
const response = await appWithMock.fetch(
47+
new Request("http://localhost/audio/check", {
48+
method: "POST",
49+
headers: { "Content-Type": "application/json" },
50+
body: JSON.stringify({ videoUrl: "https://example.com/video.mp4" }),
51+
}),
52+
);
53+
54+
expect(response.status).toBe(200);
55+
const data = await response.json();
56+
expect(data).toEqual({ hasAudio: true });
57+
});
58+
59+
test("returns hasAudio false when video has no audio track", async () => {
60+
mock.module("../../lib/ffmpeg", () => ({
61+
checkHasAudioTrack: async () => false,
62+
extractAudio: ffmpeg.extractAudio,
63+
}));
64+
65+
const { default: appWithMock } = await import("../../app");
66+
67+
const response = await appWithMock.fetch(
68+
new Request("http://localhost/audio/check", {
69+
method: "POST",
70+
headers: { "Content-Type": "application/json" },
71+
body: JSON.stringify({ videoUrl: "https://example.com/video.mp4" }),
72+
}),
73+
);
74+
75+
expect(response.status).toBe(200);
76+
const data = await response.json();
77+
expect(data).toEqual({ hasAudio: false });
78+
});
79+
});
80+
81+
describe("POST /audio/extract", () => {
82+
beforeEach(() => {
83+
mock.restore();
84+
});
85+
86+
test("returns 400 for missing videoUrl", async () => {
87+
const response = await app.fetch(
88+
new Request("http://localhost/audio/extract", {
89+
method: "POST",
90+
headers: { "Content-Type": "application/json" },
91+
body: JSON.stringify({}),
92+
}),
93+
);
94+
95+
expect(response.status).toBe(400);
96+
const data = await response.json();
97+
expect(data.code).toBe("INVALID_REQUEST");
98+
});
99+
100+
test("returns 400 for invalid URL format", async () => {
101+
const response = await app.fetch(
102+
new Request("http://localhost/audio/extract", {
103+
method: "POST",
104+
headers: { "Content-Type": "application/json" },
105+
body: JSON.stringify({ videoUrl: "invalid-url" }),
106+
}),
107+
);
108+
109+
expect(response.status).toBe(400);
110+
const data = await response.json();
111+
expect(data.code).toBe("INVALID_REQUEST");
112+
});
113+
114+
test("returns 422 when video has no audio track", async () => {
115+
mock.module("../../lib/ffmpeg", () => ({
116+
checkHasAudioTrack: async () => false,
117+
extractAudio: ffmpeg.extractAudio,
118+
}));
119+
120+
const { default: appWithMock } = await import("../../app");
121+
122+
const response = await appWithMock.fetch(
123+
new Request("http://localhost/audio/extract", {
124+
method: "POST",
125+
headers: { "Content-Type": "application/json" },
126+
body: JSON.stringify({ videoUrl: "https://example.com/video.mp4" }),
127+
}),
128+
);
129+
130+
expect(response.status).toBe(422);
131+
const data = await response.json();
132+
expect(data.code).toBe("NO_AUDIO_TRACK");
133+
});
134+
135+
test("returns audio data when extraction succeeds", async () => {
136+
const mockAudioData = new Uint8Array([0x00, 0x00, 0x00, 0x1c, 0x66, 0x74]);
137+
138+
mock.module("../../lib/ffmpeg", () => ({
139+
checkHasAudioTrack: async () => true,
140+
extractAudio: async () => mockAudioData,
141+
}));
142+
143+
const { default: appWithMock } = await import("../../app");
144+
145+
const response = await appWithMock.fetch(
146+
new Request("http://localhost/audio/extract", {
147+
method: "POST",
148+
headers: { "Content-Type": "application/json" },
149+
body: JSON.stringify({ videoUrl: "https://example.com/video.mp4" }),
150+
}),
151+
);
152+
153+
expect(response.status).toBe(200);
154+
expect(response.headers.get("Content-Type")).toBe("audio/mp4");
155+
expect(response.headers.get("Content-Length")).toBe(
156+
mockAudioData.length.toString(),
157+
);
158+
159+
const buffer = await response.arrayBuffer();
160+
expect(new Uint8Array(buffer)).toEqual(mockAudioData);
161+
});
162+
163+
test("returns 500 when ffmpeg extraction fails", async () => {
164+
mock.module("../../lib/ffmpeg", () => ({
165+
checkHasAudioTrack: async () => true,
166+
extractAudio: async () => {
167+
throw new Error("FFmpeg failed");
168+
},
169+
}));
170+
171+
const { default: appWithMock } = await import("../../app");
172+
173+
const response = await appWithMock.fetch(
174+
new Request("http://localhost/audio/extract", {
175+
method: "POST",
176+
headers: { "Content-Type": "application/json" },
177+
body: JSON.stringify({ videoUrl: "https://example.com/video.mp4" }),
178+
}),
179+
);
180+
181+
expect(response.status).toBe(500);
182+
const data = await response.json();
183+
expect(data.code).toBe("FFMPEG_ERROR");
184+
expect(data.details).toContain("FFmpeg failed");
185+
});
186+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, test } from "bun:test";
2+
import app from "../../app";
3+
4+
describe("GET /health", () => {
5+
test("returns status ok", async () => {
6+
const response = await app.fetch(new Request("http://localhost/health"));
7+
8+
expect(response.status).toBe(200);
9+
const data = await response.json();
10+
expect(data).toEqual({ status: "ok" });
11+
});
12+
});

apps/media-server/src/app.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Hono } from "hono";
2+
import { logger } from "hono/logger";
3+
import audio from "./routes/audio";
4+
import health from "./routes/health";
5+
6+
const app = new Hono();
7+
8+
app.use("*", logger());
9+
10+
app.route("/health", health);
11+
app.route("/audio", audio);
12+
13+
app.get("/", (c) => {
14+
return c.json({
15+
name: "@cap/media-server",
16+
version: "1.0.0",
17+
endpoints: ["/health", "/audio/check", "/audio/extract"],
18+
});
19+
});
20+
21+
export default app;

apps/media-server/src/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import app from "./app";
2+
3+
const port = Number(process.env.PORT) || 3456;
4+
5+
console.log(`[media-server] Starting on port ${port}`);
6+
7+
const shutdown = () => {
8+
console.log("[media-server] Shutting down...");
9+
process.exit(0);
10+
};
11+
12+
process.on("SIGINT", shutdown);
13+
process.on("SIGTERM", shutdown);
14+
process.on("SIGHUP", shutdown);
15+
16+
export default {
17+
port,
18+
fetch: app.fetch,
19+
};

0 commit comments

Comments
 (0)