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.
969 lines
34 KiB
TypeScript
969 lines
34 KiB
TypeScript
// 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);
|
||
});
|
||
});
|