"""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_, type_coerce from sqlalchemy.dialects.postgresql import JSONB TS_CONFIG = "public.unaccent_simple" # --- 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 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) 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 @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) 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, } def like_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") # --- 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