fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn

Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
This commit is contained in:
2026-10-09 22:01:28 +02:00
parent 5572499b56
commit 6df257d7df
26 changed files with 433 additions and 80 deletions
+7 -3
View File
@@ -49,6 +49,8 @@ export interface PlexCard {
status?: string; // new | watched | hidden
position_seconds?: number;
season_count?: number | null; // show
// show: an unwatched episode holds a resume point (Continue resumes it, Restart replays it)
resumable?: boolean;
season_number?: number | null; // episode
episode_number?: number | null; // episode
show_title?: string | null; // episode (in a mixed playlist)
@@ -626,18 +628,20 @@ export const plexApi = {
if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/discover?${u.toString()}`);
},
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by
// the active filters like the other rails. `exclude` is the previous draw: dealt last, not first.
// A fresh random sample for the Surprise-me rail — a new draw every call (the ↻), narrowed by the
// active filters like the other rails. `exclude` is the previous draw: dealt last, not first. `keep`
// asks for no draw at all: those titles again, in that order, with their watch state as it is now.
plexSurprise: (
scope: string,
limit?: number,
filters?: PlexFilters,
lang?: string,
exclude: string[] = [],
{ exclude = [], keep = [] }: { exclude?: string[]; keep?: string[] } = {},
): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope });
if (limit) u.set("limit", String(limit));
for (const id of exclude) u.append("exclude", id);
for (const id of keep) u.append("keep", id);
if (lang === "hu") u.set("lang", "hu");
if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/surprise?${u.toString()}`);
+2
View File
@@ -334,6 +334,8 @@ export function usePlexProgress(opts: {
finishRef.current = () => {};
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
qc.invalidateQueries({ queryKey: qk.plexShow() });
// The Discover rails (Continue watching, the Surprise draw's cards) hold watch state too.
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
};
// absRef/durationRef/videoRef are stable ref objects — listed to satisfy exhaustive-deps without
// changing when the effect re-runs (id/qc only), matching the original in-component effect.
+4 -3
View File
@@ -89,12 +89,13 @@ describe("qk — variadic composite plex keys", () => {
FILTERS,
]);
expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([
"plex-surprise",
"plex-discover",
"surprise",
"movie",
"hu",
FILTERS,
]);
// A bare call stays a prefix for the reshuffle/watched-toggle invalidations.
expect(qk.plexSurprise()).toEqual(["plex-surprise"]);
// Under the discover root: whatever refreshes the rails refreshes the draw's cards too.
expect(qk.plexSurprise()).toEqual(["plex-discover", "surprise"]);
});
});
+5 -4
View File
@@ -123,10 +123,11 @@ 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). 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],
// filter narrows against the language-specific genre column, like the grid). UNDER the discover root,
// so whatever refreshes the rails (a watched toggle, a player closing) refreshes this one too: its
// refetch reads the same draw again (`keep`), and only the ↻ deals a new one. As a sibling root the
// draw's cards kept the watch state they were drawn with.
plexSurprise: (...rest: unknown[]) => ["plex-discover", "surprise", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
// Optional trailing `lang`: a language switch remounts the detail with a new key so HU/EN
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.