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 () => {