feat(plex): adaptive (faceted) filters — each filter narrows the others

The sidebar's filter chips/bounds now ADAPT to the active filters: /facets takes the
current filters and computes each group's available values with all the OTHER active
filters applied but not its own (standard faceted search). So picking e.g. IMDb rating
>=8 trims the genre/content-rating chips + year bounds to what's still reachable, and
picking a genre narrows them further — while a multi-select group (genres) still shows
options to add. Extracted _meta_filter_conds (shared by browse/unified/facets), and the
frontend passes the live filters to plexFacets (query keyed on them, previous chips kept
during refetch). Search text is intentionally not factored into facets.
This commit is contained in:
npeter83 2026-07-11 00:31:47 +02:00
parent 736db017e4
commit 017be5f8ca
3 changed files with 119 additions and 49 deletions

View file

@ -351,36 +351,42 @@ def _added_cutoff(within: str | None) -> datetime | None:
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
def _apply_meta_filters(q, model, p: dict):
"""Apply the shared metadata filters (identical column names on plex_items + plex_shows) to a
query over `model`. `p` is the request's filter params dict. Duration is movie-only, applied by
the caller. Kept DRY so movies, shows, and the unified cross-library browse filter identically."""
def _meta_filter_conds(model, p: dict) -> list:
"""The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
`p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
conds: list = []
gsel = _csv(p.get("genres"))
if gsel:
conds = [model.genres.contains([g]) for g in gsel]
q = q.filter(and_(*conds) if p.get("genre_mode") == "all" else or_(*conds))
gc = [model.genres.contains([g]) for g in gsel]
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
crs = _csv(p.get("content_ratings"))
if crs:
q = q.filter(model.content_rating.in_(crs))
conds.append(model.content_rating.in_(crs))
if p.get("year_min") is not None:
q = q.filter(model.year >= p["year_min"])
conds.append(model.year >= p["year_min"])
if p.get("year_max") is not None:
q = q.filter(model.year <= p["year_max"])
conds.append(model.year <= p["year_max"])
if p.get("rating_min") is not None:
q = q.filter(model.rating >= p["rating_min"])
conds.append(model.rating >= p["rating_min"])
cutoff = _added_cutoff(p.get("added_within"))
if cutoff is not None:
q = q.filter(model.added_at >= cutoff)
conds.append(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND
q = q.filter(model.directors.contains([d]))
conds.append(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND
q = q.filter(model.cast_names.contains([a]))
conds.append(model.cast_names.contains([a]))
stds = _csv(p.get("studios"))
if stds: # OR
q = q.filter(model.studio.in_(stds))
conds.append(model.studio.in_(stds))
if p.get("collection"):
q = q.filter(model.collection_keys.contains([p["collection"]]))
return q
conds.append(model.collection_keys.contains([p["collection"]]))
return conds
def _apply_meta_filters(q, model, p: dict):
conds = _meta_filter_conds(model, p)
return q.filter(*conds) if conds else q
@router.get("/browse")
@ -737,12 +743,27 @@ def unified_library(
@router.get("/facets")
def facets(
scope: str = "both",
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
year_min: int | None = None,
year_max: int | None = None,
rating_min: float | None = None,
duration_min: int | None = None,
duration_max: int | None = None,
added_within: str | None = None,
directors: str | None = None,
actors: str | None = None,
studios: str | None = None,
collection: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Available filter values for the unified library, scoped to movie|show|both: genres + content
ratings (with counts merged across the scope's libraries) and the year/rating/duration bounds, so
the sidebar only offers what the scope actually contains. Duration bounds are movie-only."""
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
filters (faceted search): each group's values/bounds are computed with all the OTHER active
filters applied but NOT its own so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in facets track the sidebar filters.)"""
empty = {
"genres": [],
"content_ratings": [],
@ -757,6 +778,22 @@ def facets(
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
"duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
}
def conds(model, ids, pp, kind_movie, with_dur):
c = _meta_filter_conds(model, pp) + [model.library_id.in_(ids)]
if kind_movie:
c.append(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
c.append(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
c.append(model.duration_s <= pp["duration_max"])
return c
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
@ -764,36 +801,49 @@ def facets(
ratings: list[float] = []
durs: list[int] = []
def collect(table: str, ids: list[int], with_dur: bool) -> None:
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
base = f"FROM {table} WHERE library_id = ANY(:ids)"
for g, c in db.execute(
text(f"SELECT g, count(*) c FROM (SELECT jsonb_array_elements_text(genres) g {base}) x GROUP BY g"),
{"ids": ids},
).all():
# Genres — exclude the genre filter itself so you can keep adding genres.
gc = conds(model, ids, {**p, "genres": None}, kind_movie, True)
expanded = db.query(func.jsonb_array_elements_text(model.genres).label("g")).filter(*gc).subquery()
for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all():
genre_counts[g] = genre_counts.get(g, 0) + c
for cr, c in db.execute(
text(f"SELECT content_rating, count(*) c {base} AND content_rating IS NOT NULL GROUP BY content_rating"),
{"ids": ids},
).all():
# Content ratings — exclude its own.
cc = conds(model, ids, {**p, "content_ratings": None}, kind_movie, True)
for cr, c in (
db.query(model.content_rating, func.count())
.filter(*cc, model.content_rating.isnot(None))
.group_by(model.content_rating)
.all()
):
cr_counts[cr] = cr_counts.get(cr, 0) + c
dur_sel = ", min(duration_s), max(duration_s)" if with_dur else ""
b = db.execute(text(f"SELECT min(year), max(year), max(rating){dur_sel} {base}"), {"ids": ids}).first()
if b[0] is not None:
years.append(b[0])
if b[1] is not None:
years.append(b[1])
if b[2] is not None:
ratings.append(float(b[2]))
if with_dur:
if b[3] is not None:
durs.append(b[3])
if b[4] is not None:
durs.append(b[4])
# Year bounds — exclude the year filter.
by = db.query(func.min(model.year), func.max(model.year)).filter(
*conds(model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True)
).first()
if by[0] is not None:
years.append(by[0])
if by[1] is not None:
years.append(by[1])
# Rating max — exclude the rating filter.
br = db.query(func.max(model.rating)).filter(
*conds(model, ids, {**p, "rating_min": None}, kind_movie, True)
).scalar()
if br is not None:
ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter.
if kind_movie:
bd = db.query(func.min(model.duration_s), func.max(model.duration_s)).filter(
*conds(model, ids, p, kind_movie, False)
).first()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
collect("plex_items", movie_ids, with_dur=True)
collect("plex_shows", show_ids, with_dur=False)
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
return {
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: (-kv[1], kv[0]))],
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],