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
@@ -0,0 +1,102 @@
// @vitest-environment jsdom
//
// A show's card in the Browse grid plays (the owner, 2026-10-09): its picture asks the server which
// episode and opens the player on it; a started show's Restart opens it from the start; its title
// opens the show's page. The hook is tested on its own (usePlayShow); this is the grid wiring it.
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./PlexPlayer", () => ({
default: ({ itemId, fromStart }: { itemId: string; fromStart?: boolean }) => (
<div>
player {itemId} {fromStart ? "from the start" : "resumed"}
</div>
),
}));
vi.mock("./plexViews", async (importOriginal) => ({
...(await importOriginal<typeof import("./plexViews")>()),
PlexShowView: ({ showId }: { showId: string }) => <div>show page {showId}</div>,
}));
import PlexBrowse from "./PlexBrowse";
import { api, type PlexCard } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
beforeEach(() => window.history.replaceState({ _sub: { kind: "grid" } }, ""));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const show: PlexCard = {
id: "sh",
type: "show",
title: "Grimm",
thumb: "",
status: "in_progress",
resumable: true,
};
function mount() {
vi.spyOn(api, "plexLibrary").mockResolvedValue({
items: [show],
total: 1,
offset: 0,
episodes: [],
} as never);
const next = vi.spyOn(api, "plexShowNext").mockResolvedValue({
next: { id: "e7", type: "episode", title: "Ep 7", thumb: "" },
});
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexBrowse />
</QueryClientProvider>,
);
return { next };
}
describe("PlexBrowse — a show's card in the grid", () => {
it("plays the episode the server names", async () => {
const { next } = mount();
fireEvent.click(await screen.findByRole("button", { name: "card.continue" }));
expect(await screen.findByText("player e7 resumed")).toBeTruthy();
expect(next).toHaveBeenCalledWith("sh", "en");
});
it("starts that episode over from its Restart", async () => {
mount();
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
expect(await screen.findByText("player e7 from the start")).toBeTruthy();
});
it("opens the show's page from its title, asking nothing", async () => {
const { next } = mount();
fireEvent.click(await screen.findByText("Grimm"), { detail: 1 });
expect(await screen.findByText("show page sh")).toBeTruthy();
expect(next).not.toHaveBeenCalled();
});
it("opens the show's page from its ⓘ, asking nothing", async () => {
const { next } = mount();
fireEvent.click(await screen.findByRole("button", { name: "plex.openShow" }));
expect(await screen.findByText("show page sh")).toBeTruthy();
expect(next).not.toHaveBeenCalled();
});
});
+1
View File
@@ -332,6 +332,7 @@ export default function PlexBrowse() {
const resolveShow = usePlayShow(
(id, fromStart) => sub.open({ kind: "player", id, fromStart }),
(id) => sub.open({ kind: "show", id }),
sub.view,
);
// A show's card plays the episode it resolves to (usePlayShow); the grid's place is kept first.
function playShow(card: PlexCard, fromStart: boolean) {
@@ -7,7 +7,7 @@
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { focusManager, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
class Stub {
@@ -57,6 +57,8 @@ vi.mock("../lib/api", () => ({
}));
import PlexDiscover from "./PlexDiscover";
import { qk } from "../lib/queryKeys";
import type { PlexCard } from "../lib/api";
afterEach(() => {
cleanup();
@@ -157,7 +159,8 @@ describe("PlexDiscover", () => {
expect.objectContaining({ genres: [] }),
]);
expect(keys).toContainEqual([
"plex-surprise",
"plex-discover",
"surprise",
"both",
"en",
expect.objectContaining({ genres: [] }),
@@ -219,14 +222,24 @@ describe("PlexDiscover", () => {
await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2));
expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched
// The first draw asks for anything; the redraw hands over the one on screen to deal it last.
expect(plexSurprise.mock.calls[0]?.[4]).toEqual([]);
expect(plexSurprise.mock.calls[1]?.[4]).toEqual(["sp1"]);
expect(plexSurprise.mock.calls[0]?.[4]).toEqual({ exclude: [] });
expect(plexSurprise.mock.calls[1]?.[4]).toEqual({ exclude: ["sp1"] });
});
// Back from a card's player or page remounts the surface: the draw it left must still be there.
it("keeps its draw across a remount and a return to the window; only ↻ redraws", async () => {
// Back from a card's player or page remounts the surface: the draw it left must still be there, with
// its cards' watch state as it is now. A refetch reads the same titles again; only ↻ deals.
it("keeps its draw across a remount, a refresh and a long absence; only ↻ redraws", async () => {
plexDiscover.mockResolvedValue(fullPayload);
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
let status: PlexCard["status"] = "new";
plexSurprise.mockImplementation(
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
Promise.resolve({
scope: "both",
items: o.keep?.length
? [{ ...card("sp1", "Random Pick"), status }]
: [card(plexSurprise.mock.calls.length > 1 ? "sp2" : "sp1", "Pick")],
}),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const ui = (
<QueryClientProvider client={qc}>
@@ -241,17 +254,24 @@ describe("PlexDiscover", () => {
</QueryClientProvider>
);
const first = render(ui);
await screen.findByText("Random Pick");
await screen.findByText("Pick");
first.unmount();
render(ui);
await screen.findByText("Random Pick");
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
// Watched from somewhere else: whatever refreshes the rails refreshes the draw's cards too.
status = "watched";
await act(() => qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
const [entry] = qc.getQueryCache().findAll({ queryKey: qk.plexSurprise() });
expect(entry?.state.data).toMatchObject({ items: [{ id: "sp1", status: "watched" }] });
// No draw is ever dealt behind the viewer's back: every call after the first reads it again.
for (const call of plexSurprise.mock.calls.slice(1)) expect(call[4]).toEqual({ keep: ["sp1"] });
// Unobserved past the default five minutes (a film watched from the rail) it is still kept.
expect(entry?.options.gcTime).toBe(Infinity);
fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" }));
await screen.findByText("Pick");
expect(plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1]?.[4]).toEqual({
exclude: ["sp1"],
});
await new Promise((r) => setTimeout(r, 20));
expect(plexSurprise).toHaveBeenCalledTimes(1);
focusManager.setFocused(undefined);
});
it("shows the empty message when every rail is empty", async () => {
+20 -9
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react";
import { useRef, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
@@ -120,25 +120,36 @@ export default function PlexDiscover({
queryFn: () => api.plexDiscover(scope, lang, filters),
});
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
// A redraw hands over the draw on screen, so a small filtered pool deals its other titles first.
// A draw stands until the ↻ (or another scope, language or filter) asks for a new one: redrawn on
// every refetch, the card just looked at was gone from its rail on Back from the player or a show
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
const surpriseKey = qk.plexSurprise(scope, lang, filters);
const redraw = useRef(false);
const surpriseQ = useQuery({
queryKey: surpriseKey,
queryFn: () => {
const shown = qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? [];
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
(c) => c.id,
);
const deal = redraw.current || shown.length === 0;
redraw.current = false;
return api.plexSurprise(
scope,
undefined,
filters,
lang,
shown.map((c) => c.id),
deal ? { exclude: shown } : { keep: shown },
);
},
// A draw stands until ↻ (or another scope, language or filter) asks for a new one. A stale one was
// redrawn on every remount — Back from the player or a show page — and on a return to the window,
// so the card just looked at was gone from the rail it came from.
staleTime: Infinity,
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
// a collected draw is a new one.
gcTime: Infinity,
});
const reshuffle = () => {
redraw.current = true;
void surpriseQ.refetch();
};
// 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.
@@ -243,7 +254,7 @@ export default function PlexDiscover({
cards={surprise}
action={
<IconButton
onClick={() => surpriseQ.refetch()}
onClick={reshuffle}
loading={surpriseQ.isFetching}
label={t("plex.discover.reshuffle")}
>
@@ -179,11 +179,18 @@ describe("a Plex title's menu", () => {
});
it("a started show continues, and starts its episode over from the next row", () => {
setup(card({ type: "show", status: "in_progress" }));
setup(card({ type: "show", status: "in_progress", resumable: true }));
rightClick();
expect(labels().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]);
});
it("a part-watched show with no episode to resume plays, with nothing to start over", () => {
setup(card({ type: "show", status: "in_progress", resumable: false }));
rightClick();
expect(labels()[0]).toBe("plex.play");
expect(labels()).not.toContain("card.restartTitle");
});
it("the card's '…' opens the same menu, and says so", () => {
setup(card({}));
const hoverMore = moreButtons()[0]!;
@@ -232,6 +232,20 @@ it("closing the player detaches the <video> it was playing", async () => {
expect(v.getAttribute("src")).toBeNull();
});
it("closing the player refreshes every surface that shows watch state, the Discover rails too", async () => {
const { unmount, qc } = mount({
details: { A: item("A") },
sessions: { A: () => Promise.resolve(direct("A")) },
});
await waitFor(() => expect(video()?.getAttribute("src")).toBe("/media/A"));
const refreshed = vi.spyOn(qc, "invalidateQueries");
unmount();
const keys = refreshed.mock.calls.map(([f]) => f?.queryKey);
expect(keys).toEqual(
expect.arrayContaining([qk.plexLibrary(), qk.plexShow(), qk.plexDiscover()]),
);
});
describe("the subtitle poll and notice", () => {
it("asks about where a resumed session was asked to start, before playback has ticked", async () => {
const { status } = mount({
+14 -1
View File
@@ -200,7 +200,7 @@ describe("PlexPosterCard with a next episode", () => {
const onRestart = vi.fn();
render(
<PlexPosterCard
card={{ ...show, status: "in_progress" }}
card={{ ...show, status: "in_progress", resumable: true }}
onOpen={onOpen}
onRestart={onRestart}
/>,
@@ -211,6 +211,19 @@ describe("PlexPosterCard with a next episode", () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("plays a part-watched show with no episode to resume, with no Restart beside it", () => {
// Its next episode is untouched: Continue and Restart would both play it from its start.
render(
<PlexPosterCard
card={{ ...show, status: "in_progress", resumable: false }}
onOpen={vi.fn()}
onRestart={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "card.restart" })).toBeNull();
expect(screen.queryByRole("button", { name: "card.continue" })).toBeNull();
});
it("names the episode it would play once its preview is up, and not asked before", async () => {
vi.useFakeTimers();
const ask = vi
+1 -2
View File
@@ -347,8 +347,7 @@ export function PlexPosterCard({
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// The next episode by name: its numbers, or its title when Plex has none for it.
const nextName = ep && episodeName(ep);
// A show without its episode in hand continues where a started one stands (its rollup status).
const started = ep ? plexStarted(ep) : isShow ? card.status === "in_progress" : plexStarted(card);
const started = plexStarted(ep ?? card);
const showPage = ep ? onInfo : isShow && onPage ? () => onPage(card) : undefined;
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
@@ -30,12 +30,23 @@ function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const play = vi.fn();
const openShow = vi.fn();
const { result } = renderHook(() => usePlayShow(play, openShow), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
});
return { qc, play, openShow, playShow: result.current };
const { result, rerender, unmount } = renderHook(
({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
{
initialProps: { here: "grid" as unknown },
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
},
);
return { qc, play, openShow, playShow: result.current, rerender, unmount };
}
/** Answers the test hands out one by one, in the order it chooses. */
function answers() {
const pending: ((v: { next: PlexCard | null }) => void)[] = [];
vi.spyOn(api, "plexShowNext").mockImplementation(() => new Promise((r) => void pending.push(r)));
return pending;
}
describe("usePlayShow", () => {
@@ -65,6 +76,32 @@ describe("usePlayShow", () => {
expect(play).not.toHaveBeenCalled();
});
it("acts on the last click only: an earlier, slower answer is dropped", async () => {
const pending = answers();
const { play, playShow } = mount();
const first = playShow({ ...show, id: "a" }, false);
const second = playShow({ ...show, id: "b" }, false);
await act(async () => pending[1]!({ next: ep("b1") }));
await act(async () => pending[0]!({ next: ep("a1") }));
await act(() => Promise.all([first, second]));
expect(play.mock.calls).toEqual([["b1", false]]);
});
it("drops an answer that lands after the viewer has moved on, or left", async () => {
const pending = answers();
const { play, openShow, playShow, rerender, unmount } = mount();
const moved = playShow(show, false);
rerender({ here: "info" });
await act(async () => pending[0]!({ next: ep("e1") }));
await act(() => moved);
expect(play).not.toHaveBeenCalled();
const left = playShow(show, false);
unmount();
await act(async () => pending[1]!({ next: null }));
await act(() => left);
expect(openShow).not.toHaveBeenCalled();
});
it("says so when the question fails, and opens nothing", async () => {
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
const { play, openShow, playShow } = mount();
+15 -2
View File
@@ -1,19 +1,30 @@
import { useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { notifyActionError } from "../lib/actionErrors";
import { plexLang } from "../i18n";
import { useLatest } from "../lib/useLatest";
/** A show's card plays like any card: the episode in progress, else the first unwatched, else the
* first (`/show/{id}/next`). Asked fresh on the click, since the card's preview may hold an answer
* from before a watch somewhere else. A show with no episodes has nothing to play, so its page opens
* (`openShow`) and says so; a failed question is a toast, and nothing opens. */
* (`openShow`) and says so; a failed question is a toast, and nothing opens.
*
* The answer acts only for the LAST click, and only where it was made (`here`, the caller's view): a
* slow answer to a click the viewer has since moved on from (another card, Back, another module)
* would otherwise pull them into a player they no longer asked for. */
export function usePlayShow(
play: (episodeId: string, fromStart: boolean) => void,
openShow: (showId: string) => void,
here: unknown,
) {
const qc = useQueryClient();
const at = useLatest(here);
const asked = useRef(0);
// Unmounted, nothing of it may navigate any more.
useEffect(() => () => void ++asked.current, []);
const { t, i18n } = useTranslation();
const lang = plexLang(i18n.language);
// `undefined` when the question failed (said in the toast).
@@ -29,8 +40,10 @@ export function usePlayShow(
(e: unknown) => void notifyActionError(e, t("plex.playShowFailed")),
);
return async (show: PlexCard, fromStart: boolean) => {
const ask = ++asked.current;
const from = at.current;
const ep = await next(show.id);
if (ep === undefined) return;
if (ask !== asked.current || at.current !== from || ep === undefined) return;
if (ep) play(ep.id, fromStart);
else openShow(show.id);
};
@@ -39,9 +39,13 @@ export const plexAddLabelKey = (c: PlexCard): string =>
const isPlexPlayable = (c: PlexCard): boolean => c.type !== "show";
/** Has a resume point: the click continues it, and "from the start" is a choice. A watched item is
* stored at 0, so it never counts. */
* stored at 0, so it never counts. A show has one when an episode does (`resumable`, the episode its
* click plays): part watched with its next episode untouched, Continue and Restart would be the same
* thing, so it is not "started" here — its in-progress badge says how far it has got. */
export const plexStarted = (c: PlexCard): boolean =>
isPlexPlayable(c) && c.status !== "watched" && (c.position_seconds ?? 0) > 0;
isPlexPlayable(c)
? c.status !== "watched" && (c.position_seconds ?? 0) > 0
: c.resumable === true;
/** `open` is the host menu's own flag (the shelf is fetched only once a menu opened). Returns the
* item builder, `add` for the card's own hover button, and the dialog to render beside the menu
@@ -78,8 +82,7 @@ export function usePlexCardActions(handlers: PlexActionHandlers, open: boolean)
const playable = isPlexPlayable(c);
const isShow = c.type === "show";
const watched = c.status === "watched";
// A show continues where its rollup says it stands, as its card's Continue + Restart do.
const started = isShow ? c.status === "in_progress" : plexStarted(c);
const started = plexStarted(c);
return [
...(play
? [