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:
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user