Files
siftlode/frontend/src/lib/hlsEngine.test.ts
T
peter 0a35f2a3b1 fix(player): a stale session answer can no longer stop the playback that won
- The engine tracks the request out per item: A, then B, then back to A sent a second request
  for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
  request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
  outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
  previous item under the next one's title.
2026-10-07 23:16:25 +02:00

571 lines
19 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 {
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;
constructor(readonly handlers: HlsHandlers) {}
load(url: string) {
this.loaded = url;
}
destroy() {
this.destroyed = true;
}
getAudioTrack() {
return this.audioTrack;
}
setAudioTrack(track: number) {
this.audioTrack = track;
}
levelInfo() {
return { index: 0, total: 1 };
}
lost404() {
this.handlers.onError({
fatal: true,
network: true,
status: 404,
type: "networkError",
details: "fragLoadError",
});
}
}
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][] = [];
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,
...over,
});
return {
engine,
media,
asks,
hlses,
stops,
state,
advance: (ms: number) => void (clock += ms),
};
}
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("a session the server no longer has (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("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);
});
});