feat(plex): S6 backend — language-aware serving + IMDb-only rating
E5 S6 backend half (no migration — the *_hu / rich columns exist from S5a/S5b). Language-aware serving (?lang=hu serves the HU overlay twin, EN fallback per row): - query.py: column pickers title_col / genres_col / vector_col; field_recheck and meta_filter_conds take `lang`. DISPLAY/FILTER pickers coalesce(*_hu, *) (EN fallback); SEARCH pickers use the raw *_hu column mirroring the generated search_vector_hu (coalesce to '', not EN) so the field recheck stays a strict subset of the GIN vector (the S2 invariant). genres_col type_coerces the coalesce to JSONB so the GIN `.contains` survives. - browse.py: `lang` param on /library, /facets, /item, /show (validated to en|hu). unified_library title/vector/rank/field-scope + meta filters are language-aware for both the movie and show branches; facets unnest the active-language genre vocabulary (jsonb_typeof guard kept); detail endpoints serve HU title/summary/ genres/show_title with EN fallback. Episodes stay EN (not overlay-localized). IMDb-only rating (folded-in S5b review #5): - detail.py rich_detail: drop the audienceRating/rating fallback → imdb_rating is NULL when there's no imdb:// Rating entry (stop mislabelling a Plex audience score as IMDb). item_detail now also serves the `rating` column (audience score; show_detail already did) so the UI can show it when there's no IMDb score. Tests: test_plex_lang.py (HU title serving + EN fallback, language-specific facet vocabulary, genre-filter language matching, item_detail lang + rating split); test_plex_detail updated for IMDb-only. DB-lane suite 293 passed / 2 skipped; ruff clean.
This commit is contained in:
+47
-12
@@ -6,10 +6,40 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
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
|
||||
@@ -20,16 +50,19 @@ TS_CONFIG = "public.unaccent_simple"
|
||||
FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
||||
|
||||
|
||||
def field_recheck(model, field: str, tsq):
|
||||
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None."""
|
||||
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":
|
||||
expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.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":
|
||||
expr = func.to_tsvector(
|
||||
TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
|
||||
)
|
||||
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)
|
||||
@@ -47,14 +80,16 @@ def added_cutoff(within: str | None) -> datetime | None:
|
||||
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
||||
|
||||
|
||||
def meta_filter_conds(model, p: dict) -> list:
|
||||
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."""
|
||||
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:
|
||||
gc = [model.genres.contains([g]) for g in 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:
|
||||
@@ -82,8 +117,8 @@ def meta_filter_conds(model, p: dict) -> list:
|
||||
return conds
|
||||
|
||||
|
||||
def apply_meta_filters(q, model, p: dict):
|
||||
conds = meta_filter_conds(model, p)
|
||||
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user