Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
649c06c
fix(email): never send from auth@undefined, fall back to logged codes…
07rjain Oct 14, 2026
cec843f
fix(self-host): remove public default secrets and refuse to start wit…
07rjain Oct 14, 2026
4db89f2
feat(self-host): check MEDIA_SERVER_URL reachability at startup and h…
07rjain Oct 14, 2026
0e1a7d1
perf(media-server): size direct conversion concurrency from available…
07rjain Oct 14, 2026
7ca66e6
feat(media-server): drain active jobs on SIGTERM before aborting and …
07rjain Oct 14, 2026
5177077
fix(media-server): block private, metadata and non-http URLs with an …
07rjain Oct 14, 2026
ea9f597
perf(embed): code-split the players and upload tracker and drop the d…
07rjain Oct 14, 2026
7d387c6
docs(self-host): explain why to pin cap-web and media-server images
07rjain Oct 14, 2026
855c51f
fix(media-server): keep thumbnail defaults when a request omits width…
07rjain Oct 14, 2026
f194247
fix(media-server): close the URL and drain gaps from review
07rjain Oct 14, 2026
b7b4d59
fix(media-server): keep redirects and probe waits inside the URL policy
07rjain Oct 14, 2026
7d40c0a
fix(media-server): download thumbnails before ffmpeg sees the URL
07rjain Oct 14, 2026
49bc734
fix(media-server): decode remote recordings from a checked local file
07rjain Oct 14, 2026
e7115db
merge: update core-improvements onto main
07rjain Oct 18, 2026
a1aacbe
fix(media-server): keep DASH bases and failed audio downloads from st…
07rjain Oct 18, 2026
a25e247
fix(media-server): keep DASH Location out of media bases and resolve …
07rjain Oct 23, 2026
82381eb
merge: update core-improvements onto main
07rjain Oct 23, 2026
dfab422
fix(media-server): send parallel ranged downloads through the URL policy
07rjain Oct 23, 2026
bad4a43
chore(self-host): turn on main's shutdown drain in the compose files
07rjain Oct 23, 2026
177ed45
docs(self-host): explain that upgrades stop on the old example secret…
07rjain Oct 23, 2026
8284b71
test(media-server): serve route fixtures over http and wait for polic…
07rjain Oct 23, 2026
a550340
fix(media-server): fix the type errors the URL policy changes added
07rjain Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.self-hosting.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copy to .env next to docker-compose.yml and fill in every value.
# Generate each secret with: openssl rand -hex 32
# cap-web and the media server refuse to start when these are missing or left at a known placeholder.
NEXTAUTH_SECRET=
DATABASE_ENCRYPTION_KEY=
MEDIA_SERVER_WEBHOOK_SECRET=

# Optional: database and storage passwords (internal-only defaults are used when unset)
MYSQL_PASSWORD=
MYSQL_ROOT_PASSWORD=
MINIO_ROOT_USER=cap-admin
MINIO_ROOT_PASSWORD=

# Public URLs
CAP_URL=http://localhost:3000
S3_PUBLIC_URL=http://localhost:9000

# Optional: email. Without both values, login codes are printed to the cap-web logs.
RESEND_API_KEY=
RESEND_FROM_DOMAIN=
2 changes: 2 additions & 0 deletions apps/media-server/bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./src/__tests__/setup.ts"]
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { withMediaTransfers } from "../../lib/media-transfer";
import { processVideo } from "../../lib/media-video";
import {
getActiveDirectVideoProcessCount,
getMaxConcurrentDirectVideoProcesses,
tryAcquireDirectVideoProcessSlot,
} from "../../lib/video-capacity";

