diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 548f5314..df49dee5 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -235,4 +235,31 @@ describe("PlexDiscover", () => { ); await screen.findByText("plex.discover.empty"); }); + + it("puts a Surprise card back when its watched toggle fails", async () => { + // The resync refetches the rails but not the Surprise draw (that would reshuffle it), so a + // refused toggle stayed flipped there — the sibling of the collection page's fix. + plexDiscover.mockResolvedValue({ + ...fullPayload, + continue_watching: [], + recently_watched: [], + recently_added: [], + because_you_watched: null, + }); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + plexSetState.mockRejectedValueOnce(new Error("refused")); + wrap( + , + ); + fireEvent.click(await screen.findByLabelText("plex.markWatched")); + await waitFor(() => expect(plexSetState).toHaveBeenCalledWith("sp1", "watched")); + await waitFor(() => expect(screen.getByLabelText("plex.markWatched")).toBeTruthy()); + expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull(); + }); }); diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index c7414ef5..435b06c9 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -105,6 +105,14 @@ export default function PlexDiscover({ // Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise // list, then persist + resync via the shared helper — invalidating the grid too so it agrees. + const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) => + qc.setQueryData<{ scope: string; items: PlexCard[] }>( + qk.plexSurprise(scope, lang, filters), + (old) => + old + ? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) } + : old, + ); const toggleWatched = (card: PlexCard) => toggleWatchState(card, t, { optimistic: (next) => { @@ -122,11 +130,11 @@ export default function PlexDiscover({ } : old, ); - qc.setQueryData<{ scope: string; items: PlexCard[] }>( - qk.plexSurprise(scope, lang, filters), - (old) => (old ? { ...old, items: flip(old.items) } : old), - ); + setSurpriseStatus(card, next); }, + // The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed + // toggle was left flipped there — put back in place instead. + revert: () => setSurpriseStatus(card, card.status), resync: () => { qc.invalidateQueries({ queryKey: qk.plexDiscover() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); diff --git a/frontend/src/lib/plexWatch.ts b/frontend/src/lib/plexWatch.ts index 2ba8afd0..7749af7c 100644 --- a/frontend/src/lib/plexWatch.ts +++ b/frontend/src/lib/plexWatch.ts @@ -6,16 +6,20 @@ import { notifyActionError } from "./actionErrors"; // updater, call the API, surface a failure (the click used to look dead on 403/404/429), then // `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the // browse grid and the person page, each of which owns a different cache shape. +// +// `revert` is for a cache `resync` must NOT refetch — a random draw (Discover's Surprise rail), which +// a refetch would reshuffle under the viewer. It is called on a failure only, to put the card back. export async function toggleWatchState( card: PlexCard, t: TFunction, - handlers: { optimistic: (next: string) => void; resync: () => void }, + handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void }, ) { const next = card.status === "watched" ? "new" : "watched"; handlers.optimistic(next); try { await api.plexSetState(card.id, next); } catch (e) { + handlers.revert?.(); notifyActionError(e, t("plex.watchedFailed")); } handlers.resync(); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 423f9743..deee4b24 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -117,8 +117,9 @@ export const qk = { // refetches it alone. plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], // Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre - // filter narrows against the language-specific genre column, like the grid); `plexSurprise()` (no args) - // stays a prefix for the reshuffle/watched-toggle invalidations. + // filter narrows against the language-specific genre column, like the grid). The ↻ reshuffle is the + // query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the + // card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer. plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest], plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN