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
+4 -6
View File
@@ -13,7 +13,10 @@ def rich_detail(meta: dict) -> dict:
"""Pull the info-page extras out of a full Plex metadata dict: an IMDb score + link, content
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
missing field is just omitted."""
# Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
# Rating: IMDb-ONLY — the explicit `imdb://` entry in the Rating array, or NULL. We deliberately do
# NOT fall back to audienceRating/rating here: that is a Plex audience score, not an IMDb score, and
# labelling it "imdb_rating" mislabels it. The audience score is served separately (item/show detail
# expose the `rating` column) so the UI can show it when there's no IMDb score.
imdb_rating = None
for r in meta.get("Rating") or []:
if str(r.get("image") or "").startswith("imdb://"):
@@ -22,11 +25,6 @@ def rich_detail(meta: dict) -> dict:
except (TypeError, ValueError):
pass
break
if imdb_rating is None:
try:
imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1)
except (TypeError, ValueError):
imdb_rating = None
# IMDb id (tt…) from the Guid list → a clickable link.
imdb_id = None
for g in meta.get("Guid") or []:
+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
+44 -20
View File
@@ -51,9 +51,12 @@ from app.plex.query import (
LibraryFilters,
apply_meta_filters,
field_recheck,
genres_col,
like_escape,
meta_filter_conds,
split_csv,
title_col,
vector_col,
)
from app.routes._common import owned_or_404
from app.routes.plex._shared import item_or_404
@@ -105,6 +108,11 @@ def _lib_key(db: Session, library_id: int | None) -> str | None:
return lib.plex_key if lib else None
def _norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
@router.get("/library")
def unified_library(
scope: str = "both",
@@ -113,6 +121,7 @@ def unified_library(
sort: str = "added",
sort_dir: str = "desc",
show: str = "all",
lang: str = "en",
offset: int = 0,
limit: int = Query(default=40, ge=1, le=100),
f: LibraryFilters = Depends(),
@@ -124,6 +133,7 @@ def unified_library(
its episodes). On a search that also matches episodes (and shows are in scope), the matching
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
offset = max(0, offset)
lang = _norm_lang(lang)
p = f.as_dict()
movie_lib_ids, show_lib_ids = _content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
@@ -158,7 +168,7 @@ def unified_library(
select(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
PlexItem.title.label("title"),
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
PlexItem.added_at.label("added_at"),
@@ -169,7 +179,7 @@ def unified_library(
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
(
func.ts_rank(PlexItem.search_vector, tsq)
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
@@ -190,15 +200,15 @@ def unified_library(
)
else:
mq = mq.where(or_(st.status.is_(None), st.status != "hidden"))
mq = apply_meta_filters(mq, PlexItem, p)
mq = apply_meta_filters(mq, PlexItem, p, lang)
if f.duration_min is not None:
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
if tsq is not None:
mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
mq = mq.where(vector_col(PlexItem, lang).op("@@")(tsq))
if recheck is not None:
mq = mq.where(recheck(PlexItem, field, tsq))
mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id).subquery()
@@ -214,7 +224,7 @@ def unified_library(
select(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
PlexShow.title.label("title"),
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
PlexShow.added_at.label("added_at"),
@@ -225,7 +235,7 @@ def unified_library(
literal(0).label("position_seconds"),
s_status.label("status"),
(
func.ts_rank(PlexShow.search_vector, tsq)
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
@@ -239,11 +249,11 @@ def unified_library(
sq = sq.where(or_(wat > 0, inp > 0), wat < tot)
elif show == "unwatched":
sq = sq.where(wat == 0, inp == 0)
sq = apply_meta_filters(sq, PlexShow, p)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
sq = sq.where(vector_col(PlexShow, lang).op("@@")(tsq))
if recheck is not None:
sq = sq.where(recheck(PlexShow, field, tsq))
sq = sq.where(recheck(PlexShow, field, tsq, lang))
selects.append(sq)
items = []
@@ -343,6 +353,7 @@ def unified_library(
def facets(
scope: str = "both",
show: str = "all",
lang: str = "en",
f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
@@ -366,6 +377,7 @@ def facets(
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
lang = _norm_lang(lang)
p = f.as_dict()
uid = user.id
@@ -373,7 +385,7 @@ def facets(
def wsq(query, model, ids, pp, kind_movie, with_dur):
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
facet aggregation query, so the facets reflect exactly the visible result set."""
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp))
query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
if kind_movie:
query = query.where(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
@@ -432,16 +444,18 @@ def facets(
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
gcol = genres_col(model, lang)
expanded = (
wsq(
select(func.jsonb_array_elements_text(model.genres).label("g")),
select(func.jsonb_array_elements_text(gcol).label("g")),
model,
ids,
genre_pp,
kind_movie,
True,
)
.where(func.jsonb_typeof(model.genres) == "array")
.where(func.jsonb_typeof(gcol) == "array")
.subquery()
)
for g, c in db.execute(
@@ -1180,10 +1194,12 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
hu = _norm_lang(lang) == "hu"
sh = by_rating_key(db, PlexShow, rating_key)
if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show")
@@ -1244,15 +1260,15 @@ def show_detail(
return {
"show": {
"id": sh.rating_key,
"title": sh.title,
"summary": sh.summary,
"title": (sh.title_hu or sh.title) if hu else sh.title,
"summary": (sh.summary_hu or sh.summary) if hu else sh.summary,
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": sh.genres or [],
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
"studio": sh.studio,
"season_count": sh.child_count,
"imdb_rating": sh.imdb_rating,
@@ -1537,11 +1553,13 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
@router.get("/item/{rating_key}")
def item_detail(
rating_key: str,
lang: str = "en",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
prev/next, and the per-user resume position + watch status."""
lang = _norm_lang(lang)
it = item_or_404(db, rating_key)
st = db.execute(
select(PlexState).where(
@@ -1614,6 +1632,7 @@ def item_detail(
except Exception:
db.rollback()
hu = lang == "hu"
# Movies serve rich detail from the DB row; episodes from the live meta just fetched.
if it.kind == "movie":
_imdb_id = it.imdb_id
@@ -1623,7 +1642,7 @@ def item_detail(
"imdb_id": _imdb_id,
"imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None,
"content_rating": it.content_rating,
"genres": it.genres or [],
"genres": (it.genres_hu or it.genres if hu else it.genres) or [],
"directors": it.directors or [],
"studio": it.studio,
"tagline": it.tagline,
@@ -1636,8 +1655,8 @@ def item_detail(
return {
"id": it.rating_key,
"kind": it.kind,
"title": it.title,
"summary": it.summary,
"title": (it.title_hu or it.title) if hu else it.title,
"summary": (it.summary_hu or it.summary) if hu else it.summary,
"year": it.year,
"duration_seconds": it.duration_s,
"playable": it.playable,
@@ -1648,6 +1667,9 @@ def item_detail(
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
"imdb_url": rich.get("imdb_url"),
# Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the
# IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too.
"rating": it.rating,
"content_rating": rich.get("content_rating"),
"genres": rich.get("genres", []),
"directors": rich.get("directors", []),
@@ -1658,7 +1680,9 @@ def item_detail(
"subtitle_streams": subtitle_streams,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
"show_title": show.title if show else None,
"show_title": (
(show.title_hu or show.title if hu else show.title) if show else None
),
"season_number": it.season_number,
"episode_number": it.episode_number,
"prev_id": prev_id,
+4 -2
View File
@@ -57,9 +57,11 @@ def test_rich_detail_prefers_imdb_entry_and_builds_link():
assert d["cast"][1]["thumb"] is None # no CDN headshot → not proxied
def test_rich_detail_falls_back_to_audience_rating():
def test_rich_detail_is_imdb_only_no_audience_fallback():
# E5 S6: imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score
# is NOT mislabelled as an IMDb score (it's served separately via the `rating` column).
d = detail.rich_detail({"audienceRating": 7.25})
assert d["imdb_rating"] == 7.2 # rounded to 1dp
assert d["imdb_rating"] is None
assert d["imdb_id"] is None and d["imdb_url"] is None
+96
View File
@@ -0,0 +1,96 @@
"""E5 S6: HU-overlay language serving. When ?lang=hu, the unified library, facets, genre filter and
detail endpoints serve the Hungarian `*_hu` columns (falling back to EN where a row has no HU twin);
?lang=en (the default) serves English. Also covers the imdb_rating IMDb-only / audience-score split.
DB lane (`db` fixture) — skipped without Postgres.
"""
from datetime import datetime, timezone
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, User
def _seed(db):
"""One EN movie library with a single movie that has both EN and HU metadata, plus a second movie
that is EN-only (no HU twin) to prove the coalesce fallback."""
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
m1 = PlexItem(
rating_key="m1", library_id=lib.id, kind="movie",
title="English Title", title_hu="Magyar Cím",
summary="English summary.", summary_hu="Magyar összefoglaló.",
genres=["Drama"], genres_hu=["Dráma"],
rating=6.5, imdb_rating=None,
year=2001, added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), # serve from row, skip live enrich
)
m2 = PlexItem(
rating_key="m2", library_id=lib.id, kind="movie",
title="EN Only", genres=["Comedy"], # no HU twin
year=2002, added_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 2, tzinfo=timezone.utc),
)
db.add_all([m1, m2])
user = User(email="plexlang@example.com")
db.add(user)
db.commit()
return user
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
client = TestClient(app) # no context manager: skip lifespan
return client.get(path)
finally:
app.dependency_overrides.clear()
def _titles(resp):
return {i["title"] for i in resp.json()["items"]}
def test_library_serves_active_language_titles(db, monkeypatch):
user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en")
# HU serves the twin's Hungarian title; the EN-only row falls back to its English title.
assert _titles(hu) == {"Magyar Cím", "EN Only"}
assert _titles(en) == {"English Title", "EN Only"}
def test_facets_vocabulary_is_language_specific(db, monkeypatch):
user = _seed(db)
hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu")
en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en")
hu_genres = {g["value"] for g in hu.json()["genres"]}
en_genres = {g["value"] for g in en.json()["genres"]}
assert "Dráma" in hu_genres and "Drama" not in hu_genres
assert "Drama" in en_genres and "Dráma" not in en_genres
def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch):
user = _seed(db)
# The HU genre chip filters the HU column; the same string against the EN column matches nothing.
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma")
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma")
assert _titles(hit) == {"Magyar Cím"}
assert miss.json()["items"] == []
def test_item_detail_language_and_rating_split(db, monkeypatch):
user = _seed(db)
resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu")
body = resp.json()
assert body["title"] == "Magyar Cím"
assert body["summary"] == "Magyar összefoglaló."
# imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`.
assert body["imdb_rating"] is None
assert body["rating"] == 6.5