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.
This commit is contained in:
2026-11-07 15:09:22 +01:00
parent 25aed6635f
commit c0682aa572
13 changed files with 495 additions and 30 deletions
+26 -4
View File
@@ -17,10 +17,23 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
// 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,
@@ -28,19 +41,24 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
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).
hls.on(Hls.Events.FRAG_LOADED, (_e, data) =>
// 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,
}),
@@ -51,6 +69,10 @@ export function createHlsPort(handlers: HlsHandlers): HlsPort | null {
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;