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:
@@ -53,6 +53,30 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
_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")
|
||||
def list_libraries(
|
||||
@@ -251,6 +275,7 @@ class LibraryFilters:
|
||||
def unified_library(
|
||||
scope: str = "both",
|
||||
q: str | None = None,
|
||||
field: str = "all",
|
||||
sort: str = "added",
|
||||
sort_dir: str = "desc",
|
||||
show: str = "all",
|
||||
@@ -276,9 +301,17 @@ def unified_library(
|
||||
|
||||
ts = to_tsquery_str(q) if q 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 = []
|
||||
if want_movies:
|
||||
if want_movies and grid_field:
|
||||
st = aliased(PlexState)
|
||||
m_status = case(
|
||||
(st.status == "watched", "watched"),
|
||||
@@ -334,8 +367,10 @@ def unified_library(
|
||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||
if tsq is not None:
|
||||
mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
|
||||
if recheck is not None:
|
||||
mq = mq.where(recheck(PlexItem, field, tsq))
|
||||
selects.append(mq)
|
||||
if want_shows:
|
||||
if want_shows and grid_field:
|
||||
agg = rollup.agg_subquery(db, user.id).subquery()
|
||||
tot = func.coalesce(agg.c.total, 0)
|
||||
wat = func.coalesce(agg.c.watched, 0)
|
||||
@@ -377,69 +412,74 @@ def unified_library(
|
||||
sq = _apply_meta_filters(sq, PlexShow, p)
|
||||
if tsq is not None:
|
||||
sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
|
||||
if recheck is not None:
|
||||
sq = sq.where(recheck(PlexShow, field, tsq))
|
||||
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 = []
|
||||
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,
|
||||
}
|
||||
)
|
||||
total = 0
|
||||
if selects:
|
||||
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()
|
||||
|
||||
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 = []
|
||||
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_vec = (
|
||||
func.to_tsvector(_TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
||||
if field == "episode"
|
||||
else PlexItem.search_vector
|
||||
)
|
||||
eps = db.execute(
|
||||
select(PlexItem, ep_st, PlexShow.title.label("show_title"))
|
||||
.outerjoin(
|
||||
@@ -449,9 +489,9 @@ def unified_library(
|
||||
.where(
|
||||
PlexItem.kind == "episode",
|
||||
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)
|
||||
).all()
|
||||
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"]
|
||||
@@ -5,11 +5,11 @@ import type { Me } from "../lib/api";
|
||||
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
|
||||
import ModuleName from "./ModuleName";
|
||||
import SearchBar from "./SearchBar";
|
||||
import PlexSearchBox from "./PlexSearchBox";
|
||||
import SyncStatus from "./SyncStatus";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
import { useChannels, useChannelsActions } from "./ChannelsProvider";
|
||||
|
||||
// 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.
|
||||
const { search: playlistsSearch } = usePlaylists();
|
||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
||||
const { q: plexQ } = usePlex();
|
||||
const { setQ: setPlexQ } = usePlexActions();
|
||||
const { search: channelsSearch } = useChannels();
|
||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
||||
// 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) {
|
||||
search = (
|
||||
<SearchBar
|
||||
value={plexQ}
|
||||
placeholder={t("plex.searchPlaceholder")}
|
||||
onChange={setPlexQ}
|
||||
onGo={blur}
|
||||
/>
|
||||
);
|
||||
search = <PlexSearchBox />;
|
||||
} else if (page === "channels") {
|
||||
search = (
|
||||
<SearchBar
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -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
|
||||
// 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 {
|
||||
@@ -34,7 +41,9 @@ interface PlexActions {
|
||||
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>({
|
||||
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
|
||||
sort: PLEX_SORT_FALLBACK,
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
q: "",
|
||||
field: "all",
|
||||
playlistOpen: null,
|
||||
jumpTo: null,
|
||||
});
|
||||
const ActionsContext = createContext<PlexActions>({
|
||||
setScope: () => {},
|
||||
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
|
||||
setSort: () => {},
|
||||
setFilters: () => {},
|
||||
setQ: () => {},
|
||||
setField: () => {},
|
||||
setPlaylistOpen: () => {},
|
||||
setJumpTo: () => {},
|
||||
});
|
||||
|
||||
export function usePlex(): PlexState {
|
||||
@@ -69,6 +82,7 @@ function readPlexScope(): string {
|
||||
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.
|
||||
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
}, [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(() => {
|
||||
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
setSortState(readPlexSort());
|
||||
setFiltersRaw(readPlexFiltersRaw());
|
||||
setQState(readPlexQ());
|
||||
setFieldState(readPlexField());
|
||||
});
|
||||
|
||||
const setScope = useCallback(
|
||||
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
const setField = useCallback(
|
||||
(v: PlexSearchField) => {
|
||||
markDirty();
|
||||
setFieldState(v);
|
||||
setAccountRaw(LS.plexField, v);
|
||||
},
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
const state = useMemo<PlexState>(
|
||||
() => ({ scope, show, sort, filters, q, playlistOpen }),
|
||||
[scope, show, sort, filters, q, playlistOpen],
|
||||
() => ({ 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, setPlaylistOpen }),
|
||||
[setScope, setShow, setSort, setFilters, setQ],
|
||||
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
|
||||
[setScope, setShow, setSort, setFilters, setQ, setField],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { type FocusEventHandler, type KeyboardEventHandler, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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({
|
||||
value,
|
||||
onChange,
|
||||
@@ -15,6 +21,11 @@ export default function SearchBar({
|
||||
goIcon,
|
||||
goDisabled,
|
||||
goTitle,
|
||||
leading,
|
||||
below,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (q: string) => void;
|
||||
@@ -25,46 +36,57 @@ export default function SearchBar({
|
||||
goIcon?: ReactNode;
|
||||
goDisabled?: boolean;
|
||||
goTitle?: string;
|
||||
leading?: ReactNode;
|
||||
below?: ReactNode;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fire = () => {
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
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">
|
||||
<Search className="w-4 h-4 shrink-0 text-muted" />
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") fire();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onGo && (
|
||||
<button
|
||||
onClick={fire}
|
||||
disabled={goDisabled}
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
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"
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl">
|
||||
<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">
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
onClick={() => onChange("")}
|
||||
title={t("header.clearSearch")}
|
||||
aria-label={t("header.clearSearch")}
|
||||
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onGo && (
|
||||
<button
|
||||
onClick={fire}
|
||||
disabled={goDisabled}
|
||||
title={goTitle ?? goLabel ?? t("header.goTip")}
|
||||
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"
|
||||
>
|
||||
{goIcon}
|
||||
<span className={goIcon ? "hidden md:inline" : undefined}>
|
||||
{goLabel ?? t("header.go")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{below}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Back to library",
|
||||
"searchPlaceholder": "Search Plex…",
|
||||
"searchField": {
|
||||
"aria": "Search scope",
|
||||
"all": "Everywhere",
|
||||
"title": "Titles",
|
||||
"people": "Cast & crew",
|
||||
"summary": "Descriptions",
|
||||
"episode": "Episodes"
|
||||
},
|
||||
"suggest": {
|
||||
"aria": "Search suggestions"
|
||||
},
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"title": "Title",
|
||||
|
||||
@@ -3,6 +3,17 @@
|
||||
"backToFeed": "YouTube feed",
|
||||
"backToLibrary": "Vissza a könyvtárhoz",
|
||||
"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": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"title": "Cím",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user