fix(plex): a finished item is finished when the server says so, and a lost session is reclaimed a bounded number of times

Deep-review round 5, over round 4's own fixes.

- Round 4 added a `finish` latch so a played-through item records no checkpoint at the credits.
  On a NATURAL play-through it was raised a beat too late: per the HTML spec the browser sets
  `paused` and fires 'pause' BEFORE 'ended', so the pause checkpoint went out while the latch was
  still down and raced the watched mark — both requests read "not watched yet" from their own
  snapshot, so Plex counted one watch twice, and where Plex's reported duration runs more than the
  finish margin past the real media the checkpoint wrote a resume position AFTER the scrobble. The
  pause that ends the media no longer flushes. The regression test that was meant to cover this
  modelled the end as a bare 'ended' event — a browser that does not exist — and is now the real
  order.

- The watched mark was fired and forgotten (`.catch(() => {})`) while the latch patched the cached
  detail to watched / position 0 regardless, and round 4's once-per-id start guard meant the
  corrected detail arriving later was never used: an episode whose mark failed reopened at 0:00
  with a Watched badge the server disagreed with. `finish` now takes the in-flight write and takes
  itself back if the server refuses it — the real position is reported, the lying cache entry is
  dropped, and the failure is said out loud instead of swallowed. Both callers (the natural end
  and the credits auto-skip) hand it over.

- The reclaim of a 404'd HLS session had no bound. `parsed` cannot provide one — it belongs to one
  hls instance and every restart builds a fresh one — and the server answers 404 both for "the
  session is gone" and for "that segment is not there yet", so a transcode falling behind realtime,
  or two viewers of one item taking turns, respawned ffmpeg forever behind a spinner that never
  became an error. Two consecutive reclaims, reset by the first fragment that actually loads, then
  the honest failure.

Five regression tests, each mutation-checked red on the assertion it is named after.
This commit is contained in:
2026-09-12 03:47:42 +02:00
parent 41b652f8fd
commit 8a83bc0e9f
4 changed files with 194 additions and 20 deletions
@@ -153,6 +153,48 @@ describe("an HLS session the server no longer has", () => {
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
});
it("gives up and says so when the reclaimed session keeps 404ing", async () => {
// `parsed` cannot bound this: it lives on ONE hls instance and every restart builds a fresh one,
// so a session that parses its manifest and then 404s re-arms the guard every cycle. The server
// also answers 404 for "that segment is not there YET" (a transcode falling behind realtime),
// which is indistinguishable on the wire — so the player respawned ffmpeg forever behind a
// spinner that never became an error (round 5).
const session = mount();
const v = () => document.querySelector("video") as Media;
// The first session plus the two it is allowed to reclaim; the third 404 has nothing left.
for (let i = 0; i < 3; i++) {
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
v()._t = 700;
act(() => void v().dispatchEvent(new Event("timeupdate")));
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
}
await screen.findByText("plex.player.errGeneric");
// Two reclaims, then the honest answer — not a fourth, fifth, hundredth session.
expect(session).toHaveBeenCalledTimes(3);
});
it("starts the budget over once a fragment actually loads", async () => {
// A session lost once an hour is not a session that cannot be served; the only proof a reclaim
// worked is a fragment arriving.
const session = mount();
for (let i = 0; i < 4; i++) {
await waitFor(() => expect(hlsInstances).toHaveLength(i + 1));
act(() => hlsInstances[i]!.emit(Hls.Events.MANIFEST_PARSED));
const v = document.querySelector("video") as Media;
v._t = 700;
act(() => void v.dispatchEvent(new Event("timeupdate")));
act(() =>
hlsInstances[i]!.emit(Hls.Events.FRAG_LOADED, {
frag: { type: "main", duration: 6, stats: { total: 1000 } },
}),
);
act(() => hlsInstances[i]!.emit(Hls.Events.ERROR, lost404));
}
expect(screen.queryByText("plex.player.errGeneric")).toBeNull();
expect(session).toHaveBeenCalledTimes(5);
});
it("is a failure, not a restart, when the session never played", async () => {
// Otherwise a session that 404s from the start would restart itself in a loop.
const session = mount();
@@ -296,6 +296,17 @@ describe("leaving an item that was played through", () => {
const prefs = (p: object) =>
localStorage.setItem("siftlode.plexPlayerPrefs.7", JSON.stringify({ subLang: "en", ...p }));
// The end of the media, in the order the HTML spec mandates: the browser sets `paused`, fires
// 'pause', and only THEN fires 'ended'. Dispatching a bare 'ended' models a browser that does not
// exist, and hid the fact that the pause checkpoint goes out before the finish latch is up
// (deep-review round 5).
const endMedia = () => {
const el = video();
Object.defineProperty(el, "ended", { configurable: true, get: () => true });
act(() => void el.dispatchEvent(new Event("pause")));
act(() => void el.dispatchEvent(new Event("ended")));
};
it("settles it as watched when it ended, so Prev does not reopen it at its last seconds", async () => {
prefs({ upNext: false });
const { qc, progress } = mount({
@@ -305,7 +316,7 @@ describe("leaving an item that was played through", () => {
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
act(() => void video().dispatchEvent(new Event("ended")));
endMedia();
await screen.findByText("Title B");
await tick();
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
@@ -344,6 +355,60 @@ describe("leaving an item that was played through", () => {
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect([cached?.status, cached?.position_seconds]).toEqual(["watched", 0]);
});
it("takes the finish back when the server refuses the watched mark", async () => {
// The mark was fired and forgotten (`.catch(() => {})`) while the cache was patched to
// watched / position 0 regardless, and the once-per-id start guard meant the corrected detail
// arriving later was never used: the episode reopened at 0:00 with a Watched badge the server
// disagreed with (round 5).
prefs({ upNext: false });
const { qc, progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await playingAt("/media/A", 2995);
endMedia();
await screen.findByText("Title B");
await tick();
// The position the viewer actually reached is reported, not suppressed by a finish that the
// server never confirmed…
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
["A", 2995, 3000, true],
]);
// …and the cached detail is not left claiming watched: only the server is honest now.
const cached = qc.getQueryData<PlexItemDetail>(qk.plexItem("A", "en"));
expect(cached?.status).not.toBe("watched");
});
it("records a second viewing when the viewer plays on from the finished frame", async () => {
// `finished` is a latch that makes every checkpoint a no-op; the only thing that lowers it is
// the video's own 'play' event. Nothing tested that, so dropping the listener would have
// discarded a whole rewatch with the gate green (round 5).
prefs({ upNext: true });
const { progress } = mount({
details: { A: item("A"), B: item("B") },
sessions: { A: () => Promise.resolve(direct("A")), B: () => new Promise(() => {}) },
queue: ["A", "B"],
});
vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
await playingAt("/media/A", 2995);
endMedia(); // the up-next card is up; the item is finished
await tick();
progress.mockClear();
// The viewer cancels the card and rewinds: playing again takes the finish back.
const el = video() as Media;
Object.defineProperty(el, "ended", { configurable: true, get: () => false });
act(() => void el.dispatchEvent(new Event("play")));
el._t = 2400;
act(() => void el.dispatchEvent(new Event("timeupdate")));
act(() => void el.dispatchEvent(new Event("pause")));
await tick();
expect(progress.mock.calls.filter(([id, , , final]) => id === "A" && final)).toEqual([
["A", 2400, 3000, true],
]);
});
});
describe("restarting the session before playback has ticked", () => {
+41 -9
View File
@@ -27,6 +27,7 @@ import {
VolumeX,
} from "lucide-react";
import { api, HttpError } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import {
audioOrdForLang,
clamp01,
@@ -74,6 +75,11 @@ 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).
const MAX_SESSION_RECLAIMS = 2;
// 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
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
@@ -154,6 +160,9 @@ 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 });
const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
const { fs, toggleFs } = useFullscreen(wrapRef);
const [ready, setReady] = useState(false);
@@ -367,6 +376,9 @@ 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,
@@ -406,11 +418,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// its idle limit, a deploy) and the stream is simply gone, not broken. hls.js never retries a
// 4xx, so this used to end playback with "couldn't play" after the buffer ran out; a new
// session at the playhead is what closing and reopening the player did by hand.
//
// Bounded, because `parsed` cannot bound it: it belongs to ONE hls instance and every restart
// builds a fresh one, so it only ever blocks a session that died before its manifest. The
// server also answers 404 for "that segment is not there YET" — indistinguishable from "the
// session is gone" on the wire — so a transcode that falls behind realtime, or two viewers of
// one item taking turns reclaiming its session, restarted ffmpeg forever behind a spinner
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
loadSession(playhead(absRef, loadStartRef), !video.paused);
return;
if (restartsRef.current.n < MAX_SESSION_RECLAIMS) {
restartsRef.current.n += 1;
loadSession(playhead(absRef, loadStartRef), !video.paused);
return;
}
}
setLoadError(tRef.current("plex.player.errGeneric"));
});
@@ -657,13 +680,19 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
helpOpenRef,
});
const reportWatchedError = useCallback(
(e: unknown) => notifyActionError(e, tRef.current("plex.watchedFailed")),
[],
);
const go = useCallback(
(otherId: string | null | undefined, finished = false) => {
(otherId: string | null | undefined, watchedWrite?: Promise<unknown>) => {
if (otherId) {
// A played-through item settles as watched, with no checkpoint at the credits (see
// usePlexProgress's `finish`) — said BEFORE the pause below, whose own checkpoint on the
// 'pause' event can land before the player has re-rendered for the next item.
if (finished) finishProgress();
// 'pause' event can land before the player has re-rendered for the next item. The write is
// handed over so a REFUSED one takes the finish back instead of leaving the cache lying.
if (watchedWrite) finishProgress(watchedWrite);
// Stop the outgoing media BEFORE switching. If the next item fails to load (e.g. no local
// file), loadSession returns without replacing the <video> source, leaving the old episode
// still playing — it would then fire 'ended' against the NEW id and mark the wrong item
@@ -707,6 +736,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
go,
seekTo,
videoRef,
reportWatchedError,
});
// Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
@@ -737,9 +767,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// Only a genuine play-to-the-end counts. A next episode that failed to load never played
// (absRef 0) — its stray 'ended' must not mark anything watched or cascade another advance.
if (absRef.current <= 0) return;
api.plexSetState(id, "watched").catch(() => {});
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item.
finishProgress();
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedError);
// Whatever follows (the up-next card, the next item, closing) leaves a FINISHED item — unless
// the server refuses the mark, in which case `finish` takes itself back (see usePlexProgress).
finishProgress(watchedWrite);
// With a next item and the up-next pref on, surface the end-card (poster + countdown + Cancel)
// and let IT advance — binge stays in the viewer's control (S4/D6-A). The card only appears here,
// on play-to-the-end; credits-auto-skip advances earlier and this handler never fires for it.
@@ -751,7 +783,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
};
video.addEventListener("ended", onEnded);
return () => video.removeEventListener("ended", onEnded);
}, [id, nextId, go, onClose, finishProgress]);
}, [id, nextId, go, onClose, finishProgress, reportWatchedError]);
// Up-next card actions: advance = go to the next item (it was already marked watched on 'ended');
// cancel = dismiss and stay on the finished frame. Loading a new item closes any lingering card.
+45 -10
View File
@@ -98,10 +98,10 @@ export function usePlexProgress(opts: {
videoRef: RefObject<HTMLVideoElement | null>;
absRef: MutableRefObject<number>;
durationRef: MutableRefObject<number>;
}): { settle: () => void; finish: () => void } {
}): { settle: () => void; finish: (confirmed?: Promise<unknown>) => void } {
const { id, qc, videoRef, absRef, durationRef } = opts;
const settleRef = useRef<() => void>(() => {});
const finishRef = useRef<() => void>(() => {});
const finishRef = useRef<(confirmed?: Promise<unknown>) => void>(() => {});
useEffect(() => {
let finished = false;
// `final` = a settled checkpoint (pause / page-hide / leaving the player) vs. the periodic tick.
@@ -116,7 +116,18 @@ export function usePlexProgress(opts: {
};
const iv = window.setInterval(flush, 10000);
// Pausing is a settled position — push it to Plex now rather than waiting for unmount/unload.
const onPauseFlush = () => flush(true);
// Except the pause that ENDS the media: per the HTML spec the browser sets `paused` and fires
// `pause` BEFORE `ended`, so this ran while the finish latch was still down and sent a final
// checkpoint that raced the watched mark the 'ended' handler was about to send. Both requests
// then read "not watched yet" from their own snapshot and both scrobbled — Plex counted one
// watch twice — and where Plex's reported duration runs more than the finish margin past the
// real media, the checkpoint was not near-end either, so it wrote a resume position AFTER the
// scrobble. That race is what `finish` exists to prevent; on a natural play-through it was
// raised a beat too late (deep-review round 5).
const onPauseFlush = () => {
if (videoRef.current?.ended) return;
flush(true);
};
const video = videoRef.current;
video?.addEventListener("pause", onPauseFlush);
// A seek is a settled position too (S2: Plex is the source of truth, so it must see the new spot
@@ -164,8 +175,23 @@ export function usePlexProgress(opts: {
}
};
settleRef.current = settle;
finishRef.current = () => {
finishRef.current = (confirmed?: Promise<unknown>) => {
finished = true;
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
// watched write is then refused, the latch is taken back: the cached detail said watched /
// position 0 while the server still had the item unwatched at the last checkpoint, and the
// once-per-id start guard meant the corrected detail arriving later was never used — the
// episode reopened at 0:00 with a Watched badge the server disagreed with (round 5).
const pos = Math.floor(absRef.current);
const dur = durationRef.current;
void confirmed?.catch(() => {
finished = false;
if (pos > 0 && dur > 0) api.plexProgress(id, pos, dur, true).catch(() => {});
// `settle` may already have patched the detail to watched / 0 on the way out. Dropping that
// entry rather than invalidating it: an invalidated query with no observer keeps serving its
// stale data, and this one is a lie — no data is better, the next open fetches the truth.
qc.removeQueries({ queryKey: qk.plexItem(id, plexLang()) });
});
};
return () => {
window.clearInterval(iv);
@@ -185,7 +211,7 @@ export function usePlexProgress(opts: {
}, [id, qc, absRef, durationRef, videoRef]);
return {
settle: useCallback(() => settleRef.current(), []),
finish: useCallback(() => finishRef.current(), []),
finish: useCallback((confirmed?: Promise<unknown>) => finishRef.current(confirmed), []),
};
}
@@ -282,10 +308,13 @@ export function useAutoSkip(opts: {
autoSkipCredits: boolean;
autoSkipDelay: number;
nextId: string | null | undefined;
/** `finished` = the item being left was played through (see usePlexProgress's `finish`). */
go: (id: string | null | undefined, finished?: boolean) => void;
/** `watchedWrite` = the in-flight watched mark for the item being left, which played through (see
* usePlexProgress's `finish`); a refused one takes the finish back. */
go: (id: string | null | undefined, watchedWrite?: Promise<unknown>) => void;
seekTo: (t: number) => void;
videoRef: RefObject<HTMLVideoElement | null>;
/** Says a refused watched mark out loud; the translated string belongs to the component layer. */
reportWatchedError: (e: unknown) => void;
}): {
activeMarker: PlexMarker | undefined;
skipProgress: number | null;
@@ -326,14 +355,20 @@ export function useAutoSkip(opts: {
// The running countdown interval, so cancelAutoSkip can actually STOP it — clearing skipProgress
// alone left the interval ticking, which re-set the progress ~100ms later and skipped anyway.
const skipTimerRef = useRef<number | null>(null);
const reportWatchedErrorRef = useRef(opts.reportWatchedError);
reportWatchedErrorRef.current = opts.reportWatchedError;
const doSkip = useCallback(
(m: PlexMarker) => {
setSkipProgress(null);
if (m.type === "credits" && nextId) {
// 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, true);
// (the current id, captured here — not the reactive id, which go() is about to change). The
// write is handed to go() so a refusal takes the finish back: this path has no near-end
// checkpoint to corroborate it, so one failed request used to leave the cache claiming
// watched / position 0 while the server still had the credits position (round 5).
const watchedWrite = api.plexSetState(id, "watched");
watchedWrite.catch(reportWatchedErrorRef.current);
go(nextId, watchedWrite);
} else seekTo(m.end_s);
},
[id, go, nextId, seekTo],