Expand Down Expand Up @@ -238,15 +239,19 @@ describe("local audio levels integration", () => {
expect(objects.publications()).toBe(0);
});
test("leaves an occupied conversion slot available to its current owner", async () => {
const slot = tryAcquireDirectVideoProcessSlot(() => true);
expect(slot).not.toBeNull();
// The default slot count follows the CPUs, so occupy every slot.
const max = getMaxConcurrentDirectVideoProcesses();
const slots = Array.from({ length: max }, () =>
tryAcquireDirectVideoProcessSlot(() => true),
);
expect(slots.every((slot) => slot !== null)).toBe(true);
try {
const objects = storage();
expect(await call()).toEqual({ status: "unchanged", reason: "capacity" });
expect(objects.preparations()).toBe(0);
expect(getActiveDirectVideoProcessCount()).toBe(1);
expect(getActiveDirectVideoProcessCount()).toBe(max);
} finally {
slot?.release();
for (const slot of slots) slot?.release();
}
});
test("cancels optional work on shutdown and releases its processing slot", async () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/media-server/src/__tests__/lib/job-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ describe("durable recording workers", () => {
const sending = sendWebhook(job);
updateJob(job.jobId, { phase: "complete", progress: 100 });
expect(sendWebhook(job)).toBe(sending);
// The URL policy check runs before fetch, so wait for the request to start.
while (!release) await Bun.sleep(1);
const started = performance.now();
release?.(new Response(null, { status: 503 }));
release(new Response(null, { status: 503 }));
await sending;
expect(performance.now() - started).toBeLessThan(400);
expect(maximumInFlight).toBe(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe("mediaAudio integration tests", () => {
try {
await expect(
checkHasAudioTrack(`${server.url}video.mp4`),
).rejects.toThrow("FFprobe exited");
).rejects.toThrow("Failed to fetch media: 503");
await waitForAudioOperations(beforeCount);
} finally {
await server.stop(true);
Expand Down Expand Up @@ -233,6 +233,24 @@ describe("mediaAudio integration tests", () => {
await waitForAudioOperations(beforeCount);
});

test("releases the operation when a remote download fails", async () => {
const beforeCount = getActiveProcessCount();
const server = Bun.serve({
port: 0,
fetch: () => new Response("Missing", { status: 404 }),
});

try {
const { stream } = extractAudioStream(`${server.url}clip.mp4`);
await expect(readStream(stream)).rejects.toThrow(
"Failed to fetch media: 404",
);
await waitForAudioOperations(beforeCount);
} finally {
await server.stop(true);
}
});

test("cleans up when a real stream is cancelled", async () => {
const beforeCount = getActiveProcessCount();
const { stream } = extractAudioStream(TEST_VIDEO_WITH_AUDIO);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import { mkdtempSync, rmSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import os, { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import type appType from "../../app";
import * as audioLevels from "../../lib/audio-levels";
import * as containerCpu from "../../lib/container-cpu";
Expand Down Expand Up @@ -66,10 +65,6 @@ let transientFixtureFailures = 0;
let permanentFixtureFailures = 0;
let slowFixtureCancellations = 0;

function fileUrl(path: string) {
return pathToFileURL(path).toString();
}

function fixtureUrl(name = "test-with-audio.mp4") {
return `${baseUrl}/fixtures/${name}`;
}
Expand Down Expand Up @@ -1000,12 +995,12 @@ describe("media routes real-world integration tests", () => {
test("checks real audio tracks through the route stack", async () => {
const withAudioResponse = await app.fetch(
mediaPostRequest("/audio/check", {
videoUrl: fileUrl(TEST_VIDEO_WITH_AUDIO),
videoUrl: fixtureUrl("test-with-audio.mp4"),
}),
);
const noAudioResponse = await app.fetch(
mediaPostRequest("/audio/check", {
videoUrl: fileUrl(TEST_VIDEO_NO_AUDIO),
videoUrl: fixtureUrl("test-no-audio.mp4"),
}),
);

Expand All @@ -1021,7 +1016,7 @@ describe("media routes real-world integration tests", () => {
test("extracts real audio through the non-streaming route", async () => {
const response = await app.fetch(
mediaPostRequest("/audio/extract", {
videoUrl: fileUrl(TEST_VIDEO_WITH_AUDIO),
videoUrl: fixtureUrl("test-with-audio.mp4"),
stream: false,
}),
);
Expand All @@ -1034,7 +1029,7 @@ describe("media routes real-world integration tests", () => {
test("probes and thumbnails a real video through the route stack", async () => {
const probeResponse = await app.fetch(
mediaPostRequest("/video/probe", {
videoUrl: fileUrl(TEST_VIDEO_WITH_AUDIO),
videoUrl: fixtureUrl("test-with-audio.mp4"),
}),
);
expect(probeResponse.status).toBe(200);
Expand All @@ -1046,7 +1041,7 @@ describe("media routes real-world integration tests", () => {

const thumbnailResponse = await app.fetch(
mediaPostRequest("/video/thumbnail", {
videoUrl: fileUrl(TEST_VIDEO_WITH_AUDIO),
videoUrl: fixtureUrl("test-with-audio.mp4"),
timestamp: 0.2,
width: 160,
height: 120,
Expand Down
Loading