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"]
|
||||
Reference in New Issue
Block a user