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 `