Files
siftlode/frontend/src/lib/hlsEngine.test.ts
T
peter 90931b4750 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.
2026-11-07 21:58:36 +01:00

969 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The Plex player's session machine, without React, hls.js or a media pipeline: every race it exists
// to win, driven step by step. PlexPlayer's DOM tests hold the same behaviour through the component.
import { describe, it, expect, vi } from "vitest";
import type { PlexPlaySession } from "./api";
import {
AWAY_WINDOW_MS,
HlsEngine,
MAX_SESSION_RECLAIMS,
RECLAIM_WINDOW_MS,
type EngineDeps,
type HlsHandlers,
type HlsPort,
type SessionParams,
} from "./hlsEngine";
import { HLS_SUB_LAG } from "./plexPlayerPrefs";
/** A <video> stand-in: the media calls the engine makes, and events the test fires. */
class FakeMedia extends EventTarget {
paused = true;
currentTime = 0;
src = "";
seekEnd = 0;
plays = 0;
get seekable() {
const end = this.seekEnd;
return { length: end > 0 ? 1 : 0, end: () => end };
}
play() {
this.plays++;
this.paused = false;
return Promise.resolve();
}
pause() {
this.paused = true;
}
load() {}
removeAttribute(name: string) {
if (name === "src") this.src = "";
}
fire(type: string) {
this.dispatchEvent(new Event(type));
}
}
class FakeHls implements HlsPort {
loaded: string | null = null;
destroyed = false;
audioTrack = 0;
resumes = 0;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
}
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;
}
setAudioTrack(track: number) {
this.audioTrack = track;
}
levelInfo() {
return { index: 0, total: 1 };
}
lost404() {
this.lost(404);
}
/** A request answered "this session is gone". hls.js only reports it (not fatal) and retries for
* half a minute: with one video variant it has nowhere else to go. */
gone() {
this.handlers.onError({
fatal: false,
network: true,
status: 410,
timeout: false,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js reports a failed request it will retry by itself. */
retrying(status: number) {
this.handlers.onError({
fatal: false,
network: true,
status,
timeout: false,
type: "networkError",
details: "fragLoadError",
});
}
/** hls.js gave up on a playlist or fragment with this answer (null: no answer at all). */
lost(status: number | null) {
this.handlers.onError({
fatal: true,
network: true,
status,
timeout: false,
type: "networkError",
details: "levelLoadError",
});
}
}
interface Ask {
id: string;
startAt: number;
params: SessionParams;
resolve(s: PlexPlaySession): Promise<void>;
reject(e: unknown): Promise<void>;
}
const flush = () => new Promise((r) => setTimeout(r, 0));
function setup(over: Partial<EngineDeps> = {}) {
const media = new FakeMedia();
const asks: Ask[] = [];
const hlses: FakeHls[] = [];
const stops: [string, string][] = [];
// The engine's timers, run by hand: `fire` runs the next one still armed.
const timers: { fn: () => void; ms: number; armed: boolean }[] = [];
let clock = 1_000_000;
const state = { position: 0, duration: 3000, params: { multi: false, audio: null, aoff: 0 } };
const engine = new HlsEngine({
media: () => media as unknown as HTMLMediaElement,
requestSession: (id, startAt, params) =>
new Promise<PlexPlaySession>((res, rej) => {
asks.push({
id,
startAt,
params,
resolve: async (s) => {
res(s);
await flush();
},
reject: async (e) => {
rej(e);
await flush();
},
});
}),
stopSession: (id, sid) => void stops.push([id, sid]),
createHls: (h) => {
const hls = new FakeHls(h);
hlses.push(hls);
return hls;
},
params: () => state.params,
position: () => state.position,
duration: () => state.duration,
now: () => clock,
statusOf: (e) => (e as { status?: number }).status,
later: (fn, ms) => {
const t = { fn, ms, armed: true };
timers.push(t);
return () => void (t.armed = false);
},
...over,
});
return {
engine,
media,
asks,
hlses,
stops,
state,
timers,
advance: (ms: number) => void (clock += ms),
/** Let the next armed timer run (its delay passes on the clock too); false when none is. */
fire: () => {
const t = timers.find((x) => x.armed);
if (!t) return false;
t.armed = false;
clock += t.ms;
t.fn();
return true;
},
};
}
const hls = (id: string, start_s = 0, sid = `s-${id}-${start_s}`): PlexPlaySession => ({
mode: "hls",
url: `/hls/${id}/index.m3u8`,
start_s,
id: sid,
});
const direct = (id: string): PlexPlaySession => ({
mode: "direct",
url: `/media/${id}`,
start_s: 0,
});
describe("one request per item in flight", () => {
it("queues a restart asked for while the start is out, and sends only the LAST target", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
for (let t = 100; t <= 2000; t += 100) engine.seek("A", t);
expect(asks.map((a) => a.startAt)).toEqual([0]);
await asks[0]!.resolve(hls("A"));
expect(asks.map((a) => a.startAt)).toEqual([0, 2000]);
});
it("drops the stale answer it queued behind — it never attaches", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
engine.seek("A", 500);
await asks[0]!.resolve(hls("A", 0));
expect(hlses).toHaveLength(0);
await asks[1]!.resolve(hls("A", 500));
expect(hlses).toHaveLength(1);
expect(hlses[0]!.loaded).toBe("/hls/A/index.m3u8?t=500");
expect(media.src).toBe("");
});
it("still sends the queued target when the in-flight request fails, and shows no error for it", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 700);
await asks[0]!.reject({ status: 500 });
expect(asks.map((a) => a.startAt)).toEqual([0, 700]);
expect(engine.getSnapshot().error).toBeNull();
});
it("sends another item's request at once, and drops the first item's queue", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
engine.start("B", 0);
expect(asks.map((a) => [a.id, a.startAt])).toEqual([
["A", 0],
["B", 0],
]);
await asks[0]!.resolve(hls("A"));
await asks[1]!.resolve(direct("B"));
expect(asks).toHaveLength(2); // A's 900 is gone with A, whichever answer settles first
});
it("never sends a queued target of an item the viewer left before the next one started", async () => {
// The next item's detail may still be loading: nothing replaced the queue yet.
const { engine, asks } = setup();
engine.start("A", 0);
engine.seek("A", 900);
engine.leaveItem();
await asks[0]!.resolve(hls("A"));
expect(asks).toHaveLength(1);
});
it("reads the track parameters when the queued request is SENT", async () => {
const { engine, asks, state } = setup();
engine.start("A", 100);
engine.start("A", 100); // an audio switch while the start is out
state.params = { multi: false, audio: 1, aoff: 0 } as never;
await asks[0]!.resolve(hls("A", 100));
expect(asks[1]!.params.audio).toBe(1);
});
});
describe("answers that land late", () => {
it("are dropped after the viewer moved to another item — and the server session is stopped", async () => {
const { engine, asks, hlses, stops } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(hlses).toHaveLength(0);
expect(stops).toEqual([["A", "sA"]]);
});
it("are dropped after the player closed", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
engine.destroy(media as unknown as HTMLMediaElement);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("");
});
it("land again once a StrictMode remount revived the engine", async () => {
const { engine, asks, media } = setup();
engine.destroy(media as unknown as HTMLMediaElement);
engine.revive();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
expect(media.src).toBe("/media/A");
});
it("a superseded direct item's loadedmetadata does not seek the next one", async () => {
const { engine, asks, media } = setup();
engine.start("A", 1200);
await asks[0]!.resolve(direct("A"));
engine.leaveItem();
engine.start("B", 0);
await asks[1]!.resolve(direct("B"));
media.fire("loadedmetadata");
expect(media.currentTime).toBe(0);
});
});
describe("the server session", () => {
it("is stopped when the viewer leaves the item, and when the player closes", async () => {
const { engine, asks, stops, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
engine.start("B", 0);
await asks[1]!.resolve(hls("B", 0, "sB"));
engine.destroy(media as unknown as HTMLMediaElement);
expect(stops).toEqual([
["A", "sA"],
["B", "sB"],
]);
});
it("is not stopped by a restart of the same item — the server replaces it itself", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000, "s2"));
expect(stops).toEqual([]);
});
it("is stopped when a direct stream of the same item replaces it", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "s1"));
engine.start("A", 0); // an A/V offset back at 0 plays the file directly
await asks[1]!.resolve(direct("A"));
expect(stops).toEqual([["A", "s1"]]);
});
it("is stopped when Safari plays it natively too (no hls.js)", async () => {
const { engine, asks, stops, media } = setup({ createHls: () => null });
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
expect(media.src).toBe("/hls/A/index.m3u8");
engine.leaveItem();
expect(stops).toEqual([["A", "sA"]]);
});
it("is stopped on an unload, but not when the page goes into the back/forward cache", async () => {
const { engine, asks, stops } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0, "sA"));
engine.pageHidden(true);
expect(stops).toEqual([]);
engine.pageHidden(false);
expect(stops).toEqual([["A", "sA"]]);
});
});
describe("a failed session request", () => {
it.each([
[404, "notFound"],
[501, "transcode"],
[500, "generic"],
] as const)("%s says %s", async (status, error) => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status });
expect(engine.getSnapshot().error).toBe(error);
});
it("keeps the old stream's clock after a failed seek-restart, not after a failed move", async () => {
const { engine, asks } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
engine.seek("A", 2000);
expect(engine.clockLive).toBe(false);
await asks[1]!.reject({ status: 500 });
expect(engine.clockLive).toBe(true);
engine.leaveItem();
engine.start("B", 0);
await asks[2]!.reject({ status: 404 });
expect(engine.clockLive).toBe(false);
});
});
describe("something that never arrives (a fatal 404)", () => {
it("is reclaimed where the viewer is", async () => {
const { engine, asks, hlses, state } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
state.position = 1299;
hlses[0]!.lost404();
expect(asks[1]!.startAt).toBe(1299);
});
it(`gives up after ${MAX_SESSION_RECLAIMS} reclaims inside the window, and says so`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("rolls the window: losses closer than it keep counting, however slow", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS * 0.6);
}
expect(engine.getSnapshot().error).toBe("generic");
});
it("starts over for a loss long after the last one, and for a new item", async () => {
const { engine, asks, hlses, advance } = setup();
engine.start("A", 600);
for (let i = 0; i < 4; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.lost404();
advance(RECLAIM_WINDOW_MS + 1);
}
expect(engine.getSnapshot().error).toBeNull();
const fresh = setup();
fresh.engine.start("A", 0);
for (let i = 0; i < MAX_SESSION_RECLAIMS; i++) {
await fresh.asks[i]!.resolve(hls("A"));
fresh.hlses[i]!.lost404();
}
fresh.engine.newItem();
await fresh.asks[MAX_SESSION_RECLAIMS]!.resolve(hls("A"));
fresh.hlses[MAX_SESSION_RECLAIMS]!.lost404();
expect(fresh.engine.getSnapshot().error).toBeNull();
});
it("keeps the start's own intent to play when it was lost before it ever played", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.lost404(); // media is still paused: not the viewer's pause
await asks[1]!.resolve(hls("A", 600));
hlses[1]!.handlers.onManifestParsed();
expect(media.plays).toBe(1);
});
});
describe("a session the server says is gone (410)", () => {
async function playing() {
const s = setup();
s.engine.start("A", 600);
await s.asks[0]!.resolve(hls("A", 600));
s.hlses[0]!.handlers.onManifestParsed();
s.media.fire("canplay");
s.state.position = 1299;
return s;
}
it("is reclaimed at the first answer, not after hls.js's half minute of retries", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
expect(asks[1]!.startAt).toBe(1299);
});
it("keeps the running picture until the new session answers", async () => {
const { engine, hlses } = await playing();
expect(engine.getSnapshot().ready).toBe(true);
hlses[0]!.gone();
expect(engine.getSnapshot().ready).toBe(true);
});
it("asks once, whatever the same instance still reports", async () => {
const { asks, hlses } = await playing();
hlses[0]!.gone();
hlses[0]!.gone();
hlses[0]!.lost(410);
expect(asks).toHaveLength(2);
});
it(`shares the reclaim budget, and says so after ${MAX_SESSION_RECLAIMS}`, async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 600);
for (let i = 0; i <= MAX_SESSION_RECLAIMS; i++) {
await asks[i]!.resolve(hls("A", 600));
hlses[i]!.gone();
}
expect(asks).toHaveLength(MAX_SESSION_RECLAIMS + 1);
expect(engine.getSnapshot().error).toBe("generic");
});
it("keeps a seek made while it is out, inside what the old session still reports", async () => {
const { engine, asks, hlses, media } = await playing();
media.seekEnd = 800;
hlses[0]!.gone();
media.currentTime = 42;
engine.seek("A", 1250);
expect(media.currentTime).toBe(42); // the outgoing media stays where it is
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it("leaves a segment that is merely not there yet to hls.js's retries", async () => {
const { engine, asks, hlses } = await playing();
hlses[0]!.retrying(404);
expect(asks).toHaveLength(1);
expect(engine.getSnapshot().error).toBeNull();
});
});
describe("a server that is restarting (a deploy)", () => {
// The gateway answers 502 while the app restarts, and nothing at all may answer (status 0): the
// stream is not broken, the server is away. The buffer keeps playing meanwhile.
it("keeps a seek made while the retry is out", async () => {
const { engine, asks, hlses, state, fire, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
media.seekEnd = 800;
state.position = 1299;
hlses[0]!.lost(502);
expect(fire()).toBe(true);
engine.seek("A", 1250);
await asks[1]!.resolve(hls("A", 1299));
expect(asks.map((a) => a.startAt)).toEqual([600, 1299, 1250]);
});
it.each([502, 503, 504, 0, null])(
"is waited out on %s: no error, and the stream comes back where the viewer is",
async (status) => {
const { engine, asks, hlses, state, fire, media } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
hlses[0]!.handlers.onManifestParsed();
state.position = 1299;
hlses[0]!.lost(status);
expect(engine.getSnapshot().error).toBeNull();
expect(asks).toHaveLength(1); // not at once: the server is still away
expect(fire()).toBe(true);
expect(asks[1]!.startAt).toBe(1299);
// The buffered picture is still playing while the server is asked: no "Loading…" over it.
expect(engine.getSnapshot().ready).toBe(true);
await asks[1]!.reject({ status: 502 }); // still away
expect(engine.getSnapshot().error).toBeNull();
state.position = 1310;
expect(fire()).toBe(true);
await asks[2]!.resolve(hls("A", 1310));
expect(hlses[1]!.loaded).toContain("?t=1310");
expect(engine.getSnapshot().ready).toBe(false); // the new stream is loading now
hlses[1]!.handlers.onManifestParsed();
expect(engine.getSnapshot()).toMatchObject({ ready: true, error: null });
expect(media.plays).toBeGreaterThan(0);
},
);
it("is waited out when the very first request finds it away", async () => {
const { engine, asks, fire } = setup();
engine.start("A", 0);
await asks[0]!.reject({ status: 0 });
expect(engine.getSnapshot()).toMatchObject({ ready: false, error: null });
expect(fire()).toBe(true);
await asks[1]!.resolve(hls("A"));
expect(engine.getSnapshot().error).toBeNull();
});
it("asks less and less often, and says so once it has been away too long", async () => {
const { engine, asks, hlses, fire, timers } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.lost(502);
let i = 1;
while (fire()) await asks[i++]!.reject({ status: 502 });
const delays = timers.map((t) => t.ms);
expect(delays[1]!).toBeGreaterThan(delays[0]!);
expect(delays.reduce((a, b) => a + b, 0)).toBeGreaterThan(AWAY_WINDOW_MS);
expect(engine.getSnapshot().error).toBe("generic");
});
it("starts the wait over after the server came back", async () => {
const { engine, asks, hlses, fire, advance } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.lost(502);
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();
});
it("drops a pending retry once the viewer seeks or leaves the item", async () => {
const { engine, asks, hlses, fire } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
hlses[0]!.lost(502);
engine.seek("A", 2000); // the viewer's own restart goes out instead
expect(asks).toHaveLength(2);
fire();
expect(asks).toHaveLength(2);
await asks[1]!.reject({ status: 502 });
engine.leaveItem();
fire();
expect(asks).toHaveLength(2);
});
it("still says a real failure at once", async () => {
const { engine, asks, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
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);
});
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", () => {
it("goes into the pending session, and never plays the outgoing media", async () => {
const { engine, asks, media, hlses } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.fire("loadedmetadata");
expect(media.paused).toBe(false);
engine.seek("A", 2000, false); // a restart while paused-by-request
expect(engine.requestPlay(false)).toBe(true);
expect(media.paused).toBe(true);
const plays = media.plays;
expect(engine.requestPlay(true)).toBe(true);
expect(media.plays).toBe(plays); // the outgoing stream stays silent
expect(engine.shownPlaying()).toBe(true);
await asks[1]!.resolve(hls("A", 2000));
hlses[0]!.handlers.onManifestParsed();
expect(media.plays).toBe(plays + 1);
});
});
describe("seeking", () => {
it("a restart asked for before the new session has played keeps that session's intent to play", async () => {
// Attaching a new session empties the media: it reads paused until hls.js parses and plays. A
// seek in that window read the paused element as "the viewer paused", and the next session
// attached paused at 0:00 (measured: a burst of ← ending in a frozen frame).
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 0));
expect(media.paused).toBe(true); // attached, not played yet
engine.seek("A", 2000);
await asks[1]!.resolve(hls("A", 2000));
hlses[1]!.handlers.onManifestParsed();
expect(media.paused).toBe(false);
});
it("is native inside the attached session's loaded region", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 650);
expect(asks).toHaveLength(1);
expect(media.currentTime).toBeCloseTo(650 - (600 - HLS_SUB_LAG));
});
it("restarts past it, and while the clock is not the current session's", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A", 600));
media.seekEnd = 100;
engine.seek("A", 900);
expect(asks.map((a) => a.startAt)).toEqual([0, 900]);
engine.seek("A", 650); // inside the OLD buffer, but a restart is pending
await asks[1]!.resolve(hls("A", 900));
expect(asks.map((a) => a.startAt)).toEqual([0, 900, 650]);
});
it("clamps the target to the item", () => {
const { engine } = setup();
expect(engine.clamp(-5)).toBe(0);
expect(engine.clamp(99_999)).toBe(2999);
});
});
describe("the session's clock", () => {
it("folds hls.js's lag into a resumed HLS session's start, not a direct one's", async () => {
const { engine, asks } = setup();
engine.start("A", 600);
await asks[0]!.resolve(hls("A", 600));
expect(engine.getSnapshot().sessionK).toBeCloseTo(600 - HLS_SUB_LAG);
engine.start("A", 0);
await asks[1]!.resolve(direct("A"));
expect(engine.getSnapshot().sessionK).toBe(0);
});
it("is not trusted between asking for a session and its answer", async () => {
const { engine, asks, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(direct("A"));
media.currentTime = 50;
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBe(50);
engine.releaseClock();
expect(engine.absTime(media as unknown as HTMLMediaElement)).toBeNull();
});
it("answers 'where the viewer is' with the asked start before playback has ticked", () => {
const { engine, state } = setup();
engine.start("A", 1200);
expect(engine.playhead()).toBe(1200);
state.position = 1250;
expect(engine.playhead()).toBe(1250);
});
});
describe("audio on a multi-rendition session", () => {
it("restores the chosen track once hls.js lists the renditions, and switches client-side", async () => {
const { engine, asks, hlses, state } = setup();
state.params = { multi: true, audio: 1, aoff: 0 } as never;
expect(engine.setAudioTrack(1)).toBe(false); // nothing attached yet: the caller restarts
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onAudioTracksUpdated();
expect(hlses[0]!.audioTrack).toBe(1);
expect(engine.setAudioTrack(0)).toBe(true);
expect(hlses[0]!.audioTrack).toBe(0);
expect(asks).toHaveLength(1);
});
});
it("tells React only when the snapshot really changed", async () => {
const { engine, asks } = setup();
const seen = vi.fn();
engine.subscribe(seen);
engine.start("A", 0);
const after = seen.mock.calls.length;
engine.start("A", 0); // same ready/error/transcode — nothing new to render
expect(seen.mock.calls.length).toBe(after);
await asks[0]!.resolve(direct("A"));
});
describe("an item the viewer comes back to while its request is still out", () => {
it("waits for that request instead of sending a second one beside it (A → B → A)", async () => {
// The in-flight request was tracked in one slot: B's request took it, and the way back to A
// sent A2 next to the A1 still out — two spawns for one item, and the loser's answer could
// name the winner's session for the stop.
const { engine, asks } = setup();
engine.start("A", 0);
engine.leaveItem();
engine.start("B", 0);
engine.leaveItem();
engine.start("A", 0);
expect(asks.map((a) => a.id)).toEqual(["A", "B"]);
await asks[0]!.resolve(hls("A", 0, "s1"));
expect(asks.map((a) => a.id)).toEqual(["A", "B", "A"]);
});
});
describe("a session that was replaced before its manifest parsed", () => {
it("neither marks the player ready nor plays the outgoing media", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.seek("A", 900); // nothing seekable yet: a restart
const plays = media.plays;
hlses[0]!.handlers.onManifestParsed(); // the replaced instance parses late
expect(engine.getSnapshot().ready).toBe(false);
expect(media.plays).toBe(plays);
});
});
describe("a pause given after the answer, before the media can play", () => {
it("stands: neither the manifest nor the first canplay plays it again", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
engine.requestPlay(false);
hlses[0]!.handlers.onManifestParsed();
expect(media.paused).toBe(true);
media.fire("canplay");
expect(media.paused).toBe(true);
});
it("stands after the manifest played it too", async () => {
const { engine, asks, hlses, media } = setup();
engine.start("A", 0);
await asks[0]!.resolve(hls("A"));
hlses[0]!.handlers.onManifestParsed();
engine.requestPlay(false);
media.fire("canplay");
expect(media.paused).toBe(true);
});
});