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
+3
View File
@@ -4,6 +4,7 @@
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
import { req, beacon } from "./core";
import { type SortState } from "../columnSort";
import { type PlexSearchField } from "../plexSearch";
// Admin: result of testing the Plex server connection (also drives the library-picker).
interface PlexSection {
@@ -275,6 +276,7 @@ export const plexApi = {
plexLibrary: (p: {
scope: string; // movie | show | both
q?: string;
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
sort?: SortState;
show?: string;
offset?: number;
@@ -283,6 +285,7 @@ export const plexApi = {
}): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q);
if (p.field && p.field !== "all") u.set("field", p.field);
if (p.sort) {
u.set("sort", p.sort.key);
u.set("sort_dir", p.sort.dir);
+34
View File
@@ -0,0 +1,34 @@
// The search-box scope-pill (S2): one field-scope active at a time, narrowing the Plex full-text
// search to a single facet of the row. Kept in one module so the provider (state), the header
// SearchBar (the pill UI), and the api layer (the `field` query param) share one source of truth.
export type PlexSearchField = "all" | "title" | "people" | "summary" | "episode";
export const PLEX_SEARCH_FIELDS: PlexSearchField[] = [
"all",
"title",
"people",
"summary",
"episode",
];
// i18n label key per scope (see plex.searchField.* in the locale files).
export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchField.${f}`;
// Episodes only exist under show libraries, so the Episode scope is offered only when shows are in
// scope (show | both) — mirroring the backend, which returns nothing for episode+movie-only.
export function availablePlexFields(scope: string): PlexSearchField[] {
return scope === "movie"
? PLEX_SEARCH_FIELDS.filter((f) => f !== "episode")
: PLEX_SEARCH_FIELDS;
}
// Heal a stored/selected field against the current scope: a movie-only scope can't hold Episode, so
// it falls back to Everywhere (analogous to healPlexSort). Applied at the point of use.
export function healPlexField(field: PlexSearchField, scope: string): PlexSearchField {
return availablePlexFields(scope).includes(field) ? field : "all";
}
export function parsePlexField(v: string | null | undefined): PlexSearchField {
return v && (PLEX_SEARCH_FIELDS as string[]).includes(v) ? (v as PlexSearchField) : "all";
}
+1
View File
@@ -91,6 +91,7 @@ export const qk = {
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
plexShow: (showId?: string) =>
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
plexItem: (id: string) => ["plex-item", id] as const,
+1
View File
@@ -55,6 +55,7 @@ export const LS = {
plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexField: "siftlode.plexField",
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs",
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate