From e6ceb01737297d5a4d2d64448bd43efc0ef472a7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:46:11 -0500 Subject: [PATCH 1/4] fix(media-server): accept older video and audio codecs in imports again The codec allowlist refused MPEG-1/2, Windows Media, early MPEG-4, Flash video, H.263 and Theora, and MP2, WMA, AMR and ADPCM audio. Regular Cap imports of these in Matroska, MP4, MOV and 3GP transcoded before the list existed. Their FFmpeg decoders are mature, so they are allowed again. Cinepak, DV, Indeo and raw video stay refused. --- .../lib/legacy-codecs.integration.test.ts | 219 ++++++++++++++++++ .../__tests__/lib/media-input-policy.test.ts | 31 ++- .../lib/media-probe-hardening.test.ts | 4 +- .../src/lib/media-input-policy.ts | 41 ++++ 4 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts diff --git a/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts b/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts new file mode 100644 index 0000000000..87594a3e0d --- /dev/null +++ b/apps/media-server/src/__tests__/lib/legacy-codecs.integration.test.ts @@ -0,0 +1,219 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { probeVideoFile } from "../../lib/media-probe"; +import { stopProbeWorkers } from "../../lib/media-probe-pool"; +import { spawn } from "../../lib/media-spawn"; + +// Older codecs a regular Cap import can carry, in the containers the probe +// reads (Matroska, MP4 and 3GP). Each fixture is made with the local FFmpeg, +// then probed and transcoded to H.264 and AAC through the same hardened spawn +// the server uses, so the codec and decoder lists are what decides. A family +// whose encoder this FFmpeg lacks is skipped here. +type Fixture = { + name: string; + file: string; + video: string[]; + audio: string[] | null; + encoders: string[]; +}; + +const FIXTURES: Fixture[] = [ + { + name: "MPEG-1 video with MPEG audio layer 2", + file: "mpeg1.mkv", + video: ["-c:v", "mpeg1video"], + audio: ["-c:a", "mp2"], + encoders: ["mpeg1video", "mp2"], + }, + { + name: "MPEG-2 video with MPEG audio layer 2", + file: "mpeg2.mkv", + video: ["-c:v", "mpeg2video"], + audio: ["-c:a", "mp2"], + encoders: ["mpeg2video", "mp2"], + }, + { + name: "Windows Media video 7 with Windows Media audio 1", + file: "wmv1.mkv", + video: ["-c:v", "wmv1"], + audio: ["-c:a", "wmav1"], + encoders: ["wmv1", "wmav1"], + }, + { + name: "Windows Media video 8 with Windows Media audio 2", + file: "wmv2.mkv", + video: ["-c:v", "wmv2"], + audio: ["-c:a", "wmav2"], + encoders: ["wmv2", "wmav2"], + }, + { + name: "early MPEG-4 (DivX 3) with Microsoft ADPCM", + file: "msmpeg4v3.mkv", + video: ["-c:v", "msmpeg4"], + audio: ["-c:a", "adpcm_ms"], + encoders: ["msmpeg4", "adpcm_ms"], + }, + { + name: "early MPEG-4 version 2 with IMA ADPCM", + file: "msmpeg4v2.mkv", + video: ["-c:v", "msmpeg4v2"], + audio: ["-c:a", "adpcm_ima_wav"], + encoders: ["msmpeg4v2", "adpcm_ima_wav"], + }, + { + name: "Flash video", + file: "flv1.mkv", + video: ["-c:v", "flv"], + audio: ["-c:a", "aac"], + encoders: ["flv", "aac"], + }, + { + name: "H.263 from a phone", + file: "phone.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "aac", "-ar", "8000", "-ac", "1"], + encoders: ["h263", "aac"], + }, + { + name: "H.263 with AMR narrowband audio", + file: "amrnb.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "libopencore_amrnb", "-ar", "8000", "-ac", "1"], + encoders: ["h263", "libopencore_amrnb"], + }, + { + name: "H.263 with AMR wideband audio", + file: "amrwb.3gp", + video: ["-c:v", "h263"], + audio: ["-c:a", "libvo_amrwbenc", "-ar", "16000", "-ac", "1"], + encoders: ["h263", "libvo_amrwbenc"], + }, + { + name: "Theora with Vorbis", + file: "theora.mkv", + video: ["-c:v", "libtheora"], + audio: ["-c:a", "libvorbis"], + encoders: ["libtheora", "libvorbis"], + }, + { + name: "H.264 with MPEG audio layer 2", + file: "h264-mp2.mkv", + video: ["-c:v", "libx264"], + audio: ["-c:a", "mp2"], + encoders: ["libx264", "mp2"], + }, + { + name: "H.264 with Windows Media audio 2", + file: "h264-wma.mkv", + video: ["-c:v", "libx264"], + audio: ["-c:a", "wmav2"], + encoders: ["libx264", "wmav2"], + }, +]; + +let dir = ""; +// Read when the file loads: skipIf decides before beforeAll runs. +const encoders = new Set( + Bun.spawnSync(["ffmpeg", "-hide_banner", "-encoders"]) + .stdout.toString() + .split("\n") + .map((line) => line.trim().split(/\s+/)[1] ?? ""), +); + +async function run(cmd: string[]): Promise<{ code: number; stderr: string }> { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { code, stderr: stderr || stdout }; +} + +const available = (fixture: Fixture) => + fixture.encoders.every((name) => encoders.has(name)); + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), "legacy-codecs-")); + for (const fixture of FIXTURES) { + if (!available(fixture)) continue; + const made = await run([ + "ffmpeg", + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc=d=1:s=176x144:r=25", + ...(fixture.audio ? ["-f", "lavfi", "-i", "sine=d=1:r=44100"] : []), + ...fixture.video, + ...(fixture.audio ?? []), + join(dir, fixture.file), + ]); + if (made.code !== 0) + throw new Error(`could not make ${fixture.file}: ${made.stderr}`); + } +}, 120_000); + +afterAll(async () => { + stopProbeWorkers(); + await rm(dir, { recursive: true, force: true }); +}); + +describe("older formats a regular import can carry", () => { + for (const fixture of FIXTURES) { + test.skipIf(!available(fixture))( + `${fixture.name} probes and transcodes`, + async () => { + const input = join(dir, fixture.file); + const metadata = await probeVideoFile(input); + expect(metadata.width).toBe(176); + expect(metadata.height).toBe(144); + + const output = join(dir, `${fixture.file}.mp4`); + const transcode = spawn({ + cmd: [ + "ffmpeg", + "-v", + "error", + "-y", + "-i", + input, + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-c:a", + "aac", + output, + ], + stdout: "ignore", + stderr: "pipe", + }); + const [code, stderr] = await Promise.all([ + transcode.exited, + new Response(transcode.stderr).text(), + ]); + expect(stderr).not.toContain("not on whitelist"); + expect(code).toBe(0); + + const streams = await run([ + "ffprobe", + "-v", + "error", + "-show_entries", + "stream=codec_type,codec_name", + "-of", + "csv=p=0", + output, + ]); + expect(streams.stderr).toContain("h264,video"); + if (fixture.audio) expect(streams.stderr).toContain("aac,audio"); + }, + 60_000, + ); + } +}); diff --git a/apps/media-server/src/__tests__/lib/media-input-policy.test.ts b/apps/media-server/src/__tests__/lib/media-input-policy.test.ts index 9c6aa38030..64042a4ce9 100644 --- a/apps/media-server/src/__tests__/lib/media-input-policy.test.ts +++ b/apps/media-server/src/__tests__/lib/media-input-policy.test.ts @@ -102,15 +102,21 @@ describe("decoder whitelist", () => { "prores", "mpeg4", "mjpeg", - ]) - expect(isAllowedVideoCodec(codec)).toBe(true); - for (const codec of [ + "mpeg1video", "mpeg2video", + "wmv1", + "wmv2", + "wmv3", + "vc1", + "msmpeg4v1", + "msmpeg4v2", + "msmpeg4v3", + "flv1", + "h263", "theora", - "dvvideo", - "cinepak", - "unknown", ]) + expect(isAllowedVideoCodec(codec)).toBe(true); + for (const codec of ["dvvideo", "cinepak", "unknown"]) expect(isAllowedVideoCodec(codec)).toBe(false); const decoders = new Set(DECODER_WHITELIST.split(",")); for (const name of [ @@ -123,16 +129,23 @@ describe("decoder whitelist", () => { "mov_text", "h264_cuvid", "libdav1d", + "mpeg2video", + "msmpeg4", + "flv", + "mp2", + "wmav2", + "amrnb", + "adpcm_ms", ]) expect(decoders.has(name)).toBe(true); - for (const name of ["mpeg2video", "wmav2", "cinepak", "rawvideo"]) + for (const name of ["cinepak", "rawvideo", "dvvideo", "indeo5"]) expect(decoders.has(name)).toBe(false); }); test("FFmpeg refuses to decode a stream outside the list", async () => { const dir = await mkdtemp(join(tmpdir(), "codec-whitelist-")); try { - const path = join(dir, "mpeg2.mkv"); + const path = join(dir, "cinepak.avi"); const make = spawn({ cmd: [ "ffmpeg", @@ -143,7 +156,7 @@ describe("decoder whitelist", () => { "-i", "testsrc=d=0.2:s=64x64", "-c:v", - "mpeg2video", + "cinepak", "-y", path, ], diff --git a/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts b/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts index 5b8546062f..b532004741 100644 --- a/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts +++ b/apps/media-server/src/__tests__/lib/media-probe-hardening.test.ts @@ -64,7 +64,7 @@ beforeAll(async () => { dir = await mkdtemp(join(tmpdir(), "probe-hardening-")); const source = ["-f", "lavfi", "-i", "testsrc=d=1:s=128x96:r=10"]; await ffmpeg([...source, "-c:v", "mpeg4", join(dir, "part2.mp4")]); - await ffmpeg([...source, "-c:v", "mpeg2video", join(dir, "mpeg2.mkv")]); + await ffmpeg([...source, "-c:v", "cinepak", join(dir, "cinepak.mkv")]); await ffmpeg([ ...source, "-f", @@ -175,7 +175,7 @@ describe("probe input checks", () => { }, 30_000); test("a video codec outside the allowed list is refused", async () => { - const error = await probeError("mpeg2.mkv"); + const error = await probeError("cinepak.mkv"); expect(error).toBeInstanceOf(UnreadableMediaInputError); expect((error as Error).message).toContain("not supported"); }, 30_000); diff --git a/apps/media-server/src/lib/media-input-policy.ts b/apps/media-server/src/lib/media-input-policy.ts index 31cf8f3f27..89bfacf3b8 100644 --- a/apps/media-server/src/lib/media-input-policy.ts +++ b/apps/media-server/src/lib/media-input-policy.ts @@ -122,6 +122,44 @@ export function getSafeDemuxerWhitelist(): string { return safeDemuxerWhitelist; } +// Older video formats a regular Cap import can still carry: DVD and broadcast +// MPEG, Windows Media, Flash, early MPEG-4 and Ogg video. FFmpeg's decoders +// for them are mature. Named as ffprobe names the codec. +const LEGACY_VIDEO_CODECS = [ + "mpeg1video", + "mpeg2video", + "wmv1", + "wmv2", + "wmv3", + "vc1", + "msmpeg4v1", + "msmpeg4v2", + "msmpeg4v3", + "flv1", + "h263", + "h263i", + "h263p", + "theora", +]; + +// Decoders for those formats whose name differs from the codec name. +const LEGACY_VIDEO_DECODERS = ["msmpeg4", "flv"]; + +// Audio in those older files: MPEG audio layer 2, Windows Media audio, AMR +// from phones, and ADPCM in AVI and WAV. +const LEGACY_AUDIO_DECODERS = [ + "mp2", + "mp2float", + "wmav1", + "wmav2", + "amrnb", + "amrwb", + "libopencore_amrnb", + "libopencore_amrwb", + "adpcm_ima_wav", + "adpcm_ms", +]; + // Video codecs this server accepts in an upload, as ffprobe names them. export const ALLOWED_VIDEO_CODECS: readonly string[] = [ "h264", @@ -132,6 +170,7 @@ export const ALLOWED_VIDEO_CODECS: readonly string[] = [ "prores", "mpeg4", "mjpeg", + ...LEGACY_VIDEO_CODECS, ]; export function isAllowedVideoCodec(codec: string): boolean { @@ -206,6 +245,8 @@ export const ALLOWED_DECODERS: readonly string[] = [ "ac3_fixed", "eac3", "dca", + ...LEGACY_VIDEO_DECODERS, + ...LEGACY_AUDIO_DECODERS, ...PCM_DECODERS, "mov_text", "subrip", From 864989d20619316feb9c57cb617cd0481e97cb65 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 06:53:13 -0500 Subject: [PATCH 2/4] test(media-server): run the older-format tests in both image builds The image builds name each test file they run, so the new file did not run there. The standalone build is the image the encoders use, so the older formats are now probed and transcoded with that image's FFmpeg on both architectures. --- apps/media-server/Dockerfile | 2 +- apps/media-server/Dockerfile.standalone | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/media-server/Dockerfile b/apps/media-server/Dockerfile index f3b8963e60..f9a8bae617 100644 --- a/apps/media-server/Dockerfile +++ b/apps/media-server/Dockerfile @@ -37,7 +37,7 @@ RUN bun test src/__tests__/lib/audio-quality-policy.test.ts src/__tests__/lib/au && bun test src/__tests__/lib/live-transcribe.test.ts src/__tests__/routes/live-transcribe.test.ts \ && bun test src/__tests__/lib/media-routes-real-world.integration.test.ts \ && bun test src/__tests__/lib/media-video.integration.test.ts \ - && bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts \ + && bun test src/__tests__/lib/isolated-worker-pool.test.ts src/__tests__/lib/media-probe-hardening.test.ts src/__tests__/lib/process-hardening.test.ts src/__tests__/lib/legacy-codecs.integration.test.ts \ && bun test src/__tests__/routes/thumbnail-remote.test.ts src/__tests__/routes/health.test.ts RUN MEDIA_SERVER_RECORDING_PERFORMANCE_TESTS=1 bun test \ diff --git a/apps/media-server/Dockerfile.standalone b/apps/media-server/Dockerfile.standalone index e68b4309b6..d725ff948a 100644 --- a/apps/media-server/Dockerfile.standalone +++ b/apps/media-server/Dockerfile.standalone @@ -18,6 +18,8 @@ COPY src ./src COPY capcodec.md ./capcodec.md # Files whose tables claim far more than they hold must probe at once, or the image is not built. RUN bun test src/__tests__/lib/media-probe-sample-counts.test.ts +# The older formats regular imports carry must probe and transcode with this image's FFmpeg. +RUN bun test src/__tests__/lib/legacy-codecs.integration.test.ts ENV PORT=3456 # Self-hosted storage usually sits on a private network next to this container. From c483e1681987a4fd1f9d53d88d7909d38227226e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:07:54 -0500 Subject: [PATCH 3/4] fix(media-server): do not give the server the root group for open GPU devices NVIDIA nodes own their devices as root:root with mode 666, so the entrypoint added group 0 to the server's groups. A device anyone may read and write needs no group, so group 0 is skipped for it. A root-owned device that is not open to everyone still gets the group, as before. --- apps/media-server/docker-entrypoint.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/media-server/docker-entrypoint.sh b/apps/media-server/docker-entrypoint.sh index 3df38c7c5b..cd95a3ef3e 100755 --- a/apps/media-server/docker-entrypoint.sh +++ b/apps/media-server/docker-entrypoint.sh @@ -40,6 +40,14 @@ groups="$gid" for device in /dev/nvidia* /dev/dri/*; do [ -c "$device" ] || continue device_gid="$(stat -c %g "$device")" + # A device anyone may read and write needs no group, and the root group + # is never handed out for one. NVIDIA nodes usually own their devices + # as root:root with mode 666. + if [ "$device_gid" = 0 ]; then + case "$(stat -c %a "$device")" in + *[67]) continue ;; + esac + fi case ",$groups," in *",$device_gid,"*) ;; *) groups="$groups,$device_gid" ;; From 8e856b1baed2a38d03edb4d80c8368cead553b47 Mon Sep 17 00:00:00 2001 From: Richie McIlroy Date: Fri, 13 Nov 2026 08:13:44 -0500 Subject: [PATCH 4/4] chore(media-server): keep only comments that explain the codec lists --- apps/media-server/Dockerfile.standalone | 1 - apps/media-server/src/lib/media-input-policy.ts | 10 ++++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/media-server/Dockerfile.standalone b/apps/media-server/Dockerfile.standalone index d725ff948a..b7ca301d22 100644 --- a/apps/media-server/Dockerfile.standalone +++ b/apps/media-server/Dockerfile.standalone @@ -18,7 +18,6 @@ COPY src ./src COPY capcodec.md ./capcodec.md # Files whose tables claim far more than they hold must probe at once, or the image is not built. RUN bun test src/__tests__/lib/media-probe-sample-counts.test.ts -# The older formats regular imports carry must probe and transcode with this image's FFmpeg. RUN bun test src/__tests__/lib/legacy-codecs.integration.test.ts ENV PORT=3456 diff --git a/apps/media-server/src/lib/media-input-policy.ts b/apps/media-server/src/lib/media-input-policy.ts index 89bfacf3b8..83647b64a5 100644 --- a/apps/media-server/src/lib/media-input-policy.ts +++ b/apps/media-server/src/lib/media-input-policy.ts @@ -122,9 +122,8 @@ export function getSafeDemuxerWhitelist(): string { return safeDemuxerWhitelist; } -// Older video formats a regular Cap import can still carry: DVD and broadcast -// MPEG, Windows Media, Flash, early MPEG-4 and Ogg video. FFmpeg's decoders -// for them are mature. Named as ffprobe names the codec. +// Regular Cap imports still carry these, and FFmpeg's decoders for them are +// mature. Exotic codecs (Cinepak, DV, Indeo, raw video) stay refused. const LEGACY_VIDEO_CODECS = [ "mpeg1video", "mpeg2video", @@ -142,11 +141,10 @@ const LEGACY_VIDEO_CODECS = [ "theora", ]; -// Decoders for those formats whose name differs from the codec name. +// FFmpeg opens these codecs with decoders of another name, and the decoder +// list is matched by decoder name. const LEGACY_VIDEO_DECODERS = ["msmpeg4", "flv"]; -// Audio in those older files: MPEG audio layer 2, Windows Media audio, AMR -// from phones, and ADPCM in AVI and WAV. const LEGACY_AUDIO_DECODERS = [ "mp2", "mp2float",