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
+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", () => {