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
+9
View File
@@ -412,6 +412,7 @@ export interface PlexFacets {
export interface PlexPlaySession {
mode: "direct" | "hls";
url: string;
id?: string; // hls only: the server session, named when it is no longer wanted (plexStopSession)
start_s: number;
transcode?: boolean; // hls only: video re-encoded (non-h264 source) vs stream-copied (remux)
hw?: boolean; // hls transcode only: ran on the VAAPI hardware encoder (else libx264 software)
@@ -721,6 +722,14 @@ export const plexApi = {
// cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
// (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
// replicate req()'s credentials + account header (no retry — there's no page left to retry on).
// The player no longer wants this HLS session: its ffmpeg stops now instead of at the idle reaper.
// A keepalive POST, so it also goes out from a page being unloaded. The server only stops it while
// it is still the viewer's registered session for the item.
plexStopSession: (id: string, sessionId: string): void =>
beacon(
`/api/plex/stream/${encodeURIComponent(id)}/session/${encodeURIComponent(sessionId)}/stop`,
{},
),
plexProgressBeacon: (
id: string,
position_seconds: number,