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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user