fix(plex): a server that restarts mid-episode is waited out, not reported as a stream that cannot play

During a deploy the gateway answers 502 (or nothing answers at all) until the app is back. The
player took the first such answer for a broken stream: it said "Playback couldn't start" over a
picture still playing from its buffer, stopped loading, and never asked again, so the episode froze
where the buffer ended with the error still up. Now a 502/503/504 or no answer is the server being
away: the buffer keeps playing undisturbed, the session is asked for again where the viewer is
(2, 4, 8, then every 15 s), and the error is said only after three minutes away. A seek or another
item replaces a pending retry. Measured on localdev: a 45 s API stop now resumes at the viewer's
position, where it froze with the error before.
This commit is contained in:
2026-10-23 14:42:17 +02:00
parent cd5b26efa7
commit 38c71b7b4f
3 changed files with 190 additions and 12 deletions
+64 -9
View File
@@ -25,6 +25,15 @@ export const MAX_SESSION_RECLAIMS = 2;
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
export const RECLAIM_WINDOW_MS = 120_000;
// A server that is restarting (a deploy) is away, not broken: the gateway in front of it answers
// 502/503/504 while the app comes back, and nothing at all may answer (0). The stream it served is
// gone with it, but the buffer keeps playing — so the engine asks again where the viewer is, less and
// less often, and says it cannot play only once the server has been away this long. It used to say
// so at the first 502, over a picture still playing from the buffer, and never asked again.
export const AWAY_WINDOW_MS = 180_000;
const AWAY_RETRY_MS = [2_000, 4_000, 8_000, 15_000];
const SERVER_AWAY = new Set([0, 502, 503, 504]);
/** Why a session could not play, in the three buckets the player has a sentence for. */
type EngineError = "transcode" | "notFound" | "generic";
@@ -88,7 +97,10 @@ export interface EngineDeps {
/** The item's duration, seconds (0 = unknown). */
duration(): number;
now(): number;
/** An HTTP status from a failed session request (undefined = not an HTTP error). */
/** Run `fn` after `ms`; the returned function cancels it. */
later(fn: () => void, ms: number): () => void;
/** An HTTP status from a failed session request (0 = nothing answered; undefined = not an HTTP
* error). */
statusOf(e: unknown): number | undefined;
/** A request for a session went out: the player clears its stall spinner. */
onLoadStart?(): void;
@@ -113,6 +125,7 @@ const UNCONNECTED: EngineDeps = {
position: () => 0,
duration: () => 0,
now: () => 0,
later: () => () => {},
statusOf: () => undefined,
};
@@ -121,6 +134,8 @@ interface Request {
startAt: number;
intent: Intent;
gen: number;
/** Asked for while the attached stream still plays from its buffer (see `start`). */
quiet: boolean;
}
export class HlsEngine {
@@ -170,6 +185,9 @@ export class HlsEngine {
private queued: Request | null = null;
// How often a session the server answered 404 for may be reclaimed (see `onHlsError`).
private reclaims = { n: 0, at: 0 };
// Since when the server has been away, and how often it was asked since (see `serverAway`).
private away: { since: number; tries: number } | null = null;
private cancelRetry: () => void = () => {};
// Where the current — or still in-flight — session was ASKED to start. Written before the request
// goes out, so "where the viewer is" has an answer before the first timeupdate.
private loadStart = 0;
@@ -244,12 +262,14 @@ export class HlsEngine {
* (the player stays mounted across a queue advance). */
newItem(): void {
this.reclaims = { n: 0, at: 0 };
this.away = null;
}
/** Leaving the item: a session still in flight for it must not land after it, its stream goes,
* and the server stops producing it. */
leaveItem(): void {
this.gen++; // a queued request of this item is now stale, and is never sent
this.cancelRetry();
this.detachSession();
this.detachSession = () => {};
this.dropHls();
@@ -262,21 +282,28 @@ export class HlsEngine {
}
/** (Re)start `id` at `startAt` seconds. Another item's request goes out at once; one for the item
* already being asked for waits for that answer (see the header). */
start(id: string, startAt: number, play = true): void {
* already being asked for waits for that answer (see the header). `quiet`: the attached stream is
* still playing from its buffer (the server went away under it), so it stays the player's until
* the answer replaces it — no "Loading…" over a running picture, its clock still true. */
start(id: string, startAt: number, play = true, quiet = false): void {
if (!this.deps.media()) return;
this.cancelRetry(); // this request is the one a pending retry would have sent
const gen = ++this.gen;
this.detachSession();
this.detachSession = () => {};
this.live = false;
const intent: Intent = { play };
this.pending = intent;
this.loadStart = startAt;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
const req: Request = { id, startAt, intent, gen };
if (quiet) {
this.set({ error: null });
} else {
this.live = false;
this.deps.onLoadStart?.();
// Clear the prior session's transcode flag up front, so the cold-start overlay cannot show a
// stale "Preparing stream…" for a session not known yet — set honestly from the answer.
this.set({ ready: false, error: null, transcode: false });
}
const req: Request = { id, startAt, intent, gen, quiet };
if (this.inFlight.has(id)) this.queued = req;
else this.send(req);
}
@@ -313,7 +340,9 @@ export class HlsEngine {
this.live = this.attachedId === req.id;
// Say WHY playback can't start instead of an eternal spinner. 404 = the physical file isn't
// reachable (a missing media mount or a wrong path map); 501 = the codec needs a full transcode.
// A server that is away is asked again instead, for a while.
const status = this.deps.statusOf(e);
if (status !== undefined && SERVER_AWAY.has(status) && this.serverAway(req)) return;
this.set({ error: status === 501 ? "transcode" : status === 404 ? "notFound" : "generic" });
}
@@ -327,7 +356,10 @@ export class HlsEngine {
}
const media = this.deps.media();
if (!media) return;
this.away = null; // the server answered: a later absence is a new one
const { intent, startAt, id } = req;
// The stream that kept playing is replaced from here: the new one loads like any other.
if (req.quiet) this.set({ ready: false });
const isTranscode = sess.mode === "hls" && !!sess.transcode;
this.hw = isTranscode ? (sess.hw ?? false) : null;
this.tonemap = isTranscode ? (sess.tonemap ?? false) : null;
@@ -443,6 +475,7 @@ export class HlsEngine {
private onHlsError(req: Request, media: HTMLMediaElement, e: HlsFailure): void {
this.deps.onHlsError?.(e);
if (!e.fatal || !this.current(req)) return;
if (e.network && SERVER_AWAY.has(e.status ?? 0) && this.serverAway(req)) return;
if (e.network && e.status === 404) {
const now = this.deps.now();
// A new window when the last loss was long enough ago. The anchor moves with EVERY loss: left
@@ -462,6 +495,28 @@ export class HlsEngine {
this.set({ error: "generic" });
}
/** The server is away (see `AWAY_WINDOW_MS`): ask for `req`'s item again where the viewer is,
* after a delay that grows with each try. False once it has been away too long — the caller then
* says it cannot play. A restart the viewer asks for meanwhile (a seek, another item) replaces the
* retry: `start` and `leaveItem` cancel it, and it is dropped if the generation moved on. */
private serverAway(req: Request): boolean {
const now = this.deps.now();
const away = (this.away ??= { since: now, tries: 0 });
if (now - away.since > AWAY_WINDOW_MS) return false;
const ms = AWAY_RETRY_MS[Math.min(away.tries, AWAY_RETRY_MS.length - 1)]!;
away.tries += 1;
const gen = this.gen;
this.cancelRetry();
this.cancelRetry = this.deps.later(() => {
this.cancelRetry = () => {};
const media = this.deps.media();
if (!this.alive || this.gen !== gen || !media) return;
// Quiet while what was attached had played: its buffer may still be running.
this.start(req.id, this.playhead(), this.wantsPlay(media), this.snapshot.ready);
}, ms);
return true;
}
/** Where the viewer is: the ticked position, or — before playback has ticked (a session still
* loading, or a resumed one that has stayed paused) — where the session was ASKED to start.
* Anything that restarts "here" must use this: reading the ticked 0 restarted a resume at 20:00