vendor hls.js and play transcodes over hls

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:52:15 +00:00
co-authored by Claude Opus 5
parent 9990c04008
commit 7732ca1da5
7 changed files with 36973 additions and 30 deletions
+43 -9
View File
@@ -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 `<video>` element can play with no library.
*
* This is the deliberate fallback instead of HLS, and the reason is a dependency this repo does not have:
* Chromium cannot play an HLS playlist natively, so the m3u8 path needs hls.js, and `bunfig.toml` freezes
* installs precisely so a new package is a considered act rather than a side effect of a feature. A
* progressive stream needs nothing.
* This is the FALLBACK now, not the primary: the player prefers `hlsUrl`, whose playlist covers the whole
* runtime and therefore seeks properly. This path stays for the browser that cannot do MSE at all (iOS
* Safari's native HLS covers itself, but a stripped-down webview may have neither), and it is why the plan
* still carries a plain `url`.
*
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub.
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub when it falls back here.
*
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
@@ -313,8 +345,9 @@ function progressivePath(id: string, source: MediaSource, playSessionId: string
* the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
* because "why is my CPU pinned" is a question the UI should be able to answer.
*
* `hlsUrl` is returned alongside whenever Jellyfin offered one. Nothing consumes it yet — it is what the
* player switches to the day hls.js is added on purpose, and it costs nothing to carry until then.
* `hlsUrl` is returned alongside whenever Jellyfin offered one, and is what the player actually uses for a
* transcode — hls.js is vendored for it (`apps/Jellyfin/vendor/`). `url` remains the progressive fallback for
* a browser with no MSE, so both are carried and the player chooses.
*/
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
@@ -384,8 +417,9 @@ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url:
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
seekable: direct,
// Carried, not used. The day hls.js is a deliberate dependency, the player switches to this.
hlsUrl: source.TranscodingUrl ? `/_jf${sanitizeUpstreamPath(source.TranscodingUrl)}` : null,
// The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
// rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source)}` : null,
playSessionId,
mediaSourceId: source.Id ?? null,
mediaSource: source,