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
|
||||
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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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