feat(plex): field-scoped search (S2) + instant search typeahead

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.
This commit is contained in:
2026-08-16 08:05:18 +02:00
parent 73a58674cf
commit f9681deecf
13 changed files with 555 additions and 114 deletions
+21 -3
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { qk } from "../lib/queryKeys";
import { healPlexField } from "../lib/plexSearch";
import { healPlexSort } from "../lib/plexSort";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
@@ -90,8 +91,8 @@ export default function PlexBrowse() {
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
// shared `playlistOpen` slot, which we consume into a history subview then clear.
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
@@ -100,6 +101,8 @@ export default function PlexBrowse() {
// The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only
// key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
const sortState = healPlexSort(sort, scope);
// 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" });
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
@@ -112,6 +115,20 @@ export default function PlexBrowse() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
// The header search typeahead picked a title → open it the same way a card click would (a movie's
// info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open.
useEffect(() => {
if (jumpTo != null) {
sub.open(
jumpTo.type === "show"
? { kind: "show", id: jumpTo.id }
: { kind: "info", id: jumpTo.id },
);
setJumpTo(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTo]);
// 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
@@ -151,13 +168,14 @@ export default function PlexBrowse() {
}, [sub]);
const browseQ = useInfiniteQuery({
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters),
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState),
enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexLibrary({
scope,
q: dq || undefined,
field: fieldState,
sort: sortState,
show,
filters,