From 9990c04008dc022a18b8c4f290ce38002d7e5c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 18:34:27 +0000 Subject: [PATCH] jellyfin: ask for a bitrate, or get 416 pixels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a transcode url without `videoBitrate` is not "let the server choose". jellyfin resolves the missing value to 0 and runs ResolutionNormalizer over it, which maps a bitrate onto a resolution — 0 lands on the bottom rung. verified here: a 3840x1600 hdr source came back through scale=...min(max(iw,ih*a),416)... with -maxrate 0. the number has to be picked per source, because the two cases pull opposite ways: a cap is a reason for jellyfin to REFUSE a stream copy, and the absence of one is what makes a cpu-only 4k encode hopeless. so copyable h264 asks for a ceiling nothing hits and no width, and anything being genuinely re-encoded asks for 1080p at 12 mbit. verified both branches against the ffmpeg command jellyfin logs: h264 mkv → -codec:v:0 copy, hevc 10-bit hdr → libx264 at 1920 wide, tonemapped. Co-Authored-By: Claude Opus 5 --- src/servers/sidecar/jellyfin/routes.ts | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/servers/sidecar/jellyfin/routes.ts b/src/servers/sidecar/jellyfin/routes.ts index fb222463..4b2137ec 100644 --- a/src/servers/sidecar/jellyfin/routes.ts +++ b/src/servers/sidecar/jellyfin/routes.ts @@ -188,6 +188,16 @@ async function item(cfg: UpstreamConfig, id: string): Promise { return relayJson(res); } +type SourceStream = { + Type?: string; + Codec?: string; + Profile?: string; + Level?: number; + Width?: number; + Height?: number; + BitRate?: number; +}; + type MediaSource = { Id?: string; Container?: string; @@ -196,6 +206,7 @@ type MediaSource = { SupportsDirectStream?: boolean; DirectStreamUrl?: string; TranscodingUrl?: string; + MediaStreams?: SourceStream[]; }; type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null }; @@ -216,6 +227,40 @@ function sanitizeUpstreamPath(raw: string): string { return `${path}${qs ? `?${qs}` : ''}`; } +/** + * A ceiling high enough to never be the binding constraint on a stream copy. It matches the profile's + * `MaxStreamingBitrate`: the request has to name a number, and for a copy the right number is "more than + * whatever this file is", since Jellyfin refuses to copy a stream that exceeds the requested bitrate. + */ +const COPY_CEILING_BITRATE = 120_000_000; + +/** What a real CPU re-encode targets. 1080p at 12 Mbit is visually generous for h264 at the crf Jellyfin uses. */ +const ENCODE_BITRATE = 12_000_000; +const ENCODE_MAX_WIDTH = 1920; + +/** + * Decide the bitrate and resolution cap to ask for, from what is actually in the file. + * + * The two cases pull in opposite directions, which is why this is not one constant: + * + * video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream + * above the requested bitrate, or wider than a requested `maxWidth`), so ask for a + * ceiling nothing hits and no width at all. The bytes are passed through untouched. + * video is re-encoded → a cap is the only thing keeping ffmpeg tractable. There is no /dev/dri in the + * Jellyfin container, so 4K HDR HEVC is being decoded, tonemapped and encoded on the + * CPU; downscaling to 1080p is what makes that keep up with playback. + * + * The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2. + */ +function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth?: number } { + const video = source.MediaStreams?.find((stream) => stream.Type === 'Video'); + const copyable = + video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52; + + if (copyable) return { videoBitrate: COPY_CEILING_BITRATE }; + return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH }; +} + /** * Build the PROGRESSIVE transcode URL — one continuous mp4 the `