Add a search scope-pill to the Plex header search box (GitHub-style, one field-scope at a time) and an instant typeahead of the top matches below it. Backend (unified_library): - New `field` param: all | title | people | summary | episode. `all` is byte-identical to the pre-scope behaviour (untouched weighted search_vector); a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a per-column tsvector recheck, so scoped hits are a strict subset of Everywhere. Per-field expressions mirror the search_vector Computed def (same TS config, same left(summary,1000) truncation). - `episode` scope empties the movie/show grid and title-only-matches the grouped Episodes section; the title/people/summary scopes suppress that section. - Unknown field falls back to `all`. New DB-lane characterization tests (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is identical to today, episode empties the grid and needs shows in scope. Frontend: - PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/ keydown slots so other modules' search boxes are untouched. - Episode scope offered only when shows are in scope (movie-only hides it), healed at the point of use like the sort state. - Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc), and jumps straight to a movie's info page or a show's drill-down via a new provider jumpTo hand-off (mirrors the playlistOpen pattern). - `field` persisted per-account; en/hu labels added.
216 lines
8.3 KiB
TypeScript
216 lines
8.3 KiB
TypeScript
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
|
import { 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 { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
|
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
|
|
|
// 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 from the header typeahead: PlexBrowse opens a movie's
|
|
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
|
|
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
|
|
|
|
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 };
|
|
}
|
|
|
|
export function PlexProvider({ children }: { children: ReactNode }) {
|
|
// 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>(readPlexScope);
|
|
const [show, setShowState] = useState<string>(readPlexShow);
|
|
const [sort, setSortState] = useState<SortState>(readPlexSort);
|
|
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
|
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
|
|
const filters = useMemo<PlexFilters>(() => {
|
|
try {
|
|
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
|
|
// into the facets query key and be re-persisted on every filter change.
|
|
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
|
|
return { ...EMPTY_PLEX_FILTERS, ...rest };
|
|
} 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).
|
|
const markDirty = useAccountRehydrate(() => {
|
|
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],
|
|
);
|
|
|
|
return (
|
|
<ActionsContext.Provider value={actions}>
|
|
<StateContext.Provider value={state}>{children}</StateContext.Provider>
|
|
</ActionsContext.Provider>
|
|
);
|
|
}
|