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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user