fix(plex): a playlist that never answers is asked for again; the slow line reads this stream

A playlist timeout was resumed in the same session, but hls.js reloads no manifest on startLoad: no
request, no error, a spinner for good. It is now asked for again like a server away. An absence
also ended only on a fragment, which native HLS never reports, and a viewer trying again after the
error started with the spent window; both now start a fresh one. The slow-connection meter was
reset only when hls.js was built, so a direct answer showed the previous stream's numbers, and a
replaced session's late fragment still counted; its rates now use the stats overlay's units.
This commit is contained in:
2026-11-07 21:58:36 +01:00
parent a6f1a26658
commit 90931b4750
6 changed files with 133 additions and 33 deletions
-9
View File
@@ -6,7 +6,6 @@ import {
formatCompact,
formatCountOrDash,
formatDecimal,
formatMbps,
formatMoney,
formatDate,
formatDateTime,
@@ -285,11 +284,3 @@ 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");
});
});
-14
View File
@@ -133,20 +133,6 @@ 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
+74
View File
@@ -718,6 +718,80 @@ describe("a link too slow for the stream", () => {
expect(hlses[0]!.resumes).toBe(1);
expect(asks).toHaveLength(1);
});
it("asks for the session again when its playlist never answered", async () => {
// Loading cannot resume what never parsed: hls.js reloads no manifest on startLoad, so a resume
// here sent nothing, raised nothing, and left the spinner up for good.
const { engine, asks, hlses, fire } = setup();
engine.start("A", 300);
await asks[0]!.resolve(hls("A", 300));
hlses[0]!.timedOut("manifestLoadTimeOut");
expect(fire()).toBe(true);
expect(asks).toHaveLength(2);
expect(asks[1]!.startAt).toBe(300);
await asks[1]!.resolve(hls("A", 300));
hlses[1]!.handlers.onManifestParsed();
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
});
it("says it cannot play once the playlist has not answered for the whole window", async () => {
const { engine, asks, hlses, fire, advance } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.timedOut("manifestLoadTimeOut");
advance(AWAY_WINDOW_MS + 1);
expect(fire()).toBe(true);
await asks[1]!.resolve(hls("A"));
hlses[1]!.timedOut("manifestLoadTimeOut");
expect(engine.getSnapshot().error).not.toBeNull();
});
});
describe("what a fragment reports", () => {
it("is passed on from the session asked for only, never from one it replaced", async () => {
const seen: number[] = [];
const { engine, asks, hlses } = setup({ onFragLoaded: (f) => void seen.push(f.bytes) });
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.arrived();
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000));
hlses[0]!.arrived(); // the replaced session's last fragment lands late
expect(seen).toEqual([1_000_000]);
hlses[1]!.arrived();
expect(seen).toEqual([1_000_000, 1_000_000]);
});
});
describe("an absence when no hls.js plays the stream", () => {
it("ends with an answer the browser plays itself (native HLS)", async () => {
// Without MSE an HLS answer plays natively and no fragment is ever reported, so the answer is
// the only sign the server is back: a later absence gets its own window.
const { engine, asks, advance, fire } = setup({ createHls: () => null });
engine.start("A", 0);
await asks[0]!.reject({ status: 503 });
expect(fire()).toBe(true);
await asks[1]!.resolve(hls("A"));
advance(AWAY_WINDOW_MS + 60_000);
engine.seek("A", 900);
await asks[2]!.reject({ status: 503 });
expect(engine.getSnapshot().error).toBeNull();
expect(fire()).toBe(true);
});
it("starts afresh when the viewer tries again after the error was shown", async () => {
const { engine, asks, advance, fire } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status: 503 });
advance(AWAY_WINDOW_MS + 1);
expect(fire()).toBe(true);
await asks[1]!.reject({ status: 503 });
expect(engine.getSnapshot().error).toBe("generic");
engine.start("A", 0); // the viewer presses play again
await asks[2]!.reject({ status: 503 });
expect(engine.getSnapshot().error).toBeNull();
expect(fire()).toBe(true);
});
});
describe("play intent while a session is asked for", () => {
+13 -5
View File
@@ -318,6 +318,9 @@ export class HlsEngine {
if (quiet) {
this.set({ error: null });
} else {
// The viewer trying again after the error was shown: a new attempt, with a whole window of its
// own — the spent one turned its first failure straight into the error again.
if (this.snapshot.error) this.away = null;
this.live = false;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
@@ -377,9 +380,6 @@ export class HlsEngine {
}
const media = this.deps.media();
if (!media) return;
// 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 });
@@ -412,6 +412,10 @@ export class HlsEngine {
// Named for the stop on the way out whoever plays it: hls.js, or Safari's native HLS below.
if (sess.mode === "hls" && sess.id) this.attachedSession = { id, sessionId: sess.id };
const hls = sess.mode === "hls" ? this.deps.createHls(this.hlsHandlers(req, media)) : null;
// What hls.js does not play reports no fragments — a direct file, or HLS the browser plays
// natively (no MSE) — so its answer is the only sign the server is back. Under hls.js an
// absence ends when a fragment arrives (see `AWAY_WINDOW_MS`).
if (!hls) this.away = null;
if (hls) {
this.hls = hls;
hls.load(`${sess.url}?t=${Math.floor(startAt)}`, media);
@@ -478,7 +482,9 @@ export class HlsEngine {
}
},
onFragLoaded: (frag) => {
if (this.current(req)) this.away = null; // media gets through: whatever was away is back
// A superseded session's late fragment is not this stream's measurement.
if (!this.current(req)) return;
this.away = null; // media gets through: whatever was away is back
this.deps.onFragLoaded?.(frag);
},
onError: (e) => this.onHlsError(req, media, e),
@@ -513,7 +519,9 @@ export class HlsEngine {
return;
}
if (!e.fatal) return;
if (e.network && e.timeout) {
// A playlist that never answered is not resumed: hls.js reloads no manifest on startLoad, so a
// resume sent nothing and the spinner stayed. It is asked for again, as a server away is.
if (e.network && e.timeout && e.details !== "manifestLoadTimeOut") {
this.slowLink();
return;
}