Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
22 changes: 22 additions & 0 deletions apps/media-server/src/__tests__/lib/video-capacity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { getDefaultMaxConcurrentDirectVideoProcesses } from "../../lib/video-capacity";

describe("getDefaultMaxConcurrentDirectVideoProcesses", () => {
test("keeps one conversion on small machines", () => {
expect(getDefaultMaxConcurrentDirectVideoProcesses(1, 0)).toBe(1);
expect(getDefaultMaxConcurrentDirectVideoProcesses(2, 0)).toBe(1);
expect(getDefaultMaxConcurrentDirectVideoProcesses(0.5, 0)).toBe(1);
});

test("uses half the CPUs, capped at four", () => {
expect(getDefaultMaxConcurrentDirectVideoProcesses(4, 0)).toBe(2);
expect(getDefaultMaxConcurrentDirectVideoProcesses(8, 0)).toBe(4);
expect(getDefaultMaxConcurrentDirectVideoProcesses(32, 0)).toBe(4);
});

test("respects both the CPU set and quota", () => {
expect(getDefaultMaxConcurrentDirectVideoProcesses(2, 8)).toBe(1);
expect(getDefaultMaxConcurrentDirectVideoProcesses(8, 2)).toBe(1);
expect(getDefaultMaxConcurrentDirectVideoProcesses(8, 0.5)).toBe(1);
});
});
24 changes: 22 additions & 2 deletions apps/media-server/src/lib/video-capacity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import os from "node:os";
import { getContainerCpuLimit } from "./container-cpu";

export interface VideoProcessSlot {
release: () => void;
}
Expand All @@ -7,7 +10,24 @@ const configuredMaxDirectVideoProcesses =
process.env.MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS ?? "0",
10,
) || 0;
const DEFAULT_MAX_CONCURRENT_DIRECT_VIDEO_PROCESSES = 1;
const MAX_DEFAULT_DIRECT_VIDEO_PROCESSES = 4;

export function getDefaultMaxConcurrentDirectVideoProcesses(
availableCpus: number = os.availableParallelism(),
containerCpuLimit: number = getContainerCpuLimit(),
): number {
const cpuCapacity =
containerCpuLimit > 0
? Math.min(availableCpus, containerCpuLimit)
: availableCpus;
return Math.max(
1,
Math.min(MAX_DEFAULT_DIRECT_VIDEO_PROCESSES, Math.floor(cpuCapacity / 2)),
);
}

const defaultMaxDirectVideoProcesses =
getDefaultMaxConcurrentDirectVideoProcesses();

let activeDirectVideoProcessCount = 0;

Expand All @@ -18,7 +38,7 @@ export function getActiveDirectVideoProcessCount(): number {
export function getMaxConcurrentDirectVideoProcesses(): number {
return configuredMaxDirectVideoProcesses > 0
? configuredMaxDirectVideoProcesses
: DEFAULT_MAX_CONCURRENT_DIRECT_VIDEO_PROCESSES;
: defaultMaxDirectVideoProcesses;
}

export function tryAcquireDirectVideoProcessSlot(
Expand Down
13 changes: 13 additions & 0 deletions apps/web/content/docs/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,19 @@ By default, login links are printed to the server logs. To send emails:
RESEND_FROM_DOMAIN=yourdomain.com
```

## Media Server Tuning

The media server converts uploads in parallel based on the CPUs it can use: half the available CPUs, at least 1 and at most 4. It still turns work away when CPU or memory pressure is high. The CPU quota and assigned CPU set both limit this default. To override it in the bundled Compose deployments, add these values to `.env` (the media-server service forwards them):

```bash
MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS=2 # direct /video/convert requests
MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES=4 # all video jobs
```

Apply changes with `docker compose up -d --force-recreate media-server` (`cap-media-server` for the template and Coolify services). For a custom Compose file, also add these variables to the media-server service’s `environment` mapping; `.env` alone does not pass variables into a container.

On `docker compose stop` the media server stops taking new work and lets active work finish for up to `MEDIA_SERVER_SHUTDOWN_GRACE_MS` (25s in the bundled compose files). It then cancels what is left and sends each cancelled job's webhook: an attempt already in flight can take up to 5s, then up to 3 tries of 5s with 1.5s of backoff (16.5s). That is 46.5s before cleanup, so the compose files set `stop_grace_period: 60s`. If you raise the grace period, raise `stop_grace_period` by the same amount, or Docker kills the container before it finishes.

## Optional Features

### AI Features
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.coolify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,15 @@ services:
media-server:
image: 'ghcr.io/capsoftware/cap-media-server:latest'
restart: unless-stopped
# Shutdown budget: up to 25s drain (MEDIA_SERVER_SHUTDOWN_GRACE_MS), then
# cancel webhooks for aborted jobs (an in-flight attempt finishes first, up
# to 5s, then 3 tries x 5s + 1.5s backoff = 16.5s), then cleanup.
# 25 + 5 + 16.5 = 46.5s, so 60s leaves ~13s for cleanup before SIGKILL.
stop_grace_period: 60s
environment:
MEDIA_SERVER_SHUTDOWN_GRACE_MS: '25000'
MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS: ${MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS:-0}
MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES: ${MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES:-0}
PORT: 3456
MEDIA_SERVER_WEBHOOK_SECRET: '${SERVICE_HEX_32_MEDIASERVER}'
healthcheck:
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ services:
context: ./apps/media-server
dockerfile: Dockerfile.standalone
restart: unless-stopped
# Shutdown budget: up to 25s drain (MEDIA_SERVER_SHUTDOWN_GRACE_MS), then
# cancel webhooks for aborted jobs (an in-flight attempt finishes first, up
# to 5s, then 3 tries x 5s + 1.5s backoff = 16.5s), then cleanup.
# 25 + 5 + 16.5 = 46.5s, so 60s leaves ~13s for cleanup before SIGKILL.
stop_grace_period: 60s
environment:
MEDIA_SERVER_SHUTDOWN_GRACE_MS: "25000"
MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS: ${MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS:-0}
MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES: ${MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES:-0}
PORT: 3456
MEDIA_SERVER_WEBHOOK_SECRET: fe337b52749070bb7b5d2c78cff9948439ea73cbc1869ba39d350e6c24db53b1
ports:
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,15 @@ services:
context: ./apps/media-server
dockerfile: Dockerfile.standalone
restart: unless-stopped
# Shutdown budget: up to 25s drain (MEDIA_SERVER_SHUTDOWN_GRACE_MS), then
# cancel webhooks for aborted jobs (an in-flight attempt finishes first, up
# to 5s, then 3 tries x 5s + 1.5s backoff = 16.5s), then cleanup.
# 25 + 5 + 16.5 = 46.5s, so 60s leaves ~13s for cleanup before SIGKILL.
stop_grace_period: 60s
environment:
MEDIA_SERVER_SHUTDOWN_GRACE_MS: "25000"
MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS: ${MEDIA_SERVER_MAX_CONCURRENT_DIRECT_CONVERSIONS:-0}
MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES: ${MEDIA_SERVER_MAX_CONCURRENT_VIDEO_PROCESSES:-0}
PORT: 3456
MEDIA_SERVER_WEBHOOK_SECRET: ${MEDIA_SERVER_WEBHOOK_SECRET:-fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210}
healthcheck:
Expand Down