A shared YearPicker: the recent years as chips with their counts, and a More dialog that lays every year out by decade (sortable by count), so a ninety-year library fits on one screen. The feed's scrolling popover gives way to it; the Plex rail gets it above its range fields, a pick setting both ends. The Plex facets now count titles per year, the year filter left out.
477 lines
21 KiB
Python
477 lines
21 KiB
Python
"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets.
|
|
|
|
A user's watch state is joined through `userscope`; the feed and its facets share every filter
|
|
(`plex.query`), so a facet count is the size of what the grid would show.
|
|
"""
|
|
|
|
from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
from app.collation import collated, sorted_words
|
|
from app.models import User
|
|
from app.plex import rollup
|
|
from app.plex.cards import norm_lang
|
|
from app.plex.models import PlexItem, PlexShow, PlexState
|
|
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.userscope import owned_by
|
|
from app.utils import to_tsquery_str
|
|
|
|
|
|
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)
|
|
|
|
|
|
def library_page(
|
|
db: Session,
|
|
user: User,
|
|
*,
|
|
scope: str,
|
|
q: str | None,
|
|
field: str,
|
|
sort: str,
|
|
sort_dir: str,
|
|
show: str,
|
|
lang: str,
|
|
offset: int,
|
|
limit: int,
|
|
f: LibraryFilters,
|
|
) -> tuple[int, list, list]:
|
|
"""One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies
|
|
and shows (`kind` says which), 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), `episodes` holds the matching ones as (item, state, show) — the
|
|
grouped 'Episodes' section. `lang` is already normalised."""
|
|
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, owned_by(st.user_id, user)))
|
|
.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 and (narrow := recheck(PlexItem, field, tsq, lang)) is not None:
|
|
mq = mq.where(narrow)
|
|
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 and (narrow := recheck(PlexShow, field, tsq, lang)) is not None:
|
|
sq = sq.where(narrow)
|
|
selects.append(sq)
|
|
|
|
total = 0
|
|
rows: list = []
|
|
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 = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all())
|
|
|
|
# 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.
|
|
eps: list = []
|
|
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 = list(
|
|
db.execute(
|
|
select(PlexItem, ep_st, PlexShow)
|
|
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user)))
|
|
.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()
|
|
)
|
|
|
|
return total, rows, eps
|
|
|
|
|
|
def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> 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.) `lang` is
|
|
normalised here, after the empty answer."""
|
|
empty = {
|
|
"genres": [],
|
|
"content_ratings": [],
|
|
"studios": [],
|
|
"playable": [],
|
|
"years": [],
|
|
"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, owned_by(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] = {}
|
|
year_counts: dict[int, 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
|
|
# Titles per year — exclude the year filter, so a picked year never empties the list it was
|
|
# picked from. The year bounds are read from the same counts.
|
|
yq = (
|
|
wsq(
|
|
select(model.year, func.count()),
|
|
model,
|
|
ids,
|
|
{**p, "year_min": None, "year_max": None},
|
|
kind_movie,
|
|
True,
|
|
)
|
|
.where(model.year.isnot(None))
|
|
.group_by(model.year)
|
|
)
|
|
for y, c in db.execute(yq).all():
|
|
year_counts[y] = year_counts.get(y, 0) + c
|
|
# 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,
|
|
)
|
|
).one()
|
|
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
|
|
],
|
|
"years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)],
|
|
"year_min": min(year_counts) if year_counts else None,
|
|
"year_max": max(year_counts) if year_counts 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,
|
|
}
|