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
+101 -61
View File
@@ -53,6 +53,30 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
_TS_CONFIG = "public.unaccent_simple" _TS_CONFIG = "public.unaccent_simple"
# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE
# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to
# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column
# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions
# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)`
# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled
# separately in the query body (it targets episode rows, not the movie/show grid).
_FIELD_COLUMNS = ("title", "people", "summary", "episode")
def _field_recheck(model, field: str, tsq):
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None."""
if field == "title":
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, ""))
elif field == "people":
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, ""))
elif field == "summary":
expr = func.to_tsvector(
_TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
)
else:
return None
return expr.op("@@")(tsq)
@router.get("/libraries") @router.get("/libraries")
def list_libraries( def list_libraries(
@@ -251,6 +275,7 @@ class LibraryFilters:
def unified_library( def unified_library(
scope: str = "both", scope: str = "both",
q: str | None = None, q: str | None = None,
field: str = "all",
sort: str = "added", sort: str = "added",
sort_dir: str = "desc", sort_dir: str = "desc",
show: str = "all", show: str = "all",
@@ -276,9 +301,17 @@ def unified_library(
ts = to_tsquery_str(q) if q else None ts = to_tsquery_str(q) if q else None
tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
if field not in _FIELD_COLUMNS and field != "all":
field = "all"
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter.
grid_field = field != "episode"
recheck = (
_field_recheck if tsq is not None and field not in ("all", "episode") else None
)
selects = [] selects = []
if want_movies: if want_movies and grid_field:
st = aliased(PlexState) st = aliased(PlexState)
m_status = case( m_status = case(
(st.status == "watched", "watched"), (st.status == "watched", "watched"),
@@ -334,8 +367,10 @@ def unified_library(
mq = mq.where(PlexItem.duration_s <= f.duration_max) mq = mq.where(PlexItem.duration_s <= f.duration_max)
if tsq is not None: if tsq is not None:
mq = mq.where(PlexItem.search_vector.op("@@")(tsq)) mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
if recheck is not None:
mq = mq.where(recheck(PlexItem, field, tsq))
selects.append(mq) selects.append(mq)
if want_shows: if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id).subquery() agg = rollup.agg_subquery(db, user.id).subquery()
tot = func.coalesce(agg.c.total, 0) tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0) wat = func.coalesce(agg.c.watched, 0)
@@ -377,69 +412,74 @@ def unified_library(
sq = _apply_meta_filters(sq, PlexShow, p) sq = _apply_meta_filters(sq, PlexShow, p)
if tsq is not None: if tsq is not None:
sq = sq.where(PlexShow.search_vector.op("@@")(tsq)) sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
if recheck is not None:
sq = sq.where(recheck(PlexShow, field, tsq))
selects.append(sq) selects.append(sq)
if not selects:
return {
"scope": scope,
"total": 0,
"offset": offset,
"limit": limit,
"items": [],
"episodes": [],
}
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery()
total = db.scalar(select(func.count()).select_from(u)) or 0
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = {
"title": func.lower(u.c.title),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
}.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = db.execute(
select(u).order_by(*order).offset(offset).limit(limit)
).all()
items = [] items = []
for r in rows: total = 0
if r.kind == "movie": if selects:
items.append( union = (
{ selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
"id": r.rk, )
"type": "movie", u = union.subquery()
"title": r.title, total = db.scalar(select(func.count()).select_from(u)) or 0
"year": r.year,
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"status": r.status,
"position_seconds": r.position_seconds,
}
)
else:
items.append(
{
"id": r.rk,
"type": "show",
"title": r.title,
"year": r.year,
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
}
)
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = {
"title": func.lower(u.c.title),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
}.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = db.execute(
select(u).order_by(*order).offset(offset).limit(limit)
).all()
for r in rows:
if r.kind == "movie":
items.append(
{
"id": r.rk,
"type": "movie",
"title": r.title,
"year": r.year,
"duration_seconds": r.duration_s,
"thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable,
"status": r.status,
"position_seconds": r.position_seconds,
}
)
else:
items.append(
{
"id": r.rk,
"type": "show",
"title": r.title,
"year": r.year,
"thumb": f"/api/plex/image/{r.rk}",
"season_count": r.season_count,
"status": r.status,
}
)
# Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour)
# and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes
# target the movie/show grid, so they suppress episodes.
episodes = [] episodes = []
if tsq is not None and want_shows: if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState) ep_st = aliased(PlexState)
ep_vec = (
func.to_tsvector(_TS_CONFIG, func.coalesce(PlexItem.title, ""))
if field == "episode"
else PlexItem.search_vector
)
eps = db.execute( eps = db.execute(
select(PlexItem, ep_st, PlexShow.title.label("show_title")) select(PlexItem, ep_st, PlexShow.title.label("show_title"))
.outerjoin( .outerjoin(
@@ -449,9 +489,9 @@ def unified_library(
.where( .where(
PlexItem.kind == "episode", PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids), PlexItem.library_id.in_(show_lib_ids),
PlexItem.search_vector.op("@@")(tsq), ep_vec.op("@@")(tsq),
) )
.order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) .order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24) .limit(24)
).all() ).all()
for e, s, show_title in eps: for e, s, show_title in eps:
@@ -0,0 +1,132 @@
"""S2 · field-scoped search — the `field` param on /api/plex/library.
The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`,
the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`),
or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of
Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties
the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped
without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS).
"""
from datetime import datetime, timezone
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import (
PlexItem,
PlexLibrary,
PlexSeason,
PlexShow,
User,
)
from fastapi.testclient import TestClient
def _dt(day: int) -> datetime:
return datetime(2024, 2, day, tzinfo=timezone.utc)
def _seed(db):
"""One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is
unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary,
and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each
scoped field matches exactly its one row."""
movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([movie_lib, show_lib])
db.flush()
mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie",
title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.",
year=2001, added_at=_dt(1))
mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie",
title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.",
year=2002, added_at=_dt(2))
ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie",
title="Quiet Harbor Two", people_text="Alice Stone",
summary="A story about zephyr winds.", year=2003, added_at=_dt(3))
show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show",
summary="Weather anthology.", people_text="Bob Cloud",
year=2004, added_at=_dt(4), child_count=1)
db.add_all([mt, mp, ms, show])
db.flush()
season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1)
db.add(season)
db.flush()
ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode",
show_id=show.id, season_id=season.id, season_number=1, episode_number=1,
added_at=_dt(5))
db.add(ep)
user = User(email="plexfield@example.com")
db.add(user)
db.commit()
return {"user": user}
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _ids(body):
return sorted(i["id"] for i in body["items"])
class TestFieldScopedSearch:
def test_everywhere_matches_every_facet(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
# title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out
assert _ids(body) == ["mp", "ms", "mt"]
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_field_all_is_identical_to_default(self, db, monkeypatch):
s = _seed(db)
default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json()
scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json()
assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"]
assert default["total"] == scoped["total"]
assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]]
def test_title_scope_narrows_to_title_match(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json()
assert _ids(body) == ["mt"]
assert body["total"] == 1
assert body["episodes"] == [] # non-episode field scopes suppress the episode section
def test_people_scope_narrows_to_cast_match(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json()
assert _ids(body) == ["mp"]
def test_summary_scope_narrows_to_description_match(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json()
assert _ids(body) == ["ms"]
def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json()
assert body["items"] == []
assert body["total"] == 0
assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"]
def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch):
s = _seed(db)
body = _get(
db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie"
).json()
# movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty
assert body["items"] == []
assert body["episodes"] == []
def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch):
s = _seed(db)
body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json()
assert _ids(body) == ["mp", "ms", "mt"]
+2 -11
View File
@@ -5,11 +5,11 @@ import type { Me } from "../lib/api";
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import ModuleName from "./ModuleName"; import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar"; import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import SyncStatus from "./SyncStatus"; import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label + // The floating top header: a row of glass pills over the content — a ModuleName pill (label +
@@ -34,8 +34,6 @@ export default function Header({
// "go to full history" jump is a ChannelsProvider action. // "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists(); const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { q: plexQ } = usePlex();
const { setQ: setPlexQ } = usePlexActions();
const { search: channelsSearch } = useChannels(); const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
@@ -70,14 +68,7 @@ export default function Header({
/> />
); );
} else if (page === "plex" && me.plex_enabled) { } else if (page === "plex" && me.plex_enabled) {
search = ( search = <PlexSearchBox />;
<SearchBar
value={plexQ}
placeholder={t("plex.searchPlaceholder")}
onChange={setPlexQ}
onGo={blur}
/>
);
} else if (page === "channels") { } else if (page === "channels") {
search = ( search = (
<SearchBar <SearchBar
+21 -3
View File
@@ -9,6 +9,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { healPlexField } from "../lib/plexSearch";
import { healPlexSort } from "../lib/plexSort"; import { healPlexSort } from "../lib/plexSort";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; 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 // 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 // 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. // shared `playlistOpen` slot, which we consume into a history subview then clear.
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex(); const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions(); const { setScope, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350); const dq = useDebounced(q.trim(), 350);
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per // 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. // 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 // 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. // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort.
const sortState = healPlexSort(sort, scope); 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" }); const sub = useHistorySubview<Sub>({ kind: "grid" });
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then // 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]); }, [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 // 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 // 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 // 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]); }, [sub]);
const browseQ = useInfiniteQuery({ 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", enabled: !!scope && sub.view.kind === "grid",
initialPageParam: 0, initialPageParam: 0,
queryFn: ({ pageParam }) => queryFn: ({ pageParam }) =>
api.plexLibrary({ api.plexLibrary({
scope, scope,
q: dq || undefined, q: dq || undefined,
field: fieldState,
sort: sortState, sort: sortState,
show, show,
filters, filters,
+31 -4
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort"; import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useAccountRehydrate } from "../lib/useAccountRehydrate";
@@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate";
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks // 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. // 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 = { type PlexState = {
scope: string; // movie | show | both (unified cross-library scope) scope: string; // movie | show | both (unified cross-library scope)
show: string; show: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters; filters: PlexFilters;
q: string; q: string;
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
playlistOpen: number | null; playlistOpen: number | null;
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
}; };
interface PlexActions { interface PlexActions {
@@ -34,7 +41,9 @@ interface PlexActions {
setSort: (v: SortState) => void; setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void; setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void; setQ: (q: string) => void;
setField: (f: PlexSearchField) => void;
setPlaylistOpen: (id: number | null) => void; setPlaylistOpen: (id: number | null) => void;
setJumpTo: (t: PlexJumpTarget | null) => void;
} }
const StateContext = createContext<PlexState>({ const StateContext = createContext<PlexState>({
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
sort: PLEX_SORT_FALLBACK, sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS, filters: EMPTY_PLEX_FILTERS,
q: "", q: "",
field: "all",
playlistOpen: null, playlistOpen: null,
jumpTo: null,
}); });
const ActionsContext = createContext<PlexActions>({ const ActionsContext = createContext<PlexActions>({
setScope: () => {}, setScope: () => {},
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
setSort: () => {}, setSort: () => {},
setFilters: () => {}, setFilters: () => {},
setQ: () => {}, setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {}, setPlaylistOpen: () => {},
setJumpTo: () => {},
}); });
export function usePlex(): PlexState { export function usePlex(): PlexState {
@@ -69,6 +82,7 @@ function readPlexScope(): string {
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all"; const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}"; const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? ""; 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 // 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 // direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
// upgrade rather than snapping back to the default. // upgrade rather than snapping back to the default.
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
}, [filtersRaw]); }, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above. // Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(readPlexQ); const [q, setQState] = useState<string>(readPlexQ);
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null); 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). // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
const markDirty = useAccountRehydrate(() => { const markDirty = useAccountRehydrate(() => {
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
setSortState(readPlexSort()); setSortState(readPlexSort());
setFiltersRaw(readPlexFiltersRaw()); setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ()); setQState(readPlexQ());
setFieldState(readPlexField());
}); });
const setScope = useCallback( const setScope = useCallback(
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
}, },
[markDirty], [markDirty],
); );
const setField = useCallback(
(v: PlexSearchField) => {
markDirty();
setFieldState(v);
setAccountRaw(LS.plexField, v);
},
[markDirty],
);
const state = useMemo<PlexState>( const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }), () => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
[scope, show, sort, filters, q, playlistOpen], [scope, show, sort, filters, q, field, playlistOpen, jumpTo],
); );
const actions = useMemo<PlexActions>( const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }), () => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
[setScope, setShow, setSort, setFilters, setQ], [setScope, setShow, setSort, setFilters, setQ, setField],
); );
return ( return (
+150
View File
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Film, Tv } from "lucide-react";
import SearchBar from "./SearchBar";
import ActionMenu from "./ActionMenu";
import { usePlex, usePlexActions } from "./PlexProvider";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import {
availablePlexFields,
healPlexField,
plexFieldLabelKey,
} from "../lib/plexSearch";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
const MIN_CHARS = 2;
const SUGGEST_LIMIT = 10;
export default function PlexSearchBox() {
const { t } = useTranslation();
const { q, scope, field } = usePlex();
const { setQ, setField, setJumpTo } = usePlexActions();
const activeField = healPlexField(field, scope);
const dq = useDebounced(q.trim(), 200);
const [focused, setFocused] = useState(false);
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
const listRef = useRef<HTMLDivElement>(null);
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
const suggestEnabled = focused && dq.length >= MIN_CHARS && activeField !== "episode";
const suggestQ = useQuery({
queryKey: qk.plexSuggest(scope, dq, activeField),
enabled: suggestEnabled,
queryFn: () =>
api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
staleTime: 30_000,
});
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []).slice(0, SUGGEST_LIMIT) : [];
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
// A fresh query resets the highlight; keep it in range if the list shrank.
useEffect(() => setHi(-1), [dq, activeField, scope]);
const pick = (i: number) => {
const c = suggestions[i];
if (!c) return;
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
setFocused(false);
setHi(-1);
};
const fields = availablePlexFields(scope);
const pillLabel = t(plexFieldLabelKey(activeField));
return (
<SearchBar
value={q}
placeholder={t("plex.searchPlaceholder")}
onChange={setQ}
onFocus={() => setFocused(true)}
// Suggestion rows preventDefault on mousedown so the input keeps focus through the click; any
// other blur (Tab, the pill, clicking away) closes the panel.
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHi((h) => Math.min(h + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((h) => Math.max(h - 1, -1));
} else if (e.key === "Enter" && hi >= 0) {
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
pick(hi);
} else if (e.key === "Escape") {
setFocused(false);
}
}}
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
className="shrink-0 inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
trigger={
<>
<span className="max-w-28 truncate">{pillLabel}</span>
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
</>
}
items={fields.map((f) => ({
id: f,
label: t(plexFieldLabelKey(f)),
onSelect: () => setField(f),
}))}
/>
}
below={
open ? (
<div
ref={listRef}
role="listbox"
aria-label={t("plex.suggest.aria")}
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
>
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)}
{suggestions.map((c, i) => (
<button
key={c.id}
type="button"
role="option"
aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
hi === i ? "bg-border/60" : "hover:bg-border/40"
}`}
>
<img
src={c.thumb}
alt=""
loading="lazy"
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
/>
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
{c.year != null && (
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
)}
{c.type === "show" ? (
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
)}
</button>
))}
</div>
) : null
}
/>
);
}
+57 -35
View File
@@ -1,4 +1,4 @@
import { type ReactNode } from "react"; import { type FocusEventHandler, type KeyboardEventHandler, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
@@ -6,6 +6,12 @@ import { Search, X } from "lucide-react";
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a // (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
// clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube // clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube
// escalation as the Go action; the other modules filter as you type and Go just commits/blurs. // escalation as the Go action; the other modules filter as you type and Go just commits/blurs.
//
// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the
// search icon with a control (Plex's search scope-pill), and `below` renders an absolutely
// positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/
// `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key
// navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first.
export default function SearchBar({ export default function SearchBar({
value, value,
onChange, onChange,
@@ -15,6 +21,11 @@ export default function SearchBar({
goIcon, goIcon,
goDisabled, goDisabled,
goTitle, goTitle,
leading,
below,
onFocus,
onBlur,
onKeyDown,
}: { }: {
value: string; value: string;
onChange: (q: string) => void; onChange: (q: string) => void;
@@ -25,46 +36,57 @@ export default function SearchBar({
goIcon?: ReactNode; goIcon?: ReactNode;
goDisabled?: boolean; goDisabled?: boolean;
goTitle?: string; goTitle?: string;
leading?: ReactNode;
below?: ReactNode;
onFocus?: FocusEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const fire = () => { const fire = () => {
if (onGo && !goDisabled) onGo(); if (onGo && !goDisabled) onGo();
}; };
return ( return (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2"> <div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl">
<Search className="w-4 h-4 shrink-0 text-muted" /> <div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<input {leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
value={value} <input
onChange={(e) => onChange(e.target.value)} value={value}
onKeyDown={(e) => { onChange={(e) => onChange(e.target.value)}
if (e.key === "Enter") fire(); onFocus={onFocus}
}} onBlur={onBlur}
placeholder={placeholder} onKeyDown={(e) => {
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted" onKeyDown?.(e);
/> if (e.key === "Enter" && !e.defaultPrevented) fire();
{value && ( }}
<button placeholder={placeholder}
onClick={() => onChange("")} className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
title={t("header.clearSearch")} />
aria-label={t("header.clearSearch")} {value && (
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60" <button
> onClick={() => onChange("")}
<X className="w-4 h-4" /> title={t("header.clearSearch")}
</button> aria-label={t("header.clearSearch")}
)} className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
{onGo && ( >
<button <X className="w-4 h-4" />
onClick={fire} </button>
disabled={goDisabled} )}
title={goTitle ?? goLabel ?? t("header.goTip")} {onGo && (
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none" <button
> onClick={fire}
{goIcon} disabled={goDisabled}
<span className={goIcon ? "hidden md:inline" : undefined}> title={goTitle ?? goLabel ?? t("header.goTip")}
{goLabel ?? t("header.go")} className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
</span> >
</button> {goIcon}
)} <span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
</div>
{below}
</div> </div>
); );
} }
+11
View File
@@ -3,6 +3,17 @@
"backToFeed": "YouTube feed", "backToFeed": "YouTube feed",
"backToLibrary": "Back to library", "backToLibrary": "Back to library",
"searchPlaceholder": "Search Plex…", "searchPlaceholder": "Search Plex…",
"searchField": {
"aria": "Search scope",
"all": "Everywhere",
"title": "Titles",
"people": "Cast & crew",
"summary": "Descriptions",
"episode": "Episodes"
},
"suggest": {
"aria": "Search suggestions"
},
"sort": { "sort": {
"added": "Recently added", "added": "Recently added",
"title": "Title", "title": "Title",
+11
View File
@@ -3,6 +3,17 @@
"backToFeed": "YouTube feed", "backToFeed": "YouTube feed",
"backToLibrary": "Vissza a könyvtárhoz", "backToLibrary": "Vissza a könyvtárhoz",
"searchPlaceholder": "Keresés a Plexben…", "searchPlaceholder": "Keresés a Plexben…",
"searchField": {
"aria": "Keresési hatókör",
"all": "Mindenhol",
"title": "Címek",
"people": "Szereplők",
"summary": "Leírások",
"episode": "Epizódok"
},
"suggest": {
"aria": "Keresési javaslatok"
},
"sort": { "sort": {
"added": "Nemrég hozzáadott", "added": "Nemrég hozzáadott",
"title": "Cím", "title": "Cím",
+3
View File
@@ -4,6 +4,7 @@
// touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper. // touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper.
import { req, beacon } from "./core"; import { req, beacon } from "./core";
import { type SortState } from "../columnSort"; import { type SortState } from "../columnSort";
import { type PlexSearchField } from "../plexSearch";
// Admin: result of testing the Plex server connection (also drives the library-picker). // Admin: result of testing the Plex server connection (also drives the library-picker).
interface PlexSection { interface PlexSection {
@@ -275,6 +276,7 @@ export const plexApi = {
plexLibrary: (p: { plexLibrary: (p: {
scope: string; // movie | show | both scope: string; // movie | show | both
q?: string; q?: string;
field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode)
sort?: SortState; sort?: SortState;
show?: string; show?: string;
offset?: number; offset?: number;
@@ -283,6 +285,7 @@ export const plexApi = {
}): Promise<PlexUnifiedResult> => { }): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ scope: p.scope }); const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q); if (p.q) u.set("q", p.q);
if (p.field && p.field !== "all") u.set("field", p.field);
if (p.sort) { if (p.sort) {
u.set("sort", p.sort.key); u.set("sort", p.sort.key);
u.set("sort_dir", p.sort.dir); 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, plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const, plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest],
plexShow: (showId?: string) => plexShow: (showId?: string) =>
showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const), showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const),
plexItem: (id: string) => ["plex-item", id] as const, plexItem: (id: string) => ["plex-item", id] as const,
+1
View File
@@ -55,6 +55,7 @@ export const LS = {
plexSort: "siftlode.plexSort", plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters", plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ", plexQ: "siftlode.plexQ",
plexField: "siftlode.plexField",
plexPlaylistLayout: "siftlode.plexPlaylistLayout", plexPlaylistLayout: "siftlode.plexPlaylistLayout",
plexPlayerPrefs: "siftlode.plexPlayerPrefs", plexPlayerPrefs: "siftlode.plexPlayerPrefs",
// The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate // The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate