"""Regression (E5 S5b): the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at rich-fetch time and served from the DB row instead of a live plex.metadata() call. - `rich_detail`/`apply_rich_detail` are pure — parse + persist the rich-only fields. - `_enrich_people_batch` (the cast backfill) now also stamps those fields. - `item_detail` serves a MOVIE's detail from the row (survives a live-Plex outage) while keeping markers/streams live; an EPISODE (never enriched) still parses detail from the live meta. - `show_detail` serves from the row; `related` stays live best-effort. The endpoint tests use the DB lane (`db` fixture) — skipped when no Postgres is reachable. """ from types import SimpleNamespace from fastapi.testclient import TestClient import app.routes.plex.detail as detail_routes 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, PlexShow, User from app.plex import detail from app.plex.sync import _apply_facet_fields, _enrich_people_batch # A full Plex metadata dict: an explicit IMDb Rating entry (must win over audienceRating), an IMDb Guid, # a tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None). RICH_META = { "Rating": [ {"image": "imdb://image.rating.imdb", "value": 8.7}, {"image": "themoviedb://image.rating", "value": 7.0}, ], "audienceRating": 9.9, # ignored: the imdb Rating entry is preferred "Guid": [{"id": "tmdb://238"}, {"id": "imdb://tt0068646"}], "contentRating": "R", "Genre": [{"tag": "Crime"}, {"tag": "Drama"}], "Director": [{"tag": "Francis Ford Coppola"}], "studio": "Paramount", "tagline": "An offer you can't refuse.", "Role": [ { "tag": "Marlon Brando", "role": "Vito Corleone", "thumb": "https://metadata-static.plex.tv/b.jpg", }, {"tag": "Al Pacino", "role": "Michael"}, ], } # ---- pure parse/persist -------------------------------------------------------------------------- def test_rich_detail_prefers_imdb_entry_and_builds_link(): d = detail.rich_detail(RICH_META) assert d["imdb_rating"] == 8.7 # the imdb Rating entry, not audienceRating (9.9) assert d["imdb_id"] == "tt0068646" assert d["imdb_url"] == "https://www.imdb.com/title/tt0068646/" assert d["tagline"] == "An offer you can't refuse." assert [c["name"] for c in d["cast"]] == ["Marlon Brando", "Al Pacino"] assert d["cast"][0]["role"] == "Vito Corleone" assert d["cast"][0]["thumb"] and d["cast"][0]["thumb"].startswith("/api/plex/person-image") assert d["cast"][1]["thumb"] is None # no CDN headshot → not proxied 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"] is None assert d["imdb_id"] is None and d["imdb_url"] is None def test_apply_rich_detail_writes_only_rich_fields(): row = SimpleNamespace(imdb_rating=None, imdb_id=None, tagline=None, full_cast=None) detail.apply_rich_detail(row, RICH_META) assert row.imdb_rating == 8.7 assert row.imdb_id == "tt0068646" assert row.tagline == "An offer you can't refuse." assert [c["name"] for c in row.full_cast] == ["Marlon Brando", "Al Pacino"] def test_apply_rich_detail_empty_cast_persists_null(): row = SimpleNamespace(imdb_rating=1.0, imdb_id="x", tagline="t", full_cast=[{"name": "stale"}]) detail.apply_rich_detail(row, {}) # a poor/empty meta must clear to NULL, not keep stale data assert row.full_cast is None assert row.imdb_rating is None and row.imdb_id is None and row.tagline is None # ---- backfill persistence (DB lane) -------------------------------------------------------------- class _StubClient: """Stand-in for PlexClient: `_enrich_people_batch` calls plex.metadata(rating_key); item_detail uses it as a context manager and passes markers=True. `raise_` simulates a Plex outage.""" def __init__(self, meta: dict | None = None, raise_: bool = False): self._meta = meta or {} self._raise = raise_ def __enter__(self): return self def __exit__(self, *a): return False def metadata(self, rating_key, markers=False): if self._raise: raise RuntimeError("plex unavailable") return self._meta def test_apply_facet_fields_preserves_enriched_cast_names(): """A full sync must NOT downgrade an already-enriched full cast back to the listing top-20 — since sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose the names past the listing cap (regression guard for E5 S5b's enrich gating).""" # The cheap listing's top few cast + the (always-present) director. thin = { "Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], "Director": [{"tag": "Francis Ford Coppola"}], } # Not-yet-enriched row: the listing cast IS applied. fresh = SimpleNamespace(people_enriched_at=None, directors=None, cast_names=None) _apply_facet_fields(fresh, thin) assert fresh.cast_names == ["Al Pacino", "James Caan"] # Enriched row: the fuller stored cast is KEPT, not overwritten by the thin listing. from datetime import datetime, timezone enriched = SimpleNamespace( people_enriched_at=datetime.now(timezone.utc), directors=["Francis Ford Coppola"], cast_names=["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"], ) _apply_facet_fields(enriched, thin) assert enriched.cast_names == ["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"] # people_text is rebuilt from the (updated) directors + the PRESERVED full cast. assert "Diane Keaton" in enriched.people_text and "Francis Ford Coppola" in enriched.people_text def test_apply_facet_fields_keeps_over_cap_cast_after_a_watermark_reset(): """The enriched-cast guard must survive a watermark RESET (people_enriched_at nulled, e.g. by a migration like 0065). A stored cast LONGER than the listing cap can only be an enriched fuller list, so it's kept even though the row now reads as un-enriched — comparing against the cap, not the current listing's length, is what makes this robust (regression guard for the review fix).""" thin = {"Role": [{"tag": f"Actor {i}"} for i in range(20)]} # a full 20-name listing over_cap = SimpleNamespace( people_enriched_at=None, # watermark was reset directors=None, cast_names=[f"Enriched {i}" for i in range(22)], # 22 > the 20 cap => enriched ) _apply_facet_fields(over_cap, thin) assert over_cap.cast_names == [f"Enriched {i}" for i in range(22)] # kept, not downgraded def test_apply_facet_fields_tracks_a_trimmed_listing_when_unenriched(): """A listing-sourced cast (at or below the cap, never enriched) must TRACK Plex cast edits in both directions. When Plex trims the cast, the shorter fresh listing replaces the stored one — the old length-vs-this-listing guard would have wrongly kept the stale longer cast.""" stored = SimpleNamespace( people_enriched_at=None, directors=None, cast_names=["A", "B", "C", "D", "E"], # 5, at/below the cap => listing-sourced ) trimmed = {"Role": [{"tag": "A"}, {"tag": "B"}]} # Plex removed 3 cast members _apply_facet_fields(stored, trimmed) assert stored.cast_names == ["A", "B"] def test_enrich_people_batch_persists_detail(db): lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather") db.add(mv) db.commit() assert mv.people_enriched_at is None and mv.full_cast is None n = _enrich_people_batch(db, _StubClient(RICH_META)) assert n == 1 db.refresh(mv) assert mv.imdb_rating == 8.7 assert mv.imdb_id == "tt0068646" assert mv.tagline == "An offer you can't refuse." assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] assert mv.cast_names == ["Marlon Brando", "Al Pacino"] # people enrich still runs alongside assert mv.people_enriched_at is not None # stamped so the backfill skips it next run # ---- item_detail serving (DB lane) --------------------------------------------------------------- def _get(db, user, monkeypatch, override_dep, path): monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate override_dep(get_db, db.info["request"]) override_dep(current_user, lambda: user) return TestClient(app).get(path) def _user(db, email): u = User(email=email) db.add(u) db.flush() return u def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch, override_dep): """An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises — only markers/streams (which need the live meta) degrade.""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() from datetime import datetime, timezone mv = PlexItem( rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount", content_rating="R", imdb_rating=8.7, imdb_id="tt0068646", tagline="An offer you can't refuse.", full_cast=[{"name": "Marlon Brando", "role": "Vito Corleone", "thumb": None}], people_enriched_at=datetime.now(timezone.utc), ) db.add(mv) db.commit() user = _user(db, "outage@example.com") db.commit() monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True)) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1") assert resp.status_code == 200, resp.text body = resp.json() assert body["imdb_rating"] == 8.7 assert body["imdb_id"] == "tt0068646" assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/" assert body["tagline"] == "An offer you can't refuse." assert [c["name"] for c in body["cast"]] == ["Marlon Brando"] assert body["genres"] == ["Crime", "Drama"] assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch, override_dep): """A not-yet-enriched movie is filled from the live meta on view (and persisted for next time).""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather") db.add(mv) db.commit() user = _user(db, "lazy@example.com") db.commit() monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1") assert resp.status_code == 200, resp.text assert resp.json()["imdb_id"] == "tt0068646" db.refresh(mv) assert mv.imdb_id == "tt0068646" # persisted assert mv.people_enriched_at is not None assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] def test_item_detail_episode_serves_rich_from_live(db, monkeypatch, override_dep): """Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the live meta the endpoint fetches for markers/streams.""" lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(lib) db.flush() show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show") db.add(show) db.flush() ep = PlexItem( rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id ) db.add(ep) db.commit() user = _user(db, "ep@example.com") db.commit() monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/e1") assert resp.status_code == 200, resp.text body = resp.json() assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"] db.refresh(ep) assert ep.full_cast is None # episodes are not persisted/enriched def test_show_detail_serves_from_row(db, monkeypatch, override_dep): """A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail.""" lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(lib) db.flush() from datetime import datetime, timezone show = PlexShow( rating_key="s1", library_id=lib.id, title="A Show", genres=["Drama"], imdb_rating=9.1, imdb_id="tt0903747", full_cast=[{"name": "Bryan Cranston", "role": "Walter White", "thumb": None}], people_enriched_at=datetime.now(timezone.utc), ) db.add(show) db.commit() user = _user(db, "show@example.com") db.commit() # _show_enrichment raises → related degrades to []; the row-served detail must still render. def _boom(db_, rk): raise RuntimeError("plex unavailable") monkeypatch.setattr(detail_routes, "_show_enrichment", _boom) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1") assert resp.status_code == 200, resp.text body = resp.json()["show"] assert body["imdb_rating"] == 9.1 assert body["imdb_id"] == "tt0903747" assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/" assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"] assert resp.json()["related"] == [] def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch, override_dep): """A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted + stamped) so subsequent views serve from the row.""" lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) db.add(lib) db.flush() show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show") db.add(show) db.commit() user = _user(db, "showfill@example.com") db.commit() # _show_enrichment returns (live meta, no related) → the fill branch runs. monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, [])) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/show/s1") assert resp.status_code == 200, resp.text assert resp.json()["show"]["imdb_id"] == "tt0068646" db.refresh(show) assert show.imdb_id == "tt0068646" # persisted for next time assert show.people_enriched_at is not None assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"] def test_item_detail_names_tracks_from_their_language_not_plexs_title( db, monkeypatch, override_dep ): """E30: Plex titles a track in the Plex SERVER's language ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so a Hungarian reader got a mix. The route sends what the client names it from instead — the language tag, the forced / SDH flags, the codec tail — keeping Plex's title only as the fallback, and no invented English "Audio 2" for a track with neither.""" lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) db.add(lib) db.flush() db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="T")) db.commit() user = _user(db, "tracks@example.com") db.commit() streams = [ {"streamType": 2, "displayTitle": "English (AC3 5.1)", "languageTag": "en-US"}, {"streamType": 2, "displayTitle": "中文 (EAC3 Stereo)", "languageTag": "zh"}, {"streamType": 2}, { "streamType": 3, "displayTitle": "magyar Forced", "languageTag": "hu", "forced": True, "codec": "srt", }, { "streamType": 3, "displayTitle": "English SDH", "languageTag": "en", "hearingImpaired": True, "codec": "srt", }, ] meta = {**RICH_META, "Media": [{"Part": [{"Stream": streams}]}]} monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(meta)) body = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1").json() naming = ("label", "lang_tag", "forced", "hearing_impaired", "detail") assert [tuple(a[k] for k in naming) for a in body["audio_streams"]] == [ ("English (AC3 5.1)", "en-US", False, False, "AC3 5.1"), ("中文 (EAC3 Stereo)", "zh", False, False, "EAC3 Stereo"), (None, None, False, False, None), ] assert [tuple(s[k] for k in naming) for s in body["subtitle_streams"]] == [ ("magyar Forced", "hu", True, False, None), ("English SDH", "en", False, True, None), ]