From 7732ca1da52bc029894a532fb7d669bfbb59688c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 18:52:15 +0000 Subject: [PATCH] vendor hls.js and play transcodes over hls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a live transcode has no length and no byte ranges, so the progressive mp4 the player used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request. hls.js is checked in rather than installed. installs are frozen so that adding a package is a reviewed act, and a committed file also has no install-time hook, which is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe are in vendor/README.md; the tarball sha512 matches the registry's published integrity. the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin hands back, for the same reason progressivePath computes them: jellyfin resolves that bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream copy through) and sets no width, which asks a CPU-only container to encode 4K. safari is deliberately not given the m3u8 — segment URIs are relative and would not carry the ?token=, and it cannot set an Authorization header the way hls.js can. progressive stays as the fallback for any browser without MSE. Co-Authored-By: Claude Opus 5 --- .prettierignore | 4 + src/servers/sidecar/jellyfin/routes.ts | 52 +- .../src/apps/Jellyfin/VideoPlayer.tsx | 100 +- .../src/apps/Jellyfin/vendor/README.md | 51 + .../src/apps/Jellyfin/vendor/hls.LICENSE | 28 + .../src/apps/Jellyfin/vendor/hls.d.mts | 63 + .../src/apps/Jellyfin/vendor/hls.mjs | 36705 ++++++++++++++++ 7 files changed, 36973 insertions(+), 30 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/vendor/README.md create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.LICENSE create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.d.mts create mode 100644 src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs diff --git a/.prettierignore b/.prettierignore index 14e68779..b1bcc99c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,3 +2,7 @@ node_modules dist runtime-scripts *.min.js + +# Vendored third-party sources — reformatting them would destroy the diff against upstream, +# which is the only thing that makes a checked-in library reviewable. See the README beside it. +src/workspaces/officerdev/src/apps/Jellyfin/vendor/hls.mjs diff --git a/src/servers/sidecar/jellyfin/routes.ts b/src/servers/sidecar/jellyfin/routes.ts index 4b2137ec..9c7fb67d 100644 --- a/src/servers/sidecar/jellyfin/routes.ts +++ b/src/servers/sidecar/jellyfin/routes.ts @@ -261,17 +261,49 @@ function transcodeQuality(source: MediaSource): { videoBitrate: number; maxWidth return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH }; } +/** + * Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one. + * + * `TranscodingUrl` is built by the server from our `DeviceProfile`, which is why it is taken rather than + * reinvented: it already carries the segment container, the audio codec list, the play session and the + * transcode reasons, and every one of those is the server's business rather than ours. What it gets wrong for + * this machine is exactly two values — it resolves `VideoBitrate` from `MaxStreamingBitrate` (~119 Mbit, the + * ceiling that exists to let a stream COPY through) and never sets a width. For a file that can be copied that + * is right. For a file that must be re-encoded it asks a CPU-only container to encode 4K, which cannot keep up + * with playback. So those two are overwritten from `transcodeQuality`, and nothing else is touched. + * + * Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no + * width, on an item whose transcode reason was `ContainerNotSupported` alone. + */ +function hlsPath(source: MediaSource): string { + const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!); + const [path, search = ''] = sanitized.split('?'); + const params = new URLSearchParams(search); + const quality = transcodeQuality(source); + + // Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key + // under a different case would still be two values, so delete before setting rather than trusting that. + for (const key of [...params.keys()]) { + const lower = key.toLowerCase(); + if (lower === 'videobitrate' || lower === 'maxwidth' || lower === 'maxheight') params.delete(key); + } + params.set('VideoBitrate', String(quality.videoBitrate)); + if (quality.maxWidth) params.set('MaxWidth', String(quality.maxWidth)); + + return `${path}?${params.toString()}`; +} + /** * Build the PROGRESSIVE transcode URL — one continuous mp4 the `