"""Plex library query-shaping: the full-text field-scope rechecks and the sidebar metadata filters that /library and /facets share. Extracted from routes/plex/browse.py so the query builder has one home (and so the HU-overlay column selection lands here, not in the router). Pure query fragments — every function returns SQLAlchemy expressions or a params dict.""" from dataclasses import dataclass from datetime import datetime, timedelta, timezone from sqlalchemy import and_, case, func, or_, select, type_coerce from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Session from app.models import PlexItem, PlexLibrary from app.plex.genres import hu_genre TS_CONFIG = "public.unaccent_simple" # The ONE Plex sort vocabulary, shared by every surface that offers the Sort control (the browse grid # and the collection info page) and mirrored by the frontend's `plexSort.ts`. It lives here rather than # inside a router because the two surfaces order DIFFERENT things — browse orders a union subquery in # SQL, a collection orders its already-loaded member list in Python — and a key that one of them cannot # honour would put an option in the UI that silently does nothing. Tests pin both implementations to # this tuple, so adding a key here fails the gate until every surface can order by it. PLEX_SORT_KEYS: tuple[str, ...] = ( "added", "lastwatched", "release", "year", "rating", "duration", "title", ) # --- Language-aware column pickers (E5 S6) ---------------------------------- # # The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When # the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin. # Two flavours of picker, on purpose: # * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with # no HU metadata still shows (and filters on) its English value. (The detail endpoints serve the # HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.) # * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the # generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN # fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2 # invariant). `people_text` is language-neutral and shared by both vectors. def title_col(model, lang: str): return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title def summary_col(model, lang: str): """DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this.""" return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary def genres_col(model, lang: str): # type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce. if lang == "hu": return type_coerce(func.coalesce(model.genres_hu, model.genres), JSONB) return model.genres def top_art_keys( db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=() ) -> dict[str, list[str]]: """For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as {key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top. The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned.""" if not lib_ids or not keys: return {} k = func.jsonb_array_elements_text(key_col).label("k") expanded = ( select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k) .where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds) .subquery() ) ranked = select( expanded.c.rk, expanded.c.k, func.row_number() .over( partition_by=expanded.c.k, order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()), ) .label("rn"), ).subquery() out: dict[str, list[str]] = {} for rk, key in db.execute( select(ranked.c.rk, ranked.c.k) .where(ranked.c.k.in_(keys), ranked.c.rn <= per) .order_by(ranked.c.k, ranked.c.rn) ).all(): out.setdefault(key, []).append(rk) return out def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]: """Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime.""" if not lib_ids or not keys: return {} k = func.jsonb_array_elements_text(key_col).label("k") exp = ( select(func.coalesce(model.duration_s, 0).label("d"), k) .where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array") .subquery() ) rows = db.execute( select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k) ).all() return {key: int(total or 0) for key, total in rows} def vector_col(model, lang: str): # RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector # (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering. return model.search_vector_hu if lang == "hu" else model.search_vector def vector_match(model, lang: str, tsq): """The full-text WHERE clause. In HU, match EITHER vector so a row with no HU twin stays findable by its English title (search_vector_hu has no title tokens for such a row); EN matches only the EN vector. Ranking still uses `vector_col` (the HU vector in HU mode).""" if lang == "hu": return or_(model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq)) return model.search_vector.op("@@")(tsq) # Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE # facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to # the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column # tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions # mirror the `search_vector` Computed definition (same `TS_CONFIG`, same `left(summary, 1000)` # truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled # separately in the query body (it targets episode rows, not the movie/show grid). FIELD_COLUMNS = ("title", "people", "summary", "episode") def _title_tsv(col): return func.to_tsvector(TS_CONFIG, func.coalesce(col, "")) def _summary_tsv(col): return func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000)) def field_recheck(model, field: str, tsq, lang: str = "en"): """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None. Each per-field tsvector mirrors the generated `search_vector[_hu]` definition (same TS_CONFIG, same `left(summary, 1000)`). In HU, title/summary re-check BOTH the HU and EN columns so a scoped hit stays a strict subset of `vector_match`'s HU union; `people` is language-neutral.""" hu = lang == "hu" if field == "title": cols = [model.title_hu, model.title] if hu else [model.title] return or_(*[_title_tsv(c).op("@@")(tsq) for c in cols]) if field == "people": return func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, "")).op("@@")(tsq) if field == "summary": cols = [model.summary_hu, model.summary] if hu else [model.summary] return or_(*[_summary_tsv(c).op("@@")(tsq) for c in cols]) return None def split_csv(v: str | None) -> list[str]: return [s.strip() for s in v.split(",") if s.strip()] if v else [] _ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} def added_cutoff(within: str | None) -> datetime | None: days = _ADDED_WINDOWS.get(within or "") return datetime.now(timezone.utc) - timedelta(days=days) if days else None def meta_filter_conds(model, p: dict, lang: str = "en") -> list: """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets. Genre chips are language-specific strings, so they filter the active-language genre column.""" conds: list = [] gsel = split_csv(p.get("genres")) if gsel: gcol = genres_col(model, lang) if lang == "hu": # The HU column holds one Hungarian word per genre (plex/genres.py), so a filter saved while # it offered "Action" is read as "Akció" too. The word as picked still matches as well: a row # the sync has not reached since keeps its English (or Plex's mixed) list until it does. gc = [or_(*(gcol.contains([w]) for w in dict.fromkeys((hu_genre(g), g)))) for g in gsel] else: gc = [gcol.contains([g]) for g in gsel] conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) crs = split_csv(p.get("content_ratings")) if crs: conds.append(model.content_rating.in_(crs)) if p.get("year_min") is not None: conds.append(model.year >= p["year_min"]) if p.get("year_max") is not None: conds.append(model.year <= p["year_max"]) if p.get("rating_min") is not None: # Filter by the SAME score the UI shows on the badge (IMDb preferred, audience fallback), so a # ratingMin picked from a displayed IMDb score narrows the right column instead of the raw # audience `rating`. Both are 0–10, so coalesce is scale-consistent. conds.append(func.coalesce(model.imdb_rating, model.rating) >= p["rating_min"]) cutoff = added_cutoff(p.get("added_within")) if cutoff is not None: conds.append(model.added_at >= cutoff) for d in split_csv(p.get("directors")): # AND conds.append(model.directors.contains([d])) alist = split_csv(p.get("actors")) if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode ac = [model.cast_names.contains([a]) for a in alist] conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac)) stds = split_csv(p.get("studios")) if stds: # OR conds.append(model.studio.in_(stds)) for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5) conds.append(model.keywords.contains([kw])) csel = split_csv(p.get("collections")) if ( csel ): # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode cc = [model.collection_keys.contains([c]) for c in csel] conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc)) return conds def apply_meta_filters(q, model, p: dict, lang: str = "en"): conds = meta_filter_conds(model, p, lang) return q.where(*conds) if conds else q # --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) --------- # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. # Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill). PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw") def playback_class_expr(): """SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must precede the generic transcode branch (ordered evaluation).""" return case( (PlexItem.playable == "direct", "direct"), (PlexItem.playable == "remux", "remux"), (and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"), (PlexItem.playable == "transcode", "transcode_sw"), else_=None, ) def playable_conds(sel: list[str]) -> list: """A one-element condition list (or empty) selecting the given playback classes — an OR via `class IN (...)` over the shared CASE. Unknown keys are dropped.""" keep = [c for c in sel if c in PLAYBACK_CLASSES] return [playback_class_expr().in_(keep)] if keep else [] @dataclass class LibraryFilters: """The shared sidebar filter query-params for /library + /facets, declared once and injected via ``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included for /facets' wsq but ignored by `meta_filter_conds` (both callers apply duration separately).""" genres: str | None = None genre_mode: str = "any" content_ratings: str | None = None year_min: int | None = None year_max: int | None = None rating_min: float | None = None duration_min: int | None = None duration_max: int | None = None added_within: str | None = None directors: str | None = None actors: str | None = None actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND) studios: str | None = None keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral collections: str | None = None # CSV of collection rating_keys collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND) playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw def as_dict(self) -> dict: return { "genres": self.genres, "genre_mode": self.genre_mode, "content_ratings": self.content_ratings, "year_min": self.year_min, "year_max": self.year_max, "rating_min": self.rating_min, "duration_min": self.duration_min, "duration_max": self.duration_max, "added_within": self.added_within, "directors": self.directors, "actors": self.actors, "actor_mode": self.actor_mode, "studios": self.studios, "keywords": self.keywords, "collections": self.collections, "collection_mode": self.collection_mode, "playable": self.playable, } # --- Watch-state scope (shared by the /library grid AND the /facets aggregation) --------------- # # The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user # watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and # `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical: # a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one # home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_ # parity). Callers pass the already-joined state expressions, so this module stays model-agnostic: # * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id). # * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`. def movie_status_case(st): """The per-movie status label (new | in_progress | watched) from its PlexState alias `st`.""" return case( (st.status == "watched", "watched"), ( and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress", ), else_="new", ) def show_status_case(tot, wat, inp): """The per-show status label rolled up from its episodes' totals (total/watched/in-progress).""" return case( (and_(tot > 0, wat >= tot), "watched"), (or_(wat > 0, inp > 0), "in_progress"), else_="new", ) def apply_movie_state_filter(query, st, show: str): """Restrict a movie query to the `show` scope via its PlexState alias `st`. The default (`all`) excludes hidden movies — the exclusion that must match between grid and facets.""" if show == "watched": return query.where(st.status == "watched") if show == "in_progress": return query.where( st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched") ) if show == "unwatched": return query.where(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) return query.where(or_(st.status.is_(None), st.status != "hidden")) def apply_show_state_filter(query, tot, wat, inp, show: str): """Restrict a show query to the `show` scope via its rollup totals. `all` is a no-op (shows have no hidden concept); the caller only joins the rollup for the three non-`all` scopes.""" if show == "watched": return query.where(tot > 0, wat >= tot) if show == "in_progress": return query.where(or_(wat > 0, inp > 0), wat < tot) if show == "unwatched": return query.where(wat == 0, inp == 0) return query def content_libs(db: Session) -> tuple[list[int], list[int]]: """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5). Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's Home surface needs the same scope, and a domain package importing from `routes/` would invert the one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the move.""" libs = ( db.execute( select(PlexLibrary).where( PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) ) ) .scalars() .all() ) movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] return movie_lib_ids, show_lib_ids