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:
2026-08-17 19:07:43 +02:00
parent c0cda545e0
commit 030ba7297a
5 changed files with 195 additions and 40 deletions
+47 -12
View File
@@ -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