fix(plex): the reclaim budget bounds what it says it bounds, and a refused finish is not performed anyway

`MAX_SESSION_RECLAIMS` was cleared by the first fragment of any session, and a
reclaimed session essentially always delivers one — `start_session` does not
return until its first segment exists. So the budget only ever bit where a
session parsed its manifest and then died before a single fragment, which is not
either case its own comment names. Measured: 40 loss cycles produced 41 sessions
and never an error. A transcode falling behind realtime, or two viewers of one
rating_key taking turns, respawned ffmpeg indefinitely — the round-5 behaviour,
still there behind the guard added to stop it.

It counts within a rolling window now. Two losses inside two minutes is a
session that keeps being lost; one an hour apart is not. And the window is reset
per item, because the player stays mounted across a queue advance — otherwise a
budget spent on one episode would deny the next its first legitimate reclaim.

Separately: `finish`'s rollback re-performed the write it was taking back. On
the play-to-the-end path the position it restores IS the end of the media, and
the backend reads a position within its finish margin as a finish — so the
branch named "take the finish back" set watched, cleared the position and
scrobbled to Plex through a second route, while the caller's error toast was
already on screen saying the mark had failed. There is no resume point at the
end of the media to restore; the cache drop is the whole of the rollback there.
The credits-skip caller captures a mid-episode position and still restores it,
which is the case that needs it.

7 tests, mutation-checked: the FRAG_LOADED reset, the missing window, and the
unguarded rollback each red on the test that names them.
This commit is contained in:
2026-09-12 05:50:11 +02:00
parent f8a31d48cf
commit d89d887a9b
4 changed files with 102 additions and 25 deletions
+21 -9
View File
@@ -75,10 +75,16 @@ import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/lay
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));
// Consecutive reclaims of a 404'd HLS session before the player stops and says so. Two covers the
// real case (the session was reaped, once) without letting a server that cannot serve the stream
// keep the viewer on a spinner (see the ERROR handler).
// Reclaims of a 404'd HLS session, within `RECLAIM_WINDOW_MS`, before the player stops and says so.
// Two covers the real case (the session was reaped, once) without letting a server that cannot serve
// the stream keep the viewer on a spinner (see the ERROR handler).
const MAX_SESSION_RECLAIMS = 2;
// A ROLLING window, because "consecutive" could not bound anything: the budget used to be cleared by
// the first fragment of any session, and a reclaimed session essentially always delivers one —
// `start_session` does not return until its first segment exists. So both cases the ERROR handler
// names restarted ffmpeg forever anyway (measured: 40 loss cycles → 41 sessions, never an error).
// Two losses inside two minutes is a session that keeps being lost; one an hour apart is not.
const RECLAIM_WINDOW_MS = 120_000;
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
@@ -160,9 +166,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Where the current session was ASKED to start — set before the request goes out, so the subtitle
// poll has a position before `sessionK` (the session's answer) and before the first timeupdate.
const loadStartRef = useRef(0);
// How many times in a row this player may reclaim a session the server answered 404 for, before it
// stops and says playback failed. Reset by the first fragment that loads (see FRAG_LOADED).
const restartsRef = useRef({ n: 0 });
// How often this player may reclaim a session the server answered 404 for, before it stops and
// says playback failed. `at` is when the window started; the item-start effect zeroes it, so a
// budget spent on one episode cannot deny the next one its first legitimate reclaim.
const restartsRef = useRef({ n: 0, at: 0 });
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
@@ -376,9 +383,6 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// rolling window starts empty rather than carrying the previous session's numbers over.
fragMeterRef.current.reset();
hls.on(Hls.Events.FRAG_LOADED, (_e, data) => {
// A fragment actually arriving is the only proof a reclaimed session works; the budget is
// for a session that keeps being lost, not for one that was lost once an hour.
restartsRef.current.n = 0;
fragMeterRef.current.add({
type: data.frag.type,
bytes: data.frag.stats?.total || data.frag.stats?.loaded || 0,
@@ -429,6 +433,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
hls.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
// A new window: the last loss was long enough ago that this is a fresh incident, not the
// same session being lost over and over.
if (now - restartsRef.current.at > RECLAIM_WINDOW_MS)
restartsRef.current = { n: 0, at: now };
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
loadSession(playhead(absRef, loadStartRef), !video.paused);
@@ -504,6 +513,9 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
useEffect(() => {
if (!detail || detail.id !== id || startedForRef.current === id) return;
startedForRef.current = id;
// A fresh item is a fresh incident: the reclaim budget must not carry over from the previous
// one (the player stays mounted across a queue advance — `go()` only changes `id`).
restartsRef.current = { n: 0, at: 0 };
// Restore the per-account audio/subtitle language choice, resolved to THIS item's tracks. Set the
// audio ref synchronously so the loadSession below picks the right track on the first request.
const aOrd = audioOrdForLang(detail.audio_streams ?? [], prefs.audioLang);