Show cards carry resumable (ShowState, from the same rollup) so a part-watched show whose next episode is untouched offers Play, not a Continue and a Restart that do the same thing; plexStarted is the one rule for cards and menus. The Surprise rail's key sits under the discover root: whatever refreshes the rails re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc, and only the reshuffle deals. Closing the player refreshes the Discover rails. A show card's play acts on the last click only, and only where it was made.
578 lines
24 KiB
Python
578 lines
24 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,
|
|
or_,
|
|
select,
|
|
)
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
from app.mailtext import lang_for_user
|
|
from app.collation import collated, sorted_words
|
|
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 display_title, 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, lang: str = "en"):
|
|
"""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. The title sorts
|
|
in the reader's alphabet (`collation`)."""
|
|
return {
|
|
"added": u.c.added_at,
|
|
"title": collated(u.c.title, lang),
|
|
"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(), collated(PlexLibrary.title, lang_for_user(user)))
|
|
)
|
|
.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"),
|
|
literal(False).label("resumable"),
|
|
# "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"),
|
|
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
|
|
(inp > 0).label("resumable"),
|
|
# 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, lang)
|
|
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,
|
|
"resumable": r.resumable,
|
|
}
|
|
)
|
|
|
|
# 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)
|
|
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
|
|
# still by its English one, like `vector_match` does for the grid.
|
|
if field == "episode":
|
|
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
|
|
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
|
|
else:
|
|
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
|
|
hit = ep_vec.op("@@")(tsq)
|
|
if lang == "hu":
|
|
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
|
|
ep_vec = ep_vec_hu
|
|
eps = db.execute(
|
|
select(PlexItem, ep_st, PlexShow)
|
|
.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),
|
|
hit,
|
|
)
|
|
.order_by(func.ts_rank(ep_vec, tsq).desc())
|
|
.limit(24)
|
|
).all()
|
|
for e, s, sh in eps:
|
|
card = episode_card(e, s, lang=lang)
|
|
card["show_title"] = display_title(sh, lang) if sh is not None else None
|
|
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
|
|
|
|
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
|
|
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
|
|
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
|
|
# The same filter on the same rows, so the counts do not change
|
|
# (test_plex_facets_grid_parity).
|
|
shows_in_scope: list[int] | None = None
|
|
if show_ids and show in ("watched", "in_progress", "unwatched"):
|
|
agg = rollup.agg_subquery(db, uid).subquery()
|
|
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
|
|
in_scope = apply_show_state_filter(
|
|
in_scope,
|
|
func.coalesce(agg.c.total, 0),
|
|
func.coalesce(agg.c.watched, 0),
|
|
func.coalesce(agg.c.inprog, 0),
|
|
show,
|
|
)
|
|
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
|
|
|
|
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)
|
|
elif shows_in_scope is not None:
|
|
query = query.where(model.id.in_(shows_in_scope))
|
|
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)
|
|
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
|
|
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
|
|
return {
|
|
"genres": [
|
|
{"value": g, "count": c}
|
|
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[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], alpha[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,
|
|
}
|