"""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_, 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 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): return model.search_vector_hu if lang == "hu" else model.search_vector # 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 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. Mirrors `vector_col`'s HU definition (raw `*_hu` coalesced to '') so a scoped hit stays a strict subset of the language's GIN vector; `people` is language-neutral.""" hu = lang == "hu" if field == "title": col = model.title_hu if hu else model.title expr = func.to_tsvector(TS_CONFIG, func.coalesce(col, "")) elif field == "people": expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, "")) elif field == "summary": col = model.summary_hu if hu else model.summary expr = func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000)) else: return None return expr.op("@@")(tsq) 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: conds.append(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)) if p.get("collection"): conds.append(model.collection_keys.contains([p["collection"]])) 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 collection: str | None = None 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, "collection": self.collection, } def like_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")