diff --git a/apps/media-server/src/__tests__/lib/audio-levels.integration.test.ts b/apps/media-server/src/__tests__/lib/audio-levels.integration.test.ts index bd88592b2c..aa37373238 100644 --- a/apps/media-server/src/__tests__/lib/audio-levels.integration.test.ts +++ b/apps/media-server/src/__tests__/lib/audio-levels.integration.test.ts @@ -20,6 +20,7 @@ import { withMediaTransfers } from "../../lib/media-transfer"; import { processVideo } from "../../lib/media-video"; import { getActiveDirectVideoProcessCount, + getMaxConcurrentDirectVideoProcesses, tryAcquireDirectVideoProcessSlot, } from "../../lib/video-capacity"; @@ -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 () => { diff --git a/apps/media-server/src/__tests__/lib/video-capacity.test.ts b/apps/media-server/src/__tests__/lib/video-capacity.test.ts new file mode 100644 index 0000000000..eb0e260bf3 --- /dev/null +++ b/apps/media-server/src/__tests__/lib/video-capacity.test.ts @@ -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); + }); +}); diff --git a/apps/media-server/src/lib/video-capacity.ts b/apps/media-server/src/lib/video-capacity.ts index da08f19352..be16b57f08 100644 --- a/apps/media-server/src/lib/video-capacity.ts +++ b/apps/media-server/src/lib/video-capacity.ts @@ -1,3 +1,6 @@ +import os from "node:os"; +import { getContainerCpuLimit } from "./container-cpu"; + export interface VideoProcessSlot { release: () => void; } @@ -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; @@ -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( diff --git a/apps/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 32e16f0f97..1c4aad62f5 100644 --- a/apps/web/content/docs/self-hosting.mdx +++ b/apps/web/content/docs/self-hosting.mdx @@ -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`. With `docker-compose.template.yml`, use `docker compose -f docker-compose.template.yml up -d --force-recreate cap-media-server`. In Coolify, update the environment variables and redeploy the `media-server` service. 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 diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 628af50693..824e4353f5 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -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: diff --git a/docker-compose.template.yml b/docker-compose.template.yml index 1360b3d871..b0f33ad364 100644 --- a/docker-compose.template.yml +++ b/docker-compose.template.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 98badf0267..0760218cb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: