refactor(player): the Plex session machine is a tested engine, one request per item in flight

PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.

- A seek, track change or reclaim asked for while a session request is out replaces the queued
  target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
  requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
  (POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
  the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
  media as the viewer's pause.
This commit is contained in:
2026-10-06 22:31:48 +02:00
parent 23f16b69df
commit c1058f1d5a
14 changed files with 1492 additions and 528 deletions
@@ -502,11 +502,18 @@ describe("restarting the session before playback has ticked", () => {
{ ord: 1, label: "Magyar", language: "hu", default: false },
],
} as unknown as PlexItemDetail;
mount({ details: { A: twoAudio }, sessions: { A: () => new Promise(() => {}) } });
const answers = [deferred(), deferred(), deferred()];
let calls = 0;
mount({ details: { A: twoAudio }, sessions: { A: () => answers[calls++]!.promise } });
await waitFor(() => expect(startsAsked("A")).toEqual([1200]));
fireEvent.keyDown(window, { key: "a" });
// One request per item in flight: the switch waits for the start's answer…
await act(async () => answers[0]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(2));
fireEvent.keyDown(window, { key: "ArrowRight" });
await act(async () => answers[1]!.resolve(direct("A")));
await waitFor(() => expect(startsAsked("A")).toHaveLength(3));
// …and each restart asked where the resume put the viewer, not at the unticked 0:00.
expect(startsAsked("A")).toEqual([1200, 1200, 1210]);
});
});
@@ -529,12 +536,41 @@ it("a seek made while a restart is pending wins over it", async () => {
await playingAt("/media/A", 95);
fireEvent.keyDown(document.body, { key: "ArrowRight" }); // the 10 s step // 105: past the buffer → restart
fireEvent.keyDown(document.body, { key: "ArrowLeft" }); // 95: inside the OLD buffer
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105]));
await tick();
expect(startsAsked("A")).toEqual([0, 105]); // 95 waits for the 105 answer: one request in flight
await act(async () => pending.resolve({ mode: "direct", url: "/media/A2", start_s: 0 } as never));
await waitFor(() => expect(startsAsked("A")).toEqual([0, 105, 95]));
await tick();
expect(video().getAttribute("src")).toBe("/media/A"); // the superseded 105 answer never attached
});
it("a burst of seeks past the buffer asks the server once more, not once per press", async () => {
// Twenty presses of ← at key-repeat pace past the buffer used to be twenty session requests —
// twenty ffmpeg spawns on the server — and five seconds before the picture moved again (measured
// on localdev, 2026-10-06).
let calls = 0;
const restart = deferred();
mount({
details: { A: item("A", 1200) },
sessions: {
A: () => {
calls++;
if (calls === 1) return Promise.resolve(direct("A"));
return calls === 2 ? restart.promise : new Promise(() => {});
},
},
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
await playingAt("/media/A", 1200);
for (let i = 0; i < 20; i++) fireEvent.keyDown(document.body, { key: "ArrowLeft" });
await tick();
expect(startsAsked("A")).toEqual([1200, 1190]);
await act(async () => restart.resolve(direct("A")));
// The queued target is the LAST press's, asked once.
await waitFor(() => expect(startsAsked("A")).toEqual([1200, 1190, 1000]));
});
it("a direct-play item left before its metadata does not seek the next one", async () => {
mount({
details: { A: item("A", 1200), B: item("B") },