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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user