"""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, }