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:
@@ -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
|
"""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
|
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
|
||||||
missing field is just omitted."""
|
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
|
imdb_rating = None
|
||||||
for r in meta.get("Rating") or []:
|
for r in meta.get("Rating") or []:
|
||||||
if str(r.get("image") or "").startswith("imdb://"):
|
if str(r.get("image") or "").startswith("imdb://"):
|
||||||
@@ -22,11 +25,6 @@ def rich_detail(meta: dict) -> dict:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
break
|
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 (tt…) from the Guid list → a clickable link.
|
||||||
imdb_id = None
|
imdb_id = None
|
||||||
for g in meta.get("Guid") or []:
|
for g in meta.get("Guid") or []:
|
||||||
|
|||||||
+47
-12
@@ -6,10 +6,40 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
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"
|
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
|
# 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
|
# 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
|
# 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")
|
FIELD_COLUMNS = ("title", "people", "summary", "episode")
|
||||||
|
|
||||||
|
|
||||||
def field_recheck(model, field: str, tsq):
|
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."""
|
"""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":
|
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":
|
elif field == "people":
|
||||||
expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, ""))
|
expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, ""))
|
||||||
elif field == "summary":
|
elif field == "summary":
|
||||||
expr = func.to_tsvector(
|
col = model.summary_hu if hu else model.summary
|
||||||
TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
|
expr = func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000))
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
return expr.op("@@")(tsq)
|
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
|
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
|
"""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
|
`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 = []
|
conds: list = []
|
||||||
gsel = split_csv(p.get("genres"))
|
gsel = split_csv(p.get("genres"))
|
||||||
if gsel:
|
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))
|
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
||||||
crs = split_csv(p.get("content_ratings"))
|
crs = split_csv(p.get("content_ratings"))
|
||||||
if crs:
|
if crs:
|
||||||
@@ -82,8 +117,8 @@ def meta_filter_conds(model, p: dict) -> list:
|
|||||||
return conds
|
return conds
|
||||||
|
|
||||||
|
|
||||||
def apply_meta_filters(q, model, p: dict):
|
def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||||||
conds = meta_filter_conds(model, p)
|
conds = meta_filter_conds(model, p, lang)
|
||||||
return q.where(*conds) if conds else q
|
return q.where(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ from app.plex.query import (
|
|||||||
LibraryFilters,
|
LibraryFilters,
|
||||||
apply_meta_filters,
|
apply_meta_filters,
|
||||||
field_recheck,
|
field_recheck,
|
||||||
|
genres_col,
|
||||||
like_escape,
|
like_escape,
|
||||||
meta_filter_conds,
|
meta_filter_conds,
|
||||||
split_csv,
|
split_csv,
|
||||||
|
title_col,
|
||||||
|
vector_col,
|
||||||
)
|
)
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.routes.plex._shared import item_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
|
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")
|
@router.get("/library")
|
||||||
def unified_library(
|
def unified_library(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
@@ -113,6 +121,7 @@ def unified_library(
|
|||||||
sort: str = "added",
|
sort: str = "added",
|
||||||
sort_dir: str = "desc",
|
sort_dir: str = "desc",
|
||||||
show: str = "all",
|
show: str = "all",
|
||||||
|
lang: str = "en",
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = Query(default=40, ge=1, le=100),
|
limit: int = Query(default=40, ge=1, le=100),
|
||||||
f: LibraryFilters = Depends(),
|
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
|
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)."""
|
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
|
||||||
offset = max(0, offset)
|
offset = max(0, offset)
|
||||||
|
lang = _norm_lang(lang)
|
||||||
p = f.as_dict()
|
p = f.as_dict()
|
||||||
movie_lib_ids, show_lib_ids = _content_libs(db)
|
movie_lib_ids, show_lib_ids = _content_libs(db)
|
||||||
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||||
@@ -158,7 +168,7 @@ def unified_library(
|
|||||||
select(
|
select(
|
||||||
PlexItem.rating_key.label("rk"),
|
PlexItem.rating_key.label("rk"),
|
||||||
literal("movie").label("kind"),
|
literal("movie").label("kind"),
|
||||||
PlexItem.title.label("title"),
|
title_col(PlexItem, lang).label("title"),
|
||||||
PlexItem.year.label("year"),
|
PlexItem.year.label("year"),
|
||||||
PlexItem.rating.label("rating"),
|
PlexItem.rating.label("rating"),
|
||||||
PlexItem.added_at.label("added_at"),
|
PlexItem.added_at.label("added_at"),
|
||||||
@@ -169,7 +179,7 @@ def unified_library(
|
|||||||
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
m_status.label("status"),
|
m_status.label("status"),
|
||||||
(
|
(
|
||||||
func.ts_rank(PlexItem.search_vector, tsq)
|
func.ts_rank(vector_col(PlexItem, lang), tsq)
|
||||||
if tsq is not None
|
if tsq is not None
|
||||||
else literal(0.0)
|
else literal(0.0)
|
||||||
).label("rank"),
|
).label("rank"),
|
||||||
@@ -190,15 +200,15 @@ def unified_library(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
mq = mq.where(or_(st.status.is_(None), st.status != "hidden"))
|
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:
|
if f.duration_min is not None:
|
||||||
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
mq = mq.where(PlexItem.duration_s >= f.duration_min)
|
||||||
if f.duration_max is not None:
|
if f.duration_max is not None:
|
||||||
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
mq = mq.where(PlexItem.duration_s <= f.duration_max)
|
||||||
if tsq is not None:
|
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:
|
if recheck is not None:
|
||||||
mq = mq.where(recheck(PlexItem, field, tsq))
|
mq = mq.where(recheck(PlexItem, field, tsq, lang))
|
||||||
selects.append(mq)
|
selects.append(mq)
|
||||||
if want_shows and grid_field:
|
if want_shows and grid_field:
|
||||||
agg = rollup.agg_subquery(db, user.id).subquery()
|
agg = rollup.agg_subquery(db, user.id).subquery()
|
||||||
@@ -214,7 +224,7 @@ def unified_library(
|
|||||||
select(
|
select(
|
||||||
PlexShow.rating_key.label("rk"),
|
PlexShow.rating_key.label("rk"),
|
||||||
literal("show").label("kind"),
|
literal("show").label("kind"),
|
||||||
PlexShow.title.label("title"),
|
title_col(PlexShow, lang).label("title"),
|
||||||
PlexShow.year.label("year"),
|
PlexShow.year.label("year"),
|
||||||
PlexShow.rating.label("rating"),
|
PlexShow.rating.label("rating"),
|
||||||
PlexShow.added_at.label("added_at"),
|
PlexShow.added_at.label("added_at"),
|
||||||
@@ -225,7 +235,7 @@ def unified_library(
|
|||||||
literal(0).label("position_seconds"),
|
literal(0).label("position_seconds"),
|
||||||
s_status.label("status"),
|
s_status.label("status"),
|
||||||
(
|
(
|
||||||
func.ts_rank(PlexShow.search_vector, tsq)
|
func.ts_rank(vector_col(PlexShow, lang), tsq)
|
||||||
if tsq is not None
|
if tsq is not None
|
||||||
else literal(0.0)
|
else literal(0.0)
|
||||||
).label("rank"),
|
).label("rank"),
|
||||||
@@ -239,11 +249,11 @@ def unified_library(
|
|||||||
sq = sq.where(or_(wat > 0, inp > 0), wat < tot)
|
sq = sq.where(or_(wat > 0, inp > 0), wat < tot)
|
||||||
elif show == "unwatched":
|
elif show == "unwatched":
|
||||||
sq = sq.where(wat == 0, inp == 0)
|
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:
|
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:
|
if recheck is not None:
|
||||||
sq = sq.where(recheck(PlexShow, field, tsq))
|
sq = sq.where(recheck(PlexShow, field, tsq, lang))
|
||||||
selects.append(sq)
|
selects.append(sq)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
@@ -343,6 +353,7 @@ def unified_library(
|
|||||||
def facets(
|
def facets(
|
||||||
scope: str = "both",
|
scope: str = "both",
|
||||||
show: str = "all",
|
show: str = "all",
|
||||||
|
lang: str = "en",
|
||||||
f: LibraryFilters = Depends(),
|
f: LibraryFilters = Depends(),
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -366,6 +377,7 @@ def facets(
|
|||||||
show_ids = all_show_ids if scope in ("show", "both") else []
|
show_ids = all_show_ids if scope in ("show", "both") else []
|
||||||
if not movie_ids and not show_ids:
|
if not movie_ids and not show_ids:
|
||||||
return empty
|
return empty
|
||||||
|
lang = _norm_lang(lang)
|
||||||
p = f.as_dict()
|
p = f.as_dict()
|
||||||
|
|
||||||
uid = user.id
|
uid = user.id
|
||||||
@@ -373,7 +385,7 @@ def facets(
|
|||||||
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
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
|
"""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."""
|
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:
|
if kind_movie:
|
||||||
query = query.where(model.kind == "movie")
|
query = query.where(model.kind == "movie")
|
||||||
if with_dur and pp.get("duration_min") is not None:
|
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") —
|
# 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
|
# 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.
|
# 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 = (
|
expanded = (
|
||||||
wsq(
|
wsq(
|
||||||
select(func.jsonb_array_elements_text(model.genres).label("g")),
|
select(func.jsonb_array_elements_text(gcol).label("g")),
|
||||||
model,
|
model,
|
||||||
ids,
|
ids,
|
||||||
genre_pp,
|
genre_pp,
|
||||||
kind_movie,
|
kind_movie,
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
.where(func.jsonb_typeof(model.genres) == "array")
|
.where(func.jsonb_typeof(gcol) == "array")
|
||||||
.subquery()
|
.subquery()
|
||||||
)
|
)
|
||||||
for g, c in db.execute(
|
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}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
lang: str = "en",
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
"""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)
|
sh = by_rating_key(db, PlexShow, rating_key)
|
||||||
if sh is None:
|
if sh is None:
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||||
@@ -1244,15 +1260,15 @@ def show_detail(
|
|||||||
return {
|
return {
|
||||||
"show": {
|
"show": {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"title": sh.title,
|
"title": (sh.title_hu or sh.title) if hu else sh.title,
|
||||||
"summary": sh.summary,
|
"summary": (sh.summary_hu or sh.summary) if hu else sh.summary,
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||||
"library": _lib_key(db, sh.library_id),
|
"library": _lib_key(db, sh.library_id),
|
||||||
"content_rating": sh.content_rating,
|
"content_rating": sh.content_rating,
|
||||||
"rating": sh.rating,
|
"rating": sh.rating,
|
||||||
"genres": sh.genres or [],
|
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
|
||||||
"studio": sh.studio,
|
"studio": sh.studio,
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
"imdb_rating": sh.imdb_rating,
|
"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}")
|
@router.get("/item/{rating_key}")
|
||||||
def item_detail(
|
def item_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
lang: str = "en",
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
|
||||||
prev/next, and the per-user resume position + watch status."""
|
prev/next, and the per-user resume position + watch status."""
|
||||||
|
lang = _norm_lang(lang)
|
||||||
it = item_or_404(db, rating_key)
|
it = item_or_404(db, rating_key)
|
||||||
st = db.execute(
|
st = db.execute(
|
||||||
select(PlexState).where(
|
select(PlexState).where(
|
||||||
@@ -1614,6 +1632,7 @@ def item_detail(
|
|||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
|
hu = lang == "hu"
|
||||||
# Movies serve rich detail from the DB row; episodes from the live meta just fetched.
|
# Movies serve rich detail from the DB row; episodes from the live meta just fetched.
|
||||||
if it.kind == "movie":
|
if it.kind == "movie":
|
||||||
_imdb_id = it.imdb_id
|
_imdb_id = it.imdb_id
|
||||||
@@ -1623,7 +1642,7 @@ def item_detail(
|
|||||||
"imdb_id": _imdb_id,
|
"imdb_id": _imdb_id,
|
||||||
"imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None,
|
"imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None,
|
||||||
"content_rating": it.content_rating,
|
"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 [],
|
"directors": it.directors or [],
|
||||||
"studio": it.studio,
|
"studio": it.studio,
|
||||||
"tagline": it.tagline,
|
"tagline": it.tagline,
|
||||||
@@ -1636,8 +1655,8 @@ def item_detail(
|
|||||||
return {
|
return {
|
||||||
"id": it.rating_key,
|
"id": it.rating_key,
|
||||||
"kind": it.kind,
|
"kind": it.kind,
|
||||||
"title": it.title,
|
"title": (it.title_hu or it.title) if hu else it.title,
|
||||||
"summary": it.summary,
|
"summary": (it.summary_hu or it.summary) if hu else it.summary,
|
||||||
"year": it.year,
|
"year": it.year,
|
||||||
"duration_seconds": it.duration_s,
|
"duration_seconds": it.duration_s,
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
@@ -1648,6 +1667,9 @@ def item_detail(
|
|||||||
"imdb_rating": rich.get("imdb_rating"),
|
"imdb_rating": rich.get("imdb_rating"),
|
||||||
"imdb_id": rich.get("imdb_id"),
|
"imdb_id": rich.get("imdb_id"),
|
||||||
"imdb_url": rich.get("imdb_url"),
|
"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"),
|
"content_rating": rich.get("content_rating"),
|
||||||
"genres": rich.get("genres", []),
|
"genres": rich.get("genres", []),
|
||||||
"directors": rich.get("directors", []),
|
"directors": rich.get("directors", []),
|
||||||
@@ -1658,7 +1680,9 @@ def item_detail(
|
|||||||
"subtitle_streams": subtitle_streams,
|
"subtitle_streams": subtitle_streams,
|
||||||
"status": st.status if st else "new",
|
"status": st.status if st else "new",
|
||||||
"position_seconds": st.position_seconds if st else 0,
|
"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,
|
"season_number": it.season_number,
|
||||||
"episode_number": it.episode_number,
|
"episode_number": it.episode_number,
|
||||||
"prev_id": prev_id,
|
"prev_id": prev_id,
|
||||||
|
|||||||
@@ -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
|
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})
|
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
|
assert d["imdb_id"] is None and d["imdb_url"] is None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user