E5 S7: Plex filter UI — listbox-select pickers + studio filter

Reworks the always-expanded filter chip walls into compact, Seerr-style
"+ Add" → searchable-popover pickers (the user's space-saving ask), and adds
the missing studio filter.

- New MultiSelectChips: selected values as removable chips + a "+ Add" trigger
  opening a searchable checklist (AnchoredPopover platform — menuitemcheckbox,
  arrow/Escape, anchor flip). Options carry facet counts; a large vocabulary
  renders a 50-row cap + "N more — refine with search" footer (studios run to
  ~1500). Replaces the flat Genre + Age-rating chip lists.
- New Studio filter via the same picker. Backend: /facets now aggregates the
  studio vocabulary (distinct studio + count, self-excluded like genres). The
  studio filter itself already existed (only addable from the detail page before).
- Collection converted to the same "+ Add" pattern (CollectionPicker): a
  single-select popover with server-side search + the 50-cap; the selected
  collection still shows as a removable chip. Retires the always-inline search+list.

New i18n plex.filter.studio/add/moreRefine + common.search/remove/noResults
(en+hu). Tests: MultiSelectChips.dom.test.tsx (5, incl. the cap) +
test_plex_facets studio-vocabulary. Browser-E2E'd: Genre→Horror(402),
Studio→A24(14, capped/searched), Collection→James Bond(26)/harry-search.
Gate: vitest 342, backend lane 403.
This commit is contained in:
2026-08-21 00:40:31 +02:00
parent 187b8007f8
commit d56a791c04
10 changed files with 470 additions and 97 deletions
+24
View File
@@ -312,6 +312,7 @@ def facets(
empty = {
"genres": [],
"content_ratings": [],
"studios": [],
"year_min": None,
"year_max": None,
"rating_max": None,
@@ -355,6 +356,7 @@ def facets(
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
@@ -406,6 +408,21 @@ def facets(
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Year bounds — exclude the year filter.
by = db.execute(
wsq(
@@ -460,6 +477,9 @@ def facets(
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
return {
"genres": [
{"value": g, "count": c}
@@ -469,6 +489,10 @@ def facets(
{"value": cr, "count": c}
for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
+27 -1
View File
@@ -12,7 +12,33 @@ 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 PlexLibrary, PlexShow, User
from app.models import PlexItem, PlexLibrary, PlexShow, User
def test_facets_returns_studio_vocabulary(db, monkeypatch):
"""E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add_all([
PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"),
PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"),
PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"),
PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio
])
user = User(email="studiofacets@example.com")
db.add(user)
db.commit()
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
resp = TestClient(app).get("/api/plex/facets?scope=movie")
finally:
app.dependency_overrides.clear()
assert resp.status_code == 200, resp.text
studios = {s["value"]: s["count"] for s in resp.json()["studios"]}
assert studios == {"Pixar": 2, "A24": 1}
def test_facets_survives_json_null_genres(db, monkeypatch):