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 bd88592b2ca..2e30e72aa2c 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,18 @@ 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(); + 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/web/content/docs/self-hosting.mdx b/apps/web/content/docs/self-hosting.mdx index 32e16f0f975..4ed1f9f653b 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 allows one direct video conversion at a time by default. It still turns work away when CPU or memory pressure is high. To raise the limit after measuring CPU and memory usage on your deployment, add these values to `.env` in the bundled Compose deployments (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 628af506939..824e4353f55 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 1360b3d8715..b0f33ad3645 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 98badf02671..0760218cb1c 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: