Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
549 lines
22 KiB
Python
549 lines
22 KiB
Python
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
Integer,
|
|
String,
|
|
and_,
|
|
cast,
|
|
func,
|
|
literal,
|
|
null,
|
|
select,
|
|
)
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
from app import sysconfig
|
|
from app.auth import current_user
|
|
from app.db import get_db
|
|
from app.models import (
|
|
PlexItem,
|
|
PlexLibrary,
|
|
PlexShow,
|
|
PlexState,
|
|
User,
|
|
)
|
|
from app.plex import rollup
|
|
from app.plex.cards import episode_card
|
|
from app.plex.query import (
|
|
FIELD_COLUMNS,
|
|
PLAYBACK_CLASSES,
|
|
TS_CONFIG,
|
|
LibraryFilters,
|
|
apply_meta_filters,
|
|
apply_movie_state_filter,
|
|
apply_show_state_filter,
|
|
content_libs,
|
|
field_recheck,
|
|
genres_col,
|
|
meta_filter_conds,
|
|
movie_status_case,
|
|
playable_conds,
|
|
playback_class_expr,
|
|
show_status_case,
|
|
split_csv,
|
|
summary_col,
|
|
title_col,
|
|
vector_col,
|
|
vector_match,
|
|
)
|
|
from app.routes.plex._shared import _norm_lang
|
|
from app.utils import to_tsquery_str
|
|
|
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|
|
|
|
|
def sort_column(u, sort: str):
|
|
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
|
|
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
|
|
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
|
|
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback."""
|
|
return {
|
|
"added": u.c.added_at,
|
|
"title": func.lower(u.c.title),
|
|
"year": u.c.year,
|
|
"rating": u.c.rating,
|
|
"duration": u.c.duration_s,
|
|
"release": u.c.rel,
|
|
"lastwatched": u.c.last_watched,
|
|
}.get(sort, u.c.added_at)
|
|
|
|
|
|
@router.get("/libraries")
|
|
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
|
"""The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay
|
|
libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata."""
|
|
libs = (
|
|
db.execute(
|
|
select(PlexLibrary)
|
|
.where(PlexLibrary.overlay_of.is_(None))
|
|
.order_by(PlexLibrary.kind.desc(), PlexLibrary.title)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
out = []
|
|
for lib in libs:
|
|
if lib.kind == "movie":
|
|
count = db.scalar(
|
|
select(func.count(PlexItem.id)).where(
|
|
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
|
|
)
|
|
)
|
|
else:
|
|
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
|
|
out.append(
|
|
{
|
|
"key": lib.plex_key,
|
|
"title": lib.title,
|
|
"kind": lib.kind,
|
|
"count": count or 0,
|
|
}
|
|
)
|
|
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
|
|
|
|
|
@router.get("/library")
|
|
def unified_library(
|
|
scope: str = "both",
|
|
q: str | None = None,
|
|
field: str = "all",
|
|
sort: str = "added",
|
|
sort_dir: str = "desc",
|
|
show: str = "all",
|
|
lang: str = "en",
|
|
offset: int = 0,
|
|
limit: int = Query(default=40, ge=1, le=100),
|
|
f: LibraryFilters = Depends(),
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to
|
|
movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of
|
|
its episodes). On a search that also matches episodes (and shows are in scope), the matching
|
|
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
|
|
offset = max(0, offset)
|
|
lang = _norm_lang(lang)
|
|
p = f.as_dict()
|
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
|
|
|
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 and grid_field:
|
|
st = aliased(PlexState)
|
|
m_status = movie_status_case(st)
|
|
mq = (
|
|
select(
|
|
PlexItem.rating_key.label("rk"),
|
|
literal("movie").label("kind"),
|
|
title_col(PlexItem, lang).label("title"),
|
|
PlexItem.year.label("year"),
|
|
PlexItem.rating.label("rating"),
|
|
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
|
|
func.left(summary_col(PlexItem, lang), 300).label("summary"),
|
|
PlexItem.added_at.label("added_at"),
|
|
PlexItem.originally_available_at.label("rel"),
|
|
PlexItem.duration_s.label("duration_s"),
|
|
PlexItem.playable.label("playable"),
|
|
PlexItem.transcode_hw.label("transcode_hw"),
|
|
cast(null(), Integer).label("season_count"),
|
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
|
m_status.label("status"),
|
|
# "last watched" sort key: when the movie was last resumed, else when it was marked
|
|
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
|
|
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
|
|
(
|
|
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
|
if tsq is not None
|
|
else literal(0.0)
|
|
).label("rank"),
|
|
)
|
|
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
|
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
|
|
)
|
|
mq = apply_movie_state_filter(mq, st, show)
|
|
mq = apply_meta_filters(mq, PlexItem, p, lang)
|
|
if f.duration_min is not None:
|
|
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
|
if f.duration_max is not None:
|
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
|
# Playback-class filter (movie-only — a show is an aggregate with no single class).
|
|
mq = mq.where(*playable_conds(split_csv(f.playable)))
|
|
if tsq is not None:
|
|
mq = mq.where(vector_match(PlexItem, lang, tsq))
|
|
if recheck is not None:
|
|
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
|
selects.append(mq)
|
|
if want_shows and grid_field:
|
|
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
|
|
tot = func.coalesce(agg.c.total, 0)
|
|
wat = func.coalesce(agg.c.watched, 0)
|
|
inp = func.coalesce(agg.c.inprog, 0)
|
|
s_status = show_status_case(tot, wat, inp)
|
|
sq = (
|
|
select(
|
|
PlexShow.rating_key.label("rk"),
|
|
literal("show").label("kind"),
|
|
title_col(PlexShow, lang).label("title"),
|
|
PlexShow.year.label("year"),
|
|
PlexShow.rating.label("rating"),
|
|
func.left(summary_col(PlexShow, lang), 300).label("summary"),
|
|
PlexShow.added_at.label("added_at"),
|
|
PlexShow.originally_available_at.label("rel"),
|
|
cast(null(), Integer).label("duration_s"),
|
|
cast(null(), String).label("playable"),
|
|
cast(null(), Boolean).label("transcode_hw"),
|
|
PlexShow.child_count.label("season_count"),
|
|
literal(0).label("position_seconds"),
|
|
s_status.label("status"),
|
|
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
|
|
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
|
|
agg.c.resumed_at.label("last_watched"),
|
|
(
|
|
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
|
if tsq is not None
|
|
else literal(0.0)
|
|
).label("rank"),
|
|
)
|
|
.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
|
.where(PlexShow.library_id.in_(show_lib_ids))
|
|
)
|
|
sq = apply_show_state_filter(sq, tot, wat, inp, show)
|
|
sq = apply_meta_filters(sq, PlexShow, p, lang)
|
|
if tsq is not None:
|
|
sq = sq.where(vector_match(PlexShow, lang, tsq))
|
|
if recheck is not None:
|
|
sq = sq.where(recheck(PlexShow, field, tsq, lang))
|
|
selects.append(sq)
|
|
|
|
items = []
|
|
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 = sort_column(u, sort)
|
|
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,
|
|
"rating": r.rating,
|
|
"summary": r.summary,
|
|
"duration_seconds": r.duration_s,
|
|
"thumb": f"/api/plex/image/{r.rk}",
|
|
"playable": r.playable,
|
|
"transcode_hw": r.transcode_hw,
|
|
"status": r.status,
|
|
"position_seconds": r.position_seconds,
|
|
}
|
|
)
|
|
else:
|
|
items.append(
|
|
{
|
|
"id": r.rk,
|
|
"type": "show",
|
|
"title": r.title,
|
|
"year": r.year,
|
|
"rating": r.rating,
|
|
"summary": r.summary,
|
|
"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 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(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
|
|
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
|
|
.where(
|
|
PlexItem.kind == "episode",
|
|
PlexItem.library_id.in_(show_lib_ids),
|
|
ep_vec.op("@@")(tsq),
|
|
)
|
|
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
|
.limit(24)
|
|
).all()
|
|
for e, s, show_title in eps:
|
|
card = episode_card(e, s)
|
|
card["show_title"] = show_title
|
|
episodes.append(card)
|
|
|
|
return {
|
|
"scope": scope,
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"items": items,
|
|
"episodes": episodes,
|
|
}
|
|
|
|
|
|
@router.get("/facets")
|
|
def facets(
|
|
scope: str = "both",
|
|
show: str = "all",
|
|
lang: str = "en",
|
|
f: LibraryFilters = Depends(),
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""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": [],
|
|
"studios": [],
|
|
"playable": [],
|
|
"year_min": None,
|
|
"year_max": None,
|
|
"rating_max": None,
|
|
"duration_min": None,
|
|
"duration_max": None,
|
|
}
|
|
all_movie_ids, all_show_ids = content_libs(db)
|
|
movie_ids = all_movie_ids if scope in ("movie", "both") else []
|
|
show_ids = all_show_ids if scope in ("show", "both") else []
|
|
if not movie_ids and not show_ids:
|
|
return empty
|
|
lang = _norm_lang(lang)
|
|
p = f.as_dict()
|
|
|
|
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.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
|
|
if kind_movie:
|
|
query = query.where(model.kind == "movie")
|
|
if with_dur and pp.get("duration_min") is not None:
|
|
query = query.where(model.duration_s >= pp["duration_min"])
|
|
if with_dur and pp.get("duration_max") is not None:
|
|
query = query.where(model.duration_s <= pp["duration_max"])
|
|
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
|
|
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
|
|
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
|
|
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
|
|
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
|
st = aliased(PlexState)
|
|
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
|
|
query = apply_movie_state_filter(query, st, show)
|
|
else:
|
|
if show in ("watched", "in_progress", "unwatched"):
|
|
agg = rollup.agg_subquery(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)
|
|
query = apply_show_state_filter(query, tot, wat, inp, show)
|
|
return query
|
|
|
|
genre_counts: dict[str, int] = {}
|
|
cr_counts: dict[str, int] = {}
|
|
studio_counts: dict[str, int] = {}
|
|
playable_counts: dict[str, int] = {}
|
|
years: list[int] = []
|
|
ratings: list[float] = []
|
|
durs: list[int] = []
|
|
|
|
def per_table(model, ids, kind_movie: bool) -> None:
|
|
if not ids:
|
|
return
|
|
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
|
|
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
|
|
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
|
|
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
|
|
# every matching title carries them, so they stay togglable.)
|
|
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
|
|
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
|
|
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
|
|
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
|
|
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
|
|
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
|
|
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
|
|
gcol = genres_col(model, lang)
|
|
expanded = (
|
|
wsq(
|
|
select(func.jsonb_array_elements_text(gcol).label("g")),
|
|
model,
|
|
ids,
|
|
genre_pp,
|
|
kind_movie,
|
|
True,
|
|
)
|
|
.where(func.jsonb_typeof(gcol) == "array")
|
|
.subquery()
|
|
)
|
|
for g, c in db.execute(select(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.
|
|
crq = (
|
|
wsq(
|
|
select(model.content_rating, func.count()),
|
|
model,
|
|
ids,
|
|
{**p, "content_ratings": None},
|
|
kind_movie,
|
|
True,
|
|
)
|
|
.where(model.content_rating.isnot(None))
|
|
.group_by(model.content_rating)
|
|
)
|
|
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
|
|
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
|
|
if kind_movie:
|
|
pcls = playback_class_expr()
|
|
pcq = (
|
|
wsq(
|
|
select(pcls.label("cls"), func.count()),
|
|
model,
|
|
ids,
|
|
{**p, "playable": None},
|
|
kind_movie,
|
|
True,
|
|
)
|
|
.where(pcls.isnot(None))
|
|
.group_by(pcls)
|
|
)
|
|
for cls, c in db.execute(pcq).all():
|
|
playable_counts[cls] = playable_counts.get(cls, 0) + c
|
|
# Year bounds — exclude the year filter.
|
|
by = db.execute(
|
|
wsq(
|
|
select(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. Coalesce(imdb, audience) to match the score the
|
|
# rating filter and the UI badge use (IMDb preferred, audience fallback).
|
|
br = db.scalar(
|
|
wsq(
|
|
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
|
|
model,
|
|
ids,
|
|
{**p, "rating_min": None},
|
|
kind_movie,
|
|
True,
|
|
)
|
|
)
|
|
if br is not None:
|
|
ratings.append(float(br))
|
|
# Duration bounds (movies) — exclude the duration filter.
|
|
if kind_movie:
|
|
bd = db.execute(
|
|
wsq(
|
|
select(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])
|
|
if bd[1] is not None:
|
|
durs.append(bd[1])
|
|
|
|
per_table(PlexItem, movie_ids, True)
|
|
per_table(PlexShow, show_ids, False)
|
|
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
|
|
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
|
|
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
|
|
# 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)
|
|
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
|
|
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
|
|
for c in split_csv(f.playable):
|
|
if c in PLAYBACK_CLASSES:
|
|
playable_counts.setdefault(c, 0)
|
|
return {
|
|
"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])
|
|
],
|
|
"studios": [
|
|
{"value": s, "count": c}
|
|
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
],
|
|
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
|
|
"playable": [
|
|
{"value": c, "count": playable_counts[c]}
|
|
for c in PLAYBACK_CLASSES
|
|
if c in playable_counts
|
|
],
|
|
"year_min": min(years) if years else None,
|
|
"year_max": max(years) if years else None,
|
|
"rating_max": max(ratings) if ratings else None,
|
|
"duration_min": min(durs) if durs else None,
|
|
"duration_max": max(durs) if durs else None,
|
|
}
|