diff --git a/frontend/src/lib/plexPlayerHooks.finish.dom.test.tsx b/frontend/src/lib/plexPlayerHooks.finish.dom.test.tsx
new file mode 100644
index 00000000..42b50e60
--- /dev/null
+++ b/frontend/src/lib/plexPlayerHooks.finish.dom.test.tsx
@@ -0,0 +1,119 @@
+// @vitest-environment jsdom
+//
+// The finish latch's refusal path, against a real query cache.
+//
+// A refusal answers ONE finish: a replay begun while it was in flight starts a new watch, and a
+// refusal that lands after it must not take back the finish that watch later makes — that left the
+// cache saying unwatched, mid-episode, for an item the server had watched.
+//
+// And a refusal while the player is still on the item (the up-next card, the default end state) must
+// not empty the detail that player observes: the card is gated on it, so it dropped, came back with its
+// countdown restarted when the refetch landed, and never came back if that refetch failed too. The
+// entry is dropped when the player leaves, where the next open has to fetch the truth.
+import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
+import { act, cleanup, renderHook } from "@testing-library/react";
+import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
+import { type ReactNode } from "react";
+import { usePlexProgress } from "./plexPlayerHooks";
+import { api, type PlexItemDetail } from "./api";
+import { qk } from "./queryKeys";
+import i18n, { plexLang } from "../i18n";
+
+const RK = "4242";
+const key = () => qk.plexItem(RK, plexLang(i18n.language));
+const detail = { id: RK, status: "new", position_seconds: 600 } as unknown as PlexItemDetail;
+
+function deferred() {
+ let reject: (e: unknown) => void = () => {};
+ const promise = new Promise((_, rej) => {
+ reject = rej;
+ });
+ return { promise, reject };
+}
+
+const flush = () =>
+ act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+describe("usePlexProgress — a refused finish", () => {
+ let qc: QueryClient;
+ let video: HTMLVideoElement;
+ beforeEach(() => {
+ qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ video = document.createElement("video");
+ vi.spyOn(api, "plexProgress").mockResolvedValue({ ok: true } as never);
+ });
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ const setup = (observe: boolean) => {
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ // Stable, as the player's own refs are: the effect re-runs on a new ref object, and a re-run
+ // starts the item over.
+ const refs = {
+ videoRef: { current: video },
+ absRef: { current: 1500 },
+ durationRef: { current: 3000 },
+ };
+ return renderHook(
+ () => {
+ useQuery({
+ queryKey: key(),
+ queryFn: () => Promise.resolve(detail),
+ enabled: observe,
+ });
+ return usePlexProgress({
+ id: RK,
+ qc,
+ ...refs,
+ });
+ },
+ { wrapper },
+ );
+ };
+
+ it.each([
+ ["before", true],
+ ["after", false],
+ ])(
+ "a refusal landing after a replay, %s its next finish, takes back neither",
+ async (_, early) => {
+ qc.setQueryData(key(), detail);
+ const { result } = setup(false);
+ const first = deferred();
+ act(() => result.current.finish(first.promise));
+ act(() => void video.dispatchEvent(new Event("play"))); // the viewer plays it again
+ if (early) {
+ first.reject(new Error("refused")); // only now is the first finish refused
+ await flush();
+ }
+ act(() => result.current.finish(Promise.resolve()));
+ await flush();
+ if (!early) {
+ first.reject(new Error("refused"));
+ await flush();
+ }
+ act(() => result.current.settle());
+ expect(qc.getQueryData(key())).toMatchObject({ status: "watched", position_seconds: 0 });
+ },
+ );
+
+ it("keeps the detail the still-open player observes, and drops it when the player leaves", async () => {
+ const { result, unmount } = setup(true);
+ await flush();
+ expect(qc.getQueryData(key())).toBeDefined();
+ const refused = deferred();
+ act(() => result.current.finish(refused.promise));
+ refused.reject(new Error("refused"));
+ await flush();
+ expect(qc.getQueryData(key())).toBeDefined();
+ unmount();
+ expect(qc.getQueryData(key())).toBeUndefined();
+ });
+});
diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts
index dbc20408..13a5a166 100644
--- a/frontend/src/lib/plexPlayerHooks.ts
+++ b/frontend/src/lib/plexPlayerHooks.ts
@@ -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) => {
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 () => {