Files
siftlode/frontend/src/lib/hlsPort.ts
T
peter c0682aa572 fix(plex): a link too slow for the stream resumes in place, and says so
A request hls.js got no answer to in time (no status) was taken for a server
away: a new session, the same first segment lost again, a restart every half
minute that never ended, since every session answer reset the away window.
Measured on prod: a work network at ~4 Mbit/s against 11-16 MB remux segments.

A timeout now resumes loading in the same session; the away window ends only
when media arrives (or a direct stream answers), and runs out in a 'too slow'
error. A segment gets 30 s to its first byte. After 5 s of stalling on a link
measured slower than the stream, the player says so with both rates.
2026-11-07 15:09:22 +01:00

96 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Hls from "hls.js";
import type { HlsHandlers, HlsPort } from "./hlsEngine";
// The one place hls.js is constructed for the Plex player: the engine (hlsEngine.ts) sees only the
// narrow `HlsPort`, so its tests run without hls.js or a media pipeline.
export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
if (!Hls.isSupported()) return null;
// startPosition: 0 — our HLS is a growing EVENT playlist with no ENDLIST (see _HLS_TAIL in
// stream.py), which hls.js classifies as LIVE. Its default (startPosition: -1) then starts playback
// at the live edge and hugs it, so it never builds a forward buffer (rebuffer spinners), and on a
// resume/seek session the segments' absolute PTS (-copyts, offset by the server's -ss) don't line
// up with the live-edge timeline → 0 fragments load → a permanent stall. But the seek-restart model
// means fragment 0 IS the requested content position (`?t=` positioned the server ffmpeg there),
// so it always starts VOD-style at 0 and buffers forward up to maxBufferLength.
//
// No subtitle or caption controllers (a controller left unset is never built): the subtitles are
// a <track> of ours, out of band, and hls.js empties the cues of EVERY text track on the element
// when a manifest loads or the media attaches/detaches — ours included, so a VTT that loaded before
// the manifest showed nothing for the rest of the session.
//
// A fragment gets 30 s to its first byte, not hls.js's 10: a remux segment is the source's own
// bitrate (11–16 MB measured on prod), and on a slow link (a work network at ~4 Mbit/s) the small
// audio segment beside it waits behind it longer than 10 s — five such waits ended the load. The
// rest is hls.js's default policy.
const hls = new Hls({
maxBufferLength: 30,
enableWorker: true,
startPosition: 0,
fragLoadPolicy: {
default: {
maxTimeToFirstByteMs: 30_000,
maxLoadTimeMs: 120_000,
timeoutRetry: { maxNumRetry: 4, retryDelayMs: 0, maxRetryDelayMs: 0 },
errorRetry: { maxNumRetry: 6, retryDelayMs: 1000, maxRetryDelayMs: 8000 },
},
},
subtitleTrackController: undefined,
subtitleStreamController: undefined,
timelineController: undefined,
});
hls.on(Hls.Events.MANIFEST_PARSED, () => handlers.onManifestParsed());
hls.on(Hls.Events.AUDIO_TRACKS_UPDATED, () => handlers.onAudioTracksUpdated());
// The delivered bitrate is measured per fragment: the master playlist's own BANDWIDTH is not a
// measurement (see plexStats.ts). When its download ran says how fast the link delivers.
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
const l = data.frag.stats?.loading;
handlers.onFragLoaded({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
durationS: data.frag.duration,
loading: l && l.end > l.start ? { start: l.start, end: l.end } : undefined,
});
});
hls.on(Hls.Events.ERROR, (_e, data) =>
handlers.onError({
fatal: data.fatal,
network: data.type === Hls.ErrorTypes.NETWORK_ERROR,
status: data.response?.code ?? null,
// hls.js names every no-answer-in-time failure `…TimeOut` (fragLoadTimeOut, levelLoadTimeOut,
// audioTrackLoadTimeOut, manifestLoadTimeOut, …).
timeout: data.details.endsWith("TimeOut"),
type: data.type,
details: data.details,
}),
);
return {
load(url, media) {
hls.loadSource(url);
hls.attachMedia(media);
},
destroy: () => hls.destroy(),
// hls.js's own recovery after a fatal network error: load again from where the media is. The
// position is passed: with none, a session that never ticked would start at the live edge (see
// `startPosition: 0` above).
resume: () => hls.startLoad(hls.media?.currentTime ?? 0),
getAudioTrack: () => hls.audioTrack,
setAudioTrack(track) {
hls.audioTrack = track;
},
levelInfo() {
// currentLevel is -1 while auto-ABR hasn't settled; loadLevel then gives the res/bitrate.
const idx = hls.currentLevel >= 0 ? hls.currentLevel : hls.loadLevel;
const lv = idx >= 0 ? hls.levels?.[idx] : undefined;
return {
index: hls.currentLevel,
total: hls.levels?.length ?? 0,
width: lv?.width,
height: lv?.height,
bitrate: lv?.bitrate,
videoCodec: lv?.videoCodec,
audioCodec: lv?.audioCodec,
};
},
};
}