Files
siftlode/frontend/src/components/PlexProvider.tsx
T
peter 82914b56ca fix(e24): the Home surface's three UAT findings — Back, the reroll, and a show card
All three were measured before they were touched, and the diagnosis moved twice.

**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.

A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.

**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.

The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.

**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.

Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.

Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.

Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
2026-09-06 05:38:28 +02:00

310 lines
14 KiB
TypeScript

import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import {
hasPlexParams,
plexParamsToState,
plexParamUpdates,
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage). init-from-storage seeds
// them, and useAccountRehydrate re-reads once the tab's account is pinned — this provider mounts above
// <App>, which pins it only after /api/me, so a fresh tab would otherwise show defaults until a reload
// (an account SWITCH does full-reload, but the FIRST load does not). `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = {
id: string;
type: "movie" | "show" | "play";
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
* actually came from rather than to a module root they never visited. Omitted for a jump made
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
};
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
playlistOpen: number | null;
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setField: (f: PlexSearchField) => void;
setPlaylistOpen: (id: number | null) => void;
setJumpTo: (t: PlexJumpTarget | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
// The per-account init readers, shared by the initial useState seed AND the account-pin re-hydrate.
function readPlexScope(): string {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
}
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
// upgrade rather than snapping back to the default.
function readPlexSort(): SortState {
const raw = getAccountRaw(LS.plexSort);
if (!raw) return PLEX_SORT_FALLBACK;
let wasJson = false;
try {
const parsed = parseSortState(JSON.parse(raw));
wasJson = true;
if (parsed) return parsed;
} catch {
/* not JSON → a legacy bare-key string, handled below */
}
// Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a
// key. Only a non-JSON raw is a genuine legacy bare key.
if (wasJson) return PLEX_SORT_FALLBACK;
let dir: "asc" | "desc" = "asc";
try {
if (
(JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc"
)
dir = "desc";
} catch {
/* ignore */
}
return { key: raw, dir };
}
// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
function readPlexFilters(): PlexFilters {
try {
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
} catch {
return EMPTY_PLEX_FILTERS;
}
}
// A shared-link seed = the URL-merged state PLUS which dimensions the URL actually carried. `present`
// matters because the base below is read at MOUNT, before the account is pinned (getAccountRaw returns
// null then), so the base for an OMITTED dimension is a hard default, NOT this account's stored value.
// The provider must therefore persist/keep only the PRESENT dimensions on account-pin and re-read the
// account's real stored value for the absent ones — otherwise a partial link silently resets them.
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = { state: PlexUrlState; present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
const state = plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
return {
state,
present: {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
},
};
}
export function PlexProvider({ children }: { children: ReactNode }) {
// Captured once: the shared-link content state (or null). Seeds the slices below and, on account pin,
// is persisted to the account instead of being clobbered by the stored view (see the rehydrate).
const [urlSeed] = useState(readPlexUrlSeed);
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => urlSeed?.state.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.state.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.state.sort ?? readPlexSort());
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
urlSeed ? JSON.stringify(urlSeed.state.filters) : readPlexFiltersRaw(),
);
const filters = useMemo<PlexFilters>(() => {
try {
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
return coercePlexFilters(JSON.parse(filtersRaw));
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(readPlexQ);
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
// PlexBrowse consumes it into an info/show history subview then clears it.
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
// A shared-link seed is the exception: instead of reloading the account's stored view, PERSIST the URL
// state to this account (so the shared view wins and survives a later plain reload), and adopt only the
// non-shared slices (q/field) from storage. Mirrors FeedFiltersProvider's initialHadUrlFilters branch.
const markDirty = useAccountRehydrate(() => {
if (urlSeed) {
// Per dimension: a dimension the URL carried (present) is the shared view — adopt it and persist
// it to this now-pinned account (so it wins and survives a later plain reload). A dimension the
// URL OMITTED was seeded from a mount-time default (no account yet), so do NOT persist that
// default over the account's real stored value — re-read the stored value instead, exactly like a
// normal load. This is the fix for a partial shared link silently resetting the omitted slices.
const { state, present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, state.scope);
else setScopeState(readPlexScope());
if (present.show) setAccountRaw(LS.plexShow, state.show);
else setShowState(readPlexShow());
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(state.sort));
else setSortState(readPlexSort());
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(state.filters));
else setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
return;
}
setScopeState(readPlexScope());
setShowState(readPlexShow());
setSortState(readPlexSort());
setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
});
const setScope = useCallback(
(v: string) => {
markDirty();
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
},
[markDirty],
);
const setShow = useCallback(
(v: string) => {
markDirty();
setShowState(v);
setAccountRaw(LS.plexShow, v);
},
[markDirty],
);
const setSort = useCallback(
(v: SortState) => {
markDirty();
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
},
[markDirty],
);
const setFilters = useCallback(
(f: PlexFilters) => {
markDirty();
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
},
[markDirty],
);
const setQ = useCallback(
(v: string) => {
markDirty();
setQState(v);
setAccountRaw(LS.plexQ, v);
},
[markDirty],
);
const setField = useCallback(
(v: PlexSearchField) => {
markDirty();
setFieldState(v);
setAccountRaw(LS.plexField, v);
},
[markDirty],
);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
[setScope, setShow, setSort, setFilters, setQ, setField],
);
// S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// search term is deliberately transient-per-view and not part of the shared four-key contract.
const { page } = useNavigation();
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}