E5 S4: Plex Discover rail-surface (default landing)

A dedicated Plex "Discover" landing — a stack of horizontal Carousel rails
served from ONE consolidated read (serve-from-DB, no live Plex/TMDB call):

- GET /api/plex/discover → continue_watching (in-progress movies + shows, most
  recently progressed) · recently_added · genres (top-N tiles) ·
  because_you_watched (recs of the most-recent watched, TMDB-enriched movie,
  resolved to library rows by guid via serve_block's resolver).
- GET /api/plex/surprise → a fresh random, scope-bounded sample for the ↻ rail.

Frontend: PlexDiscover.tsx (rails reuse the S1 Carousel + S3 PlexPosterCard;
colored genre tiles → tap to filter the grid; ↻ reshuffles the surprise rail
alone). Discover is now the root sub-view; the flat grid becomes the bookmarkable
"Browse all" secondary view (?v=browse) reached by a header search/filter (a
browse-intent flip) or the new Discover⟷Browse toggle.

resolve_recs is promoted from _resolve_recs (now cross-module). Tests:
test_plex_discover.py (rail shape/order, continue-watching, because-you-watched
+ null, surprise bounds, scope) and PlexDiscover.dom.test.tsx; plexSubUrl
round-trip updated for discover-root/grid-browse. Backend lane 393, vitest 332.
This commit is contained in:
2026-08-20 22:15:54 +02:00
parent 5b5e76b73d
commit f9b63745f9
13 changed files with 1022 additions and 9 deletions
+49 -2
View File
@@ -25,6 +25,8 @@ import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
import SegmentedToggle from "./SegmentedToggle";
import PlexDiscover from "./PlexDiscover";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
import { StateMessage } from "./QueryState";
@@ -70,7 +72,9 @@ export default function PlexBrowse() {
const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`));
// Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field.
const fieldState = healPlexField(field, scope);
const sub = useHistorySubview<Sub>({ kind: "grid" }, PLEX_SUB_URL);
// Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
@@ -95,6 +99,19 @@ 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;
const prevIntent = useRef(browseIntent);
useEffect(() => {
if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
sub.open({ kind: "grid" });
}
prevIntent.current = browseIntent;
}, [browseIntent, sub]);
// Opening a show/player replaces the grid entirely (the component returns early below), so the
// scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
@@ -124,7 +141,7 @@ export default function PlexBrowse() {
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
e.preventDefault();
sub.back();
}
@@ -337,12 +354,42 @@ export default function PlexBrowse() {
);
}
// Discover ⟷ Browse switch, shown on both surfaces so either can return to the other.
const viewToggle = (
<SegmentedToggle
size="md"
ariaLabel={t("plex.view.label")}
options={[
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={sub.view.kind === "discover" ? "discover" : "grid"}
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
if (sub.view.kind === "discover") {
return (
<div className="p-4 max-w-[1600px] mx-auto">
<div className="mb-4 flex items-center">{viewToggle}</div>
<PlexDiscover
scope={scope}
onOpenCard={onCard}
onInfoCard={onInfo}
onGenre={(g) => applyFilter({ genres: [g] })}
onSeeAllRecent={() => sub.open({ kind: "grid" })}
/>
</div>
);
}
return (
<div className="p-4 max-w-[1600px] mx-auto">
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
{viewToggle}
<p className="text-xs text-muted">
{browseQ.isLoading
? " "