fix(plex): facet + sort refinements

- Facets now respect the watch-state filter too (In progress / Watched / Unwatched),
  so e.g. the Year bounds match the visible set (was showing the library min, not the
  filtered min).
- Genre chips sorted alphabetically (was by count).
- Sort chips ordered alphabetically by their localized label.
- Ascending/Descending chips swapped (Ascending first) and the default sort direction
  is now Ascending.
- Default sort is now Title (ascending) instead of Recently added / Descending.
This commit is contained in:
npeter83 2026-07-11 00:45:05 +02:00
parent 017be5f8ca
commit fe024ab29d
4 changed files with 65 additions and 34 deletions

View file

@ -743,6 +743,7 @@ def unified_library(
@router.get("/facets")
def facets(
scope: str = "both",
show: str = "all",
genres: str | None = None,
genre_mode: str = "any",
content_ratings: str | None = None,
@ -785,15 +786,41 @@ def facets(
"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)]
uid = user.id
def wsq(query, model, ids, pp, kind_movie, with_dur):
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
facet aggregation query, so the facets reflect exactly the visible result set."""
query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp))
if kind_movie:
c.append(model.kind == "movie")
query = query.filter(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
c.append(model.duration_s >= pp["duration_min"])
query = query.filter(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
query = query.filter(model.duration_s <= pp["duration_max"])
if show in ("watched", "in_progress", "unwatched"):
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
if show == "watched":
query = query.filter(st.status == "watched")
elif show == "in_progress":
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
else:
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else:
if show in ("watched", "in_progress", "unwatched"):
agg = _show_agg_subq(db, uid).subquery()
query = query.outerjoin(agg, agg.c.show_id == model.id)
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
if show == "watched":
query = query.filter(tot > 0, wat >= tot)
elif show == "in_progress":
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
else:
query = query.filter(wat == 0, inp == 0)
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
@ -805,37 +832,36 @@ def facets(
if not ids:
return
# 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()
expanded = wsq(
db.query(func.jsonb_array_elements_text(model.genres).label("g")),
model, ids, {**p, "genres": None}, kind_movie, True,
).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
# 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()
):
crq = wsq(
db.query(model.content_rating, func.count()),
model, ids, {**p, "content_ratings": None}, kind_movie, True,
).filter(model.content_rating.isnot(None)).group_by(model.content_rating)
for cr, c in crq.all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# 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)
by = wsq(
db.query(func.min(model.year), func.max(model.year)),
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()
br = wsq(db.query(func.max(model.rating)), 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)
bd = wsq(
db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False
).first()
if bd[0] is not None:
durs.append(bd[0])
@ -845,7 +871,7 @@ def facets(
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]))],
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,