The HTTP files browse, detail, people, collections and _shared do no database work of their own now. New plex/queries.py (the rows a page shows, a user's watch state through userscope), plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the unified browse feed, its search and facets) and plex/collections.py (a collection's source, editability, card, certificate and member order). Every watch-state join in the moved code names its owner through owned_by. Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
474 lines
21 KiB
Python
474 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": [],
|
|
"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] = {}
|
|
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,
|
|
)
|
|
).one()
|
|
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,
|
|
)
|
|
).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
|
|
],
|
|
"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,
|
|
}
|