1. (correctness) Show hero rating regression: PlexBrowse's show hero rendered a rating only when show.imdb_rating was set, so after the S6 IMDb-only change a show with no imdb:// entry showed no rating. Now falls back to the audience `rating` like PlexInfo. 2. (correctness) ratingMin column mismatch: the rating filter and the /facets rating_max now key off coalesce(imdb_rating, rating) — the SAME "best score" the badge displays — so a ratingMin picked from a shown IMDb score narrows the right column instead of the raw audience `rating`. 3. (altitude) HU search gap: new query.vector_match() matches EITHER vector in HU (or_(search_vector_hu, search_vector)) so a row with no HU twin stays findable by its English title; ranking still uses the HU vector. field_recheck unions the HU+EN per-field tsvectors in HU, staying a strict subset of the prefilter. 4. (test-coverage) Extracted the badge logic to lib/plexRating.pickRatingBadge (shared by PlexInfo + the show hero) with a unit test (imdb-preferred, audience fallback, 0-score, none). Backend tests added for the HU union search + the IMDb-preferred rating filter. 5. (efficiency) Language switch no longer double-fetches the grid: browseQ is gated on a committedLang state that lags `lang` by one render, so the genre-clear effect settles into a single fetch instead of one throwaway fetch with stale-language genres plus a second. DB-lane suite 295 passed / 2 skip; FE tsc/knip/lint clean, 217 vitest pass.
192 lines
8.7 KiB
Python
192 lines
8.7 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_, 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):
|
||
# 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))
|
||
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("_", "\\_")
|