diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py index 0935af9a..0f9887fc 100644 --- a/backend/app/plex/detail.py +++ b/backend/app/plex/detail.py @@ -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 []: diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 3121fc7b..35c27f89 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -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 diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index a29d21f1..8716354e 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -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, diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index cf471222..d5d0bba3 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -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 diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py new file mode 100644 index 00000000..b1e94b9c --- /dev/null +++ b/backend/tests/test_plex_lang.py @@ -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