Consumes the TMDB block the S2 engine already persists + serves (movie/show detail's `tmdb`), turning the movie info page into a Seerr-style enriched detail (page variant only — the player overlay stays lean): - Multi-source ratings row: IMDb (links out) · TMDb (with vote count) · Plex audience — whichever are present. Only the primary score (IMDb, else audience) carries the ratingMin filter, mirroring the backend rating column. - Facts panel: release · runtime · cert · budget · revenue · studios · countries. - Keyword chips → tap to filter the library. Backend: a new `keywords` filter on LibraryFilters + meta_filter_conds (`keywords @> [kw]`, both models), wired through PlexFilters (appendPlexFilters / plexFilterCount / active-chip / clear). - Full crew (TMDB key-crew) → tap a name to open their person page. Tests: test_plex_keyword_filter.py (3) + PlexInfo.dom.test.tsx (5, incl. the overlay-stays-lean guard). New i18n plex.info.facts.* / keywords / crew / audience (en+hu). Gate: vitest 337, backend lane 396.
260 lines
12 KiB
Python
260 lines
12 KiB
Python
"""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]))
|
||
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
|
||
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
|
||
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,
|
||
"keywords": self.keywords,
|
||
"collection": self.collection,
|
||
}
|
||
|
||
|
||
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
|