From c0682aa572809c608cb9e22f88916e44cc9f1d58 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 7 Nov 2026 15:09:22 +0100 Subject: [PATCH] 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. --- frontend/src/i18n/locales/en/plex.json | 3 + frontend/src/i18n/locales/hu/plex.json | 3 + frontend/src/lib/format.test.ts | 9 ++ frontend/src/lib/format.ts | 14 +++ frontend/src/lib/hlsEngine.test.ts | 110 +++++++++++++++++ frontend/src/lib/hlsEngine.ts | 57 ++++++++- frontend/src/lib/hlsPort.test.ts | 112 +++++++++++++++--- frontend/src/lib/hlsPort.ts | 30 ++++- .../lib/plexPlayerHooks.slowLink.dom.test.ts | 60 ++++++++++ frontend/src/lib/plexPlayerHooks.ts | 44 +++++++ frontend/src/lib/plexStats.test.ts | 32 +++++ frontend/src/lib/plexStats.ts | 22 ++++ frontend/src/modules/plex/PlexPlayer.tsx | 29 ++++- 13 files changed, 495 insertions(+), 30 deletions(-) create mode 100644 frontend/src/lib/plexPlayerHooks.slowLink.dom.test.ts diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index cbbe8a75..72ff1ecf 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -191,6 +191,9 @@ "errNotFound": "This file can't be played — the media isn't reachable on the server (the Plex media mount is missing or the path map is wrong). Ask the admin to check the Plex configuration.", "errTranscode": "This file's format needs transcoding, which isn't supported yet.", "errGeneric": "Playback couldn't start. Please try again.", + "errSlow": "Couldn't play: the connection is too slow for this file", + "slowLink": "Slow connection", + "slowLinkRates": "This file needs about {{need}}, about {{got}} is arriving", "help": "Keyboard shortcuts", "infoBtn": "Media info", "more": "More", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index bbf6264b..a7e17d40 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -191,6 +191,9 @@ "errNotFound": "Ez a fájl nem játszható le — a média nem érhető el a szerveren (hiányzik a Plex media-mount, vagy hibás az útvonal-leképezés). Kérd meg az adminisztrátort, hogy ellenőrizze a Plex beállításait.", "errTranscode": "Ennek a fájlnak a formátuma átkódolást igényel, ami még nem támogatott.", "errGeneric": "Nem sikerült elindítani a lejátszást. Próbáld újra.", + "errSlow": "Nem sikerült lejátszani: a kapcsolat túl lassú ehhez a fájlhoz", + "slowLink": "Lassú a kapcsolat", + "slowLinkRates": "A fájlhoz kb. {{need}} kell, most kb. {{got}} érkezik", "help": "Billentyűparancsok", "infoBtn": "Média infó", "more": "Továbbiak", diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts index 096bb752..5d075edd 100644 --- a/frontend/src/lib/format.test.ts +++ b/frontend/src/lib/format.test.ts @@ -6,6 +6,7 @@ import { formatCompact, formatCountOrDash, formatDecimal, + formatMbps, formatMoney, formatDate, formatDateTime, @@ -284,3 +285,11 @@ describe("numbers in the UI language", () => { expect(formatMoney(160_000_000, "hu")).not.toBe(formatMoney(160_000_000, "en")); }); }); + +describe("formatMbps", () => { + it("writes a rate in the browser's own unit, a decimal only under 10", () => { + expect(formatMbps(19_600_000, "en")).toBe("20 Mb/s"); + expect(formatMbps(4_130_000, "en")).toBe("4.1 Mb/s"); + expect(formatMbps(4_130_000, "hu")).toBe("4,1 Mb/mp"); + }); +}); diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9fe40ca6..519e9bf8 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -133,6 +133,20 @@ export function formatUnit( ); } +/** A rate in megabits per second, in the browser's own short form ("20 Mb/s" / "20 Mb/mp"): one + * decimal under 10, whole above. */ +export function formatMbps(bitsPerSecond: number, lang: string): string { + const mbps = bitsPerSecond / 1_000_000; + const digits = mbps < 10 ? 1 : 0; + return numberFormat(lang, { + style: "unit", + unit: "megabit-per-second", + unitDisplay: "short", + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }).format(mbps); +} + /** Compact human runtime ("1 hr 30 min", "45 min", "45 sec"; HU "1 ó 30 p") from seconds. Formats * BOTH a single item's length (Plex movies/episodes) and an aggregate total (playlist playtime, on * the YouTube and the Plex side alike) — it is the one renderer for a playlist total, so a total diff --git a/frontend/src/lib/hlsEngine.test.ts b/frontend/src/lib/hlsEngine.test.ts index 38c54da3..88f52781 100644 --- a/frontend/src/lib/hlsEngine.test.ts +++ b/frontend/src/lib/hlsEngine.test.ts @@ -46,6 +46,7 @@ class FakeHls implements HlsPort { loaded: string | null = null; destroyed = false; audioTrack = 0; + resumes = 0; constructor(readonly handlers: HlsHandlers) {} load(url: string) { this.loaded = url; @@ -53,6 +54,24 @@ class FakeHls implements HlsPort { destroy() { this.destroyed = true; } + resume() { + this.resumes++; + } + /** hls.js gave up on a request that never answered in time: no response, so no status. */ + timedOut(details = "fragLoadTimeOut") { + this.handlers.onError({ + fatal: true, + network: true, + status: null, + timeout: true, + type: "networkError", + details, + }); + } + /** A fragment arrived: media is getting through. */ + arrived() { + this.handlers.onFragLoaded({ type: "main", bytes: 1_000_000, durationS: 6 }); + } getAudioTrack() { return this.audioTrack; } @@ -72,6 +91,7 @@ class FakeHls implements HlsPort { fatal: false, network: true, status: 410, + timeout: false, type: "networkError", details: "fragLoadError", }); @@ -82,6 +102,7 @@ class FakeHls implements HlsPort { fatal: false, network: true, status, + timeout: false, type: "networkError", details: "fragLoadError", }); @@ -92,6 +113,7 @@ class FakeHls implements HlsPort { fatal: true, network: true, status, + timeout: false, type: "networkError", details: "levelLoadError", }); @@ -579,6 +601,7 @@ describe("a server that is restarting (a deploy)", () => { fire(); await asks[1]!.resolve(hls("A")); hlses[1]!.handlers.onManifestParsed(); + hlses[1]!.arrived(); // back: media gets through again advance(AWAY_WINDOW_MS + 1); hlses[1]!.lost(502); // a second deploy, long after the first expect(engine.getSnapshot().error).toBeNull(); @@ -608,6 +631,93 @@ describe("a server that is restarting (a deploy)", () => { hlses[0]!.lost(500); expect(engine.getSnapshot().error).toBe("generic"); }); + + it("takes a direct stream's answer for the server being back: it has no fragments to show it", async () => { + const { engine, asks, fire, advance } = setup(); + engine.start("A", 0); + await asks[0]!.reject({ status: 502 }); + fire(); + await asks[1]!.resolve(direct("A")); + advance(AWAY_WINDOW_MS + 1); + engine.start("A", 100); // a second deploy, long after the first + await asks[2]!.reject({ status: 502 }); + expect(engine.getSnapshot().error).toBeNull(); + }); + + it("ends in an error when the server answers every session but none of them delivers", async () => { + // Every answer used to start the absence over, so this asked for a new session forever. + const { engine, asks, hlses, fire } = setup(); + engine.start("A", 0); + await asks[0]!.resolve(hls("A")); + hlses[0]!.handlers.onManifestParsed(); + hlses[0]!.lost(0); + for (let i = 1; i < 40 && fire(); i++) { + await asks[i]!.resolve(hls("A", 0, `s-${i}`)); + hlses[i]!.handlers.onManifestParsed(); + hlses[i]!.lost(0); + } + expect(engine.getSnapshot().error).toBe("generic"); + expect(asks.length).toBeLessThan(40); + }); +}); + +describe("a link too slow for the stream", () => { + // Measured on prod (2026-11-04, a work network at ~4 Mbit/s against 11–16 MB remux segments): an + // audio segment waited behind a video one past hls.js's time to first byte, five times, and gave up + // with no answer. That was taken for a server away: a new session, the same segment lost again, a + // restart every half minute until the viewer closed the player. + it("resumes loading in the same session, and asks for no new one", async () => { + const { engine, asks, hlses, timers } = setup(); + engine.start("A", 453); + await asks[0]!.resolve(hls("A", 453)); + hlses[0]!.handlers.onManifestParsed(); + hlses[0]!.timedOut(); + expect(asks).toHaveLength(1); + expect(timers.filter((t) => t.armed)).toHaveLength(0); + expect(hlses[0]!.resumes).toBe(1); + expect(hlses[0]!.destroyed).toBe(false); + expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null }); + }); + + it.each(["fragLoadTimeOut", "levelLoadTimeOut", "audioTrackLoadTimeOut"])( + "treats a %s the same way", + async (details) => { + const { engine, asks, hlses } = setup(); + engine.start("A", 0); + await asks[0]!.resolve(hls("A")); + hlses[0]!.timedOut(details); + expect(asks).toHaveLength(1); + expect(hlses[0]!.resumes).toBe(1); + }, + ); + + it("keeps going for as long as media still arrives, however slowly", async () => { + const { engine, asks, hlses, advance } = setup(); + engine.start("A", 0); + await asks[0]!.resolve(hls("A")); + hlses[0]!.handlers.onManifestParsed(); + for (let i = 0; i < 10; i++) { + hlses[0]!.timedOut(); + advance(60_000); + hlses[0]!.arrived(); + } + hlses[0]!.timedOut(); + expect(engine.getSnapshot().error).toBeNull(); + expect(asks).toHaveLength(1); + }); + + it("says the connection is too slow once nothing has arrived for the whole window", async () => { + const { engine, asks, hlses, advance } = setup(); + engine.start("A", 0); + await asks[0]!.resolve(hls("A")); + hlses[0]!.handlers.onManifestParsed(); + hlses[0]!.timedOut(); + advance(AWAY_WINDOW_MS + 1); + hlses[0]!.timedOut(); + expect(engine.getSnapshot().error).toBe("slow"); + expect(hlses[0]!.resumes).toBe(1); + expect(asks).toHaveLength(1); + }); }); describe("play intent while a session is asked for", () => { diff --git a/frontend/src/lib/hlsEngine.ts b/frontend/src/lib/hlsEngine.ts index 4436c63a..7fa75875 100644 --- a/frontend/src/lib/hlsEngine.ts +++ b/frontend/src/lib/hlsEngine.ts @@ -30,14 +30,20 @@ export const RECLAIM_WINDOW_MS = 120_000; // gone with it, but the buffer keeps playing — so the engine asks again where the viewer is, less and // less often, and says it cannot play only once the server has been away this long. It used to say // so at the first 502, over a picture still playing from the buffer, and never asked again. +// +// The absence ends when MEDIA arrives again (a fragment loads), not when a session request is +// answered: a server that answers every session and delivers none of them is still not serving, and +// ending the absence at each answer restarted the stream forever. The same window bounds a link too +// slow for the stream (see `slowLink`). export const AWAY_WINDOW_MS = 180_000; const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000]; const SERVER_AWAY = new Set([0, 502, 503, 504]); // The server's answer for a session it no longer has; its 404 means a segment not there yet. const SESSION_GONE = 410; -/** Why a session could not play, in the three buckets the player has a sentence for. */ -type EngineError = "transcode" | "notFound" | "generic"; +/** Why a session could not play, in the buckets the player has a sentence for. `slow`: the link + * never delivered the stream in time for the whole away window. */ +type EngineError = "transcode" | "notFound" | "slow" | "generic"; /** What the player renders from the engine. A new object whenever any of it changes. */ export interface EngineSnapshot { @@ -55,14 +61,27 @@ export interface HlsFailure { fatal: boolean; network: boolean; status: number | null; + /** A request that got no answer in time (no response, so `status` is null). */ + timeout: boolean; type: string; details: string; } +/** A loaded fragment, as the meters read it. `loading`: when its download started and ended + * (performance.now() ms), when known. */ +export interface FragLoaded { + type: string; + bytes: number; + durationS: number; + loading?: { start: number; end: number }; +} + /** One hls.js instance, narrowed to what the engine uses (hlsPort.ts binds the real one). */ export interface HlsPort { load(url: string, media: HTMLMediaElement): void; destroy(): void; + /** Start loading again where it stopped, after a fatal network error (same session). */ + resume(): void; getAudioTrack(): number; setAudioTrack(track: number): void; levelInfo(): HlsLevelInfo; @@ -71,7 +90,7 @@ export interface HlsPort { export interface HlsHandlers { onManifestParsed(): void; onAudioTracksUpdated(): void; - onFragLoaded(frag: { type: string; bytes: number; durationS: number }): void; + onFragLoaded(frag: FragLoaded): void; onError(e: HlsFailure): void; } @@ -106,7 +125,7 @@ export interface EngineDeps { statusOf(e: unknown): number | undefined; /** A request for a session went out: the player clears its stall spinner. */ onLoadStart?(): void; - onFragLoaded?(frag: { type: string; bytes: number; durationS: number }): void; + onFragLoaded?(frag: FragLoaded): void; onHlsError?(e: HlsFailure): void; } @@ -358,7 +377,9 @@ export class HlsEngine { } const media = this.deps.media(); if (!media) return; - this.away = null; // the server answered: a later absence is a new one + // A direct stream reports no fragments: its answer is the only sign the server is back. An HLS + // absence ends when a fragment arrives (see `AWAY_WINDOW_MS`). + if (sess.mode !== "hls") this.away = null; const { intent, startAt, id } = req; // The stream that kept playing is replaced from here: the new one loads like any other. if (req.quiet) this.set({ ready: false }); @@ -456,7 +477,10 @@ export class HlsEngine { /* out of range — ignore */ } }, - onFragLoaded: (frag) => this.deps.onFragLoaded?.(frag), + onFragLoaded: (frag) => { + if (this.current(req)) this.away = null; // media gets through: whatever was away is back + this.deps.onFragLoaded?.(frag); + }, onError: (e) => this.onHlsError(req, media, e), }; } @@ -489,6 +513,10 @@ export class HlsEngine { return; } if (!e.fatal) return; + if (e.network && e.timeout) { + this.slowLink(); + return; + } if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return; if (e.network && e.status === 404) { this.reclaim(req, media, false); @@ -538,6 +566,23 @@ export class HlsEngine { return true; } + /** A request got no answer in time: the link is too slow for what the stream asks of it, not the + * server away (measured on prod: a work network at ~4 Mbit/s against 11–16 MB remux segments; an + * audio segment waited behind a video one past hls.js's time to first byte). A new session would + * throw away what had arrived and lose its first segment the same way — a restart every half + * minute, never playing. So loading resumes in the same session, for as long as media still + * arrives; the away window says it is too slow once nothing has for that long. */ + private slowLink(): void { + const now = this.deps.now(); + const away = (this.away ??= { since: now, tries: 0 }); + if (now - away.since > AWAY_WINDOW_MS) { + this.set({ error: "slow" }); + return; + } + away.tries += 1; + this.hls?.resume(); + } + /** Where the viewer is: the ticked position, or — before playback has ticked (a session still * loading, or a resumed one that has stayed paused) — where the session was ASKED to start. * Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00 diff --git a/frontend/src/lib/hlsPort.test.ts b/frontend/src/lib/hlsPort.test.ts index aea69cc0..e26e8a1a 100644 --- a/frontend/src/lib/hlsPort.test.ts +++ b/frontend/src/lib/hlsPort.test.ts @@ -1,42 +1,124 @@ +// The one place hls.js is built for the Plex player, held against a stand-in hls.js: what config it is +// built with, and what it hands the engine from hls.js's events. +// // hls.js empties the cues of every text track on the