A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
import { useCallback, useEffect, 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 { logFilterChange } from "../lib/filterLog";
|
|
import { useUrlMirror } from "../lib/useUrlMirror";
|
|
import { useNavigation } from "./navigationContext";
|
|
import {
|
|
PlexActionsContext,
|
|
PlexStateContext,
|
|
type PlexActions,
|
|
type PlexJumpTarget,
|
|
type PlexState,
|
|
} from "./plexContext";
|
|
|
|
// 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) and seeded from it at mount
|
|
// (BootGate claims the local state for the account first; a different account reloads the tab). `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.
|
|
//
|
|
// The contexts, their value types and the hooks live in plexContext.ts.
|
|
|
|
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
|
|
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 };
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
// The base is this account's stored view (BootGate claims the local state before this provider mounts), so a
|
|
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
|
|
// `present` records which dimensions the link actually carried: only those are persisted (below).
|
|
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
|
|
type PlexUrlSeed = 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(),
|
|
});
|
|
const present = {
|
|
scope: params.has("pscope"),
|
|
sort: params.has("psort"),
|
|
show: params.has("pshow"),
|
|
filters: params.has("pf"),
|
|
};
|
|
return { ...state, present };
|
|
}
|
|
|
|
export function PlexProvider({ children }: { children: ReactNode }) {
|
|
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
|
|
// the account (the effect further down), so the shared view also survives a later plain reload.
|
|
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?.scope ?? readPlexScope());
|
|
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
|
|
const [sort, setSortState] = useState<SortState>(() => urlSeed?.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.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);
|
|
|
|
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
|
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
|
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
|
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
|
// account has picked filters, so the account's default saved view would never be applied again.
|
|
useEffect(() => {
|
|
if (!urlSeed) return;
|
|
const { present } = urlSeed;
|
|
if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope);
|
|
if (present.show) setAccountRaw(LS.plexShow, urlSeed.show);
|
|
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
|
|
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
|
|
}, [urlSeed]);
|
|
|
|
const setScope = useCallback((v: string) => {
|
|
logFilterChange("plex", { scope: v });
|
|
setScopeState(v);
|
|
setAccountRaw(LS.plexLibrary, v);
|
|
}, []);
|
|
const setShow = useCallback((v: string) => {
|
|
logFilterChange("plex", { show: v });
|
|
setShowState(v);
|
|
setAccountRaw(LS.plexShow, v);
|
|
}, []);
|
|
const setSort = useCallback((v: SortState) => {
|
|
logFilterChange("plex", { sort: v });
|
|
setSortState(v);
|
|
setAccountRaw(LS.plexSort, JSON.stringify(v));
|
|
}, []);
|
|
const setFilters = useCallback((f: PlexFilters) => {
|
|
logFilterChange("plex", { filters: f });
|
|
const s = JSON.stringify(f);
|
|
setFiltersRaw(s);
|
|
setAccountRaw(LS.plexFilters, s);
|
|
}, []);
|
|
const setQ = useCallback((v: string) => {
|
|
logFilterChange("plex", {}, v);
|
|
setQState(v);
|
|
setAccountRaw(LS.plexQ, v);
|
|
}, []);
|
|
const setField = useCallback((v: PlexSearchField) => {
|
|
logFilterChange("plex", { field: v });
|
|
setFieldState(v);
|
|
setAccountRaw(LS.plexField, v);
|
|
}, []);
|
|
|
|
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],
|
|
);
|
|
|
|
// 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 (
|
|
<PlexActionsContext.Provider value={actions}>
|
|
<PlexStateContext.Provider value={state}>{children}</PlexStateContext.Provider>
|
|
</PlexActionsContext.Provider>
|
|
);
|
|
}
|