fix(plex): a stale finish refusal no longer sticks, and a refusal keeps the player's detail

A refusal that landed after a replay raised the refusal flag for the finish that watch made later,
so leaving skipped the watched patch. Each finish now resets the flag and a refusal acts only for
the finish it answers.

A refusal while the player still sat on the item (the up-next card) removed the detail query it
observes, dropping the card and restarting its countdown, or losing it if the refetch failed. It is
now refetched under the player and dropped only when the player leaves.
This commit is contained in:
2026-10-28 19:05:24 +01:00
parent 7e5d4a7d93
commit 0d410592e2
2 changed files with 147 additions and 8 deletions
+28 -8
View File
@@ -214,6 +214,14 @@ export function usePlexProgress(opts: {
// write; this closes the other three. What the refusal does take back is the
// CLAIM: the cache must not say watched, and `settle` must not re-assert it on the way out.
let finishRefused = false;
// Whether the player has left the item (`settle` ran): what a refusal does to the cached detail
// depends on whether the player is still observing it.
let left = false;
// Which finish a refusal answers. A replay starts a new watch, so a refusal of an earlier finish
// that lands after it must not raise `finishRefused` for the finish that watch makes later — the
// leave then skipped its watched patch, and the cache said unwatched, mid-item, for an item the
// server had watched.
let finishGen = 0;
// `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.
@@ -261,6 +269,7 @@ export function usePlexProgress(opts: {
const onPlayAgain = () => {
finished = false;
finishRefused = false;
finishGen = finishGen + 1;
};
video?.addEventListener("play", onPlayAgain);
// Back (unmount) persists the position server-side via flush(), but reopening the same item in
@@ -273,12 +282,14 @@ export function usePlexProgress(opts: {
const settle = () => {
// Match the language-keyed detail query so the optimistic resume patch lands.
const key = qk.plexItem(id, plexLang(i18n.language));
left = true;
if (finished) {
// A refused finish claims nothing: the server has the item unwatched at the position the
// rollback restored, and the catch below has already dropped this entry, so the next open
// fetches the truth. Re-patching "watched / 0" here would put back the very lie the
// refusal handler removed.
if (!finishRefused)
// rollback restored. Re-patching "watched / 0" here would put back the very lie the refusal
// handler took back, and the entry the player kept observing (see the catch below) may
// still be the one from before the rollback — dropped, the next open fetches the truth.
if (finishRefused) qc.removeQueries({ queryKey: key });
else
qc.setQueryData(key, (old) =>
old ? { ...old, status: "watched", position_seconds: 0 } : old,
);
@@ -293,6 +304,9 @@ export function usePlexProgress(opts: {
settleRef.current = settle;
finishRef.current = (confirmed?: Promise<unknown>) => {
finished = true;
finishRefused = false;
finishGen = finishGen + 1;
const gen = finishGen;
// The latch has to go up BEFORE the media pauses, so it cannot wait for the server. If the
// watched write is then refused, what is taken back is the CLAIM, not the latch (see
// `finishRefused`): the cached detail said watched / position 0 while the server still had
@@ -306,6 +320,7 @@ export function usePlexProgress(opts: {
// position left an unwatched item with a ~99% bar that reopened in its last seconds.
const ended = video?.ended === true;
void confirmed?.catch(() => {
if (gen !== finishGen) return;
finishRefused = true;
// Restore the resume point the finish replaced — but ONLY where there is one to restore.
// On the play-to-the-end path `pos` IS the end of the media, and the backend reads a
@@ -316,10 +331,15 @@ export function usePlexProgress(opts: {
// credits-skip caller captures a mid-episode position and is unaffected.
if (!ended && pos > 0 && dur > 0 && pos <= dur - FINISH_MARGIN_S)
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(i18n.language)) });
// Still on the item (the up-next card is the default end state), the player observes this
// entry: emptying it dropped the card — gated on the detail — and its countdown restarted
// when the refetch landed, or the card never came back if that failed too. So it is
// refetched under the player, and dropped when the player leaves (`settle`). Already left,
// `settle` may have patched it to watched / 0 on the way out, and an invalidated query with
// no observer keeps serving that lie: dropped now, the next open fetches the truth.
const key = qk.plexItem(id, plexLang(i18n.language));
if (left) qc.removeQueries({ queryKey: key });
else void qc.invalidateQueries({ queryKey: key });
});
};
return () => {