feat(plex): apply the sidebar filters to the Discover rails too

Discover now behaves like the Browse grid — the active metadata filters (genre /
rating / year / collection / studio / …) narrow every rail: Continue watching,
Recently added, Surprise me, the genre tiles, and Because you watched. Same
LibraryFilters / meta_filter_conds the grid uses, applied to each rail's movie +
show query (movie duration included); a rail may come back empty, which the client
already hides. Genre-language matches the grid.

Because Discover honours filters itself, adding a filter no longer force-flips the
Discover landing to the grid (only a header SEARCH does, since Discover has no
search rail) — so you stay on Discover with narrowed rails.

Frontend threads `filters` into api.plexDiscover/plexSurprise + their query keys
(plexSurprise is variadic now) and the watched-toggle setQueryData; PlexDiscover
reads them from usePlex. Tests: two discover filter-narrowing cases; PlexDiscover
dom test updated for the provider default + 3-arg call.
This commit is contained in:
2026-08-22 17:35:37 +02:00
parent a60164edc4
commit 9798e506cf
7 changed files with 159 additions and 55 deletions
+6 -5
View File
@@ -105,11 +105,12 @@ export default function PlexBrowse() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTo]);
// Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on
// the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge,
// so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a
// Back to Discover with the search still in the box doesn't immediately re-flip.
const browseIntent = !!dq || plexFilterCount(filters) > 0;
// Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
// the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
// to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
// trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
// adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
const browseIntent = !!dq;
const prevIntent = useRef(browseIntent);
useEffect(() => {
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
@@ -42,6 +42,17 @@ vi.mock("../lib/api", () => ({
plexSurprise: (...a: unknown[]) => plexSurprise(...a),
plexSetState: (...a: unknown[]) => plexSetState(...a),
},
// PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs
// EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply.
EMPTY_PLEX_FILTERS: {
genres: [],
contentRatings: [],
directors: [],
actors: [],
studios: [],
keywords: [],
collections: [],
},
}));
import PlexDiscover from "./PlexDiscover";
@@ -102,7 +113,8 @@ describe("PlexDiscover", () => {
expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0);
expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0);
expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0);
expect(plexDiscover).toHaveBeenCalledWith("both", "en");
// Now called with the active filters too (empty default here, no provider mounted).
expect(plexDiscover).toHaveBeenCalledWith("both", "en", expect.objectContaining({ genres: [] }));
});
it("fires onGenre with the tile's value", async () => {
+12 -7
View File
@@ -6,6 +6,7 @@ import { qk } from "../lib/queryKeys";
import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api";
import { plexLang } from "../i18n";
import { toggleWatchState } from "../lib/plexWatch";
import { usePlex } from "./PlexProvider";
import { Carousel } from "./Carousel";
import { PlexPosterCard } from "./plexCards";
import { LoadingState, StateMessage } from "./QueryState";
@@ -89,14 +90,17 @@ export default function PlexDiscover({
const { t } = useTranslation();
const qc = useQueryClient();
const lang = plexLang();
// The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the
// query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same).
const { filters } = usePlex();
const q = useQuery({
queryKey: qk.plexDiscover(scope, lang),
queryFn: () => api.plexDiscover(scope, lang),
queryKey: qk.plexDiscover(scope, lang, filters),
queryFn: () => api.plexDiscover(scope, lang, filters),
});
// Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface.
const surpriseQ = useQuery({
queryKey: qk.plexSurprise(scope),
queryFn: () => api.plexSurprise(scope),
queryKey: qk.plexSurprise(scope, filters),
queryFn: () => api.plexSurprise(scope, undefined, filters, lang),
staleTime: 0,
});
@@ -107,7 +111,7 @@ export default function PlexDiscover({
optimistic: (next) => {
const flip = (cards: PlexCard[]) =>
cards.map((c) => (c.id === card.id ? { ...c, status: next } : c));
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang), (old) =>
qc.setQueryData<PlexDiscoverData>(qk.plexDiscover(scope, lang, filters), (old) =>
old
? {
...old,
@@ -119,8 +123,9 @@ export default function PlexDiscover({
}
: old,
);
qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) =>
old ? { ...old, items: flip(old.items) } : old,
qc.setQueryData<{ scope: string; items: PlexCard[] }>(
qk.plexSurprise(scope, filters),
(old) => (old ? { ...old, items: flip(old.items) } : old),
);
},
resync: () => {
+14 -4
View File
@@ -463,16 +463,26 @@ export const plexApi = {
method: "PUT",
body: JSON.stringify({ item_rating_keys: itemRks }),
}),
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary.
plexDiscover: (scope: string, lang?: string): Promise<PlexDiscover> => {
// Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the
// active sidebar `filters` narrow every rail (so Discover behaves like the Browse grid).
plexDiscover: (scope: string, lang?: string, filters?: PlexFilters): Promise<PlexDiscover> => {
const u = new URLSearchParams({ scope });
if (lang === "hu") u.set("lang", "hu");
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).
plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => {
// 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.
plexSurprise: (
scope: string,
limit?: number,
filters?: PlexFilters,
lang?: string,
): Promise<{ scope: string; items: PlexCard[] }> => {
const u = new URLSearchParams({ scope });
if (limit) u.set("limit", String(limit));
if (lang === "hu") u.set("lang", "hu");
if (filters) appendPlexFilters(u, filters);
return req(`/api/plex/surprise?${u.toString()}`);
},
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
+3 -2
View File
@@ -97,8 +97,9 @@ export const qk = {
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
// refetches it alone.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
plexSurprise: (scope?: string) =>
scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const),
// Variadic like plexDiscover so it can key by scope + the active filters; `plexSurprise()` (no args)
// stays a prefix for the reshuffle/watched-toggle invalidations.
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
// metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both.