fix(plex): the player restarts a reaped session, finishes a played-through item as watched, and starts each item once

Deep-review round 4, the Plex player.

- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
  hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
  now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
  its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
  credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
  patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
  as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
  playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
  session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
  seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
  language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
This commit is contained in:
2026-09-11 23:18:33 +02:00
parent 31dc4184f5
commit 6a92585098
4 changed files with 552 additions and 54 deletions
+59 -22
View File
@@ -84,20 +84,31 @@ export function useControlsVisibility(opts: {
// calls it itself BEFORE it moves to another item: by the time this effect's cleanup runs for the
// outgoing id, the position has already been reset for the incoming one — so the cleanup found 0 and
// skipped, and every Next dropped the last stretch watched of the episode being left.
//
// `finish` says the item was played THROUGH (it ended, or the credits auto-skip left it) and the
// caller has marked it watched. From then on leaving it records no position: a checkpoint at the
// credits raced the watched mark (landing second, it left a watched row with a resume point and put
// that point back on Plex after the scrobble), and the reopen patch wrote "new, at 49:55" into the
// cache — Prev within the next 30 s reopened the episode at its last seconds (or at the credits,
// which auto-skipped straight back). The server stores a watched item at position 0, so the patch
// says exactly that. Playing on from the finished frame takes the finish back.
export function usePlexProgress(opts: {
id: string;
qc: QueryClient;
videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>;
durationRef: MutableRefObject<number>;
}): { settle: () => void } {
}): { settle: () => void; finish: () => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
const finishRef = useRef<() => void>(() => {});
useEffect(() => {
let finished = false;
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
// Only final checkpoints get mirrored to a linked Plex account (backend-side debounce), so Plex
// sees the resume position when the user actually stops — not a timeline call every 10 seconds.
const flush = (final = false) => {
if (finished) return;
if (absRef.current > 0 && durationRef.current > 0)
api
.plexProgress(id, Math.floor(absRef.current), durationRef.current, final)
@@ -122,43 +133,72 @@ export function usePlexProgress(opts: {
// so without this the resume point lags up to 10s — e.g. a seek right before F5 was lost. Uses a
// keepalive beacon so the POST survives the unload (a final checkpoint — the user is leaving).
const onHide = () => {
if (absRef.current > 0 && durationRef.current > 0)
if (!finished && absRef.current > 0 && durationRef.current > 0)
api.plexProgressBeacon(id, Math.floor(absRef.current), durationRef.current, true);
};
window.addEventListener("pagehide", onHide);
const onPlayAgain = () => {
finished = false;
};
video?.addEventListener("play", onPlayAgain);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
// the SAME session read react-query's CACHED detail — whose position_seconds predated this watch —
// so the resume landed at the old spot (the progress GET could also race the POST). So leaving
// also patches the cached detail, and a reopen resumes from where we left off. (F5 was always fine:
// a reload starts with an empty cache and fetches the fresh position.) Safe to do only when leaving
// — patching while mounted would retrigger the loadSession effect and reseek mid-play. Reading the
// LATEST abs/duration is the whole point of the checkpoint (the resume point when leaving).
// a reload starts with an empty cache and fetches the fresh position.) Done when leaving, because
// that is when the position is final. Reading the LATEST abs/duration is the whole point of the
// checkpoint (the resume point when leaving).
const settle = () => {
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang());
if (finished) {
qc.setQueryData(key, (old) =>
old ? { ...old, status: "watched", position_seconds: 0 } : old,
);
return;
}
flush(true);
if (absRef.current > 0 && durationRef.current > 0) {
const pos = Math.floor(absRef.current);
// Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands.
qc.setQueryData(qk.plexItem(id, plexLang()), (old) =>
old ? { ...old, position_seconds: pos } : old,
);
qc.setQueryData(key, (old) => (old ? { ...old, position_seconds: pos } : old));
}
};
settleRef.current = settle;
finishRef.current = () => {
finished = true;
};
return () => {
window.clearInterval(iv);
window.clearTimeout(seekTimer);
window.removeEventListener("pagehide", onHide);
video?.removeEventListener("pause", onPauseFlush);
video?.removeEventListener("seeked", onSeekFlush);
video?.removeEventListener("play", onPlayAgain);
settle();
settleRef.current = () => {};
finishRef.current = () => {};
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
};
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
}, [id, qc, absRef, durationRef, videoRef]);
return { settle: useCallback(() => settleRef.current(), []) };
return {
settle: useCallback(() => settleRef.current(), []),
finish: useCallback(() => finishRef.current(), []),
};
}
/** Where the viewer is. `abs` only moves once playback has ticked — until then (a session still
* loading, or a resumed one that has stayed paused) it reads 0 while the film is minutes in, so the
* answer is where the session was ASKED to start. Anything that restarts the session "here" — an
* audio switch, an A/V offset, a keyboard seek — must use this: reading `abs` restarted a resume at
* 20:00 from 0:00, and the next checkpoint saved that (deep-review round 4). */
export function playhead(
absRef: MutableRefObject<number>,
loadStartRef: MutableRefObject<number>,
): number {
return absRef.current > 0 ? absRef.current : loadStartRef.current;
}
// Volume/mute for the Plex player's HTML5 <video> (0..1 float, independent .muted flag). Persists to
@@ -242,7 +282,8 @@ export function useAutoSkip(opts: {
autoSkipCredits: boolean;
autoSkipDelay: number;
nextId: string | null | undefined;
go: (id: string | null | undefined) => void;
/** `finished` = the item being left was played through (see usePlexProgress's `finish`). */
go: (id: string | null | undefined, finished?: boolean) => void;
seekTo: (t: number) => void;
videoRef: RefObject<HTMLVideoElement | null>;
}): {
@@ -292,7 +333,7 @@ export function useAutoSkip(opts: {
// Binge auto-advance: the episode we're leaving was watched through the credits, so mark IT
// (the current id, captured here — not the reactive id, which go() is about to change).
api.plexSetState(id, "watched").catch(() => {});
go(nextId);
go(nextId, true);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo],
@@ -468,10 +509,11 @@ export function usePlexTracks(opts: {
/* ignore */
}
} else {
loadSession(absRef.current); // single-audio: re-map the track server-side (session restart)
// Single audio (or the session has not answered yet): re-map the track server-side.
loadSession(playhead(absRef, loadStartRef));
}
},
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef],
[loadSession, detail, patchPrefs, audioRef, multiAudioRef, hlsRef, absRef, loadStartRef],
);
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
// (applied by the effect below), NO session restart. Off = null.
@@ -520,9 +562,9 @@ export function usePlexTracks(opts: {
patchPrefs({ audioOffset: v });
aoffRef.current = v;
if (aoffTimer.current) window.clearTimeout(aoffTimer.current);
aoffTimer.current = window.setTimeout(() => loadSession(absRef.current), 500);
aoffTimer.current = window.setTimeout(() => loadSession(playhead(absRef, loadStartRef)), 500);
},
[patchPrefs, loadSession, aoffRef, absRef],
[patchPrefs, loadSession, aoffRef, absRef, loadStartRef],
);
// --- is the selected track actually extractable yet? -------------------------------------------
@@ -541,12 +583,7 @@ export function usePlexTracks(opts: {
// answer is where the session was asked to start. Not `sessionK`: that only lands once the
// session request has answered, so the first poll of a fresh open read 0 from it, and after a
// move to the next item it still held the PREVIOUS item's start.
queryFn: () =>
api.plexSubtitleStatus(
id,
subOrd as number,
absRef.current > 0 ? absRef.current : loadStartRef.current,
),
queryFn: () => api.plexSubtitleStatus(id, subOrd as number, playhead(absRef, loadStartRef)),
enabled: subOrd != null,
// Keep polling until the track is COMPLETE, not just showable: each poll carries the current
// position, so seeking past the extracted window is what asks for the next one. A settled