The grid served Hungarian titles, but every other card surface (Discover rails, the show page's episodes, search, collections, the person page, playlists, Home's resume rail, hero and comfort shelf) served English. The card serializers now take a required keyword-only lang (a forgotten call site fails instead of going English), and each of those reads takes ?lang=hu, with the language in its query key. A Hungarian search finds an episode by its Hungarian title too. An episode on a poster rail showed its 16:9 still cropped into a poster, titled only with the episode name; it now shows the show's poster and name, with S·E · title under it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the number in the reader's language; a season with a name of its own keeps it. Home's comfort-shelf move wrote its optimistic order to the key without the language, which nothing read any more; it writes the query's own key.
556 lines
23 KiB
Python
556 lines
23 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 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):
|
|
"""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)
|
|
# In HU an episode is found by its Hungarian title too (E30: the overlay now 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
|
|
|
|
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,
|
|
}
|