diff --git a/backend/alembic/versions/0065_plex_detail_fields.py b/backend/alembic/versions/0065_plex_detail_fields.py new file mode 100644 index 00000000..c5b62be6 --- /dev/null +++ b/backend/alembic/versions/0065_plex_detail_fields.py @@ -0,0 +1,45 @@ +"""plex rich-detail item/show columns (E5 S5b) + +On plex_items and plex_shows: the fields that were previously read LIVE from plex.metadata() on every +info-page view — `imdb_rating` (the IMDb-specific score, distinct from `rating` = audienceRating), +`imdb_id` (tt…), `tagline`, and `full_cast` (ordered [{name, role, thumb}]). Persisting them lets the +detail endpoints serve from the DB row instead of a live Plex call (outage-resilient, and the +precondition for S6's pure-DB language-aware serving). + +These are filled by the same rich backfill that already fills the full cast (`_enrich_people_batch`), +so the data step below resets `people_enriched_at` to NULL on movie/show rows: the backfill re-fetches +each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table +rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0065_plex_detail_fields" +down_revision: Union[str, None] = "0064_plex_hu_overlay_cols" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("plex_items", "plex_shows") + + +def upgrade() -> None: + for t in _TABLES: + op.add_column(t, sa.Column("imdb_rating", sa.Float(), nullable=True)) + op.add_column(t, sa.Column("imdb_id", sa.String(16), nullable=True)) + op.add_column(t, sa.Column("tagline", sa.Text(), nullable=True)) + op.add_column(t, sa.Column("full_cast", postgresql.JSONB(), nullable=True)) + # Force a one-time re-enrich so already-enriched rows gain the new columns (episodes are never + # enriched, so their NULL people_enriched_at is unaffected; they keep serving detail live). + op.execute("UPDATE plex_items SET people_enriched_at = NULL WHERE kind = 'movie'") + op.execute("UPDATE plex_shows SET people_enriched_at = NULL") + + +def downgrade() -> None: + for t in _TABLES: + op.drop_column(t, "full_cast") + op.drop_column(t, "tagline") + op.drop_column(t, "imdb_id") + op.drop_column(t, "imdb_rating") diff --git a/backend/app/models.py b/backend/app/models.py index f5ad0d7b..65078e47 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1048,6 +1048,12 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. + imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) + imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link + tagline: Mapped[str | None] = mapped_column(Text) + full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched, # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) @@ -1159,6 +1165,12 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): people_text: Mapped[str | None] = mapped_column(Text) # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. + imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) + imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link + tagline: Mapped[str | None] = mapped_column(Text) + full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py new file mode 100644 index 00000000..0935af9a --- /dev/null +++ b/backend/app/plex/detail.py @@ -0,0 +1,61 @@ +"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that +the detail endpoints serve from the DB row (E5 S5b). + +Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so +the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. +`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row +(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`).""" + +from app.plex import people + + +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. + imdb_rating = None + for r in meta.get("Rating") or []: + if str(r.get("image") or "").startswith("imdb://"): + try: + imdb_rating = round(float(r.get("value")), 1) + 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 []: + gid = str(g.get("id") or "") + if gid.startswith("imdb://"): + imdb_id = gid.split("imdb://", 1)[1] + break + return { + "imdb_rating": imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "content_rating": meta.get("contentRating"), + "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], + "directors": [ + d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") + ][:4], + "studio": meta.get("studio"), + "tagline": meta.get("tagline"), + "cast": people.cast_from_meta(meta), + } + + +def apply_rich_detail(row, meta: dict) -> None: + """Persist the rich-ONLY detail fields onto a movie/show row so the info page serves them from the + DB instead of a live Plex call. Call wherever a rich metadata dict is fetched (the cast backfill + + the info-page lazy enrich). Empty cast persists as SQL NULL (none_as_null), so an EN fallback and + the `full_cast IS NULL` "not yet enriched" signal stay meaningful.""" + d = rich_detail(meta) + row.imdb_rating = d["imdb_rating"] + row.imdb_id = d["imdb_id"] + row.tagline = d["tagline"] + row.full_cast = d["cast"] or None diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 8a76317f..b4dd20fa 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -17,7 +17,7 @@ from sqlalchemy.orm import Session from app import sysconfig from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow -from app.plex import people +from app.plex import detail, people from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key @@ -258,6 +258,7 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e) continue # leave people_enriched_at NULL so it's retried on a later run people.enrich_row_people(db, row, people.cast_from_meta(meta)) + detail.apply_rich_detail(row, meta) # imdb/tagline/full_cast → served from the row, not live row.people_enriched_at = now n += 1 db.commit() diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 1c5b0b27..2bf98d3f 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -39,6 +39,7 @@ from app.models import ( PlexState, User, ) +from app.plex import detail as plex_detail from app.plex import people as plex_people from app.plex import rollup, subtitles from app.plex import sync as plex_sync @@ -1392,31 +1393,28 @@ def show_detail( all_cards.append(card) show_roll = rollup.rollup(all_cards) - # Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live - # Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping - # (_related_show_cards adds this user's watch-state) stays out of the shared cache. - rich: dict = {} + # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one + # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries + # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and + # the lazy fill; the row-served detail below still renders (outage-resilient). related: list[dict] = [] try: meta, related_raw = _show_enrichment(db, sh.rating_key) - rich = _rich_meta(meta) related = _related_show_cards(db, user.id, related_raw, exclude=sh.id) + if sh.people_enriched_at is None and meta: + plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) + plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views + sh.people_enriched_at = datetime.now(timezone.utc) + try: + db.commit() + except Exception: + db.rollback() except Exception: - # Best-effort enrichment only — the local episode list is already assembled, so any live-Plex - # failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500. + # Best-effort only — the local episode list is already assembled, so any live-Plex failure + # (not configured, HTTP/JSON shape) must degrade, not 500. pass - # Lazy enrichment: persist the show's full cast so its cast members become filterable (same as the - # movie info page — the sync only stored the listing's top ~3), and stamp people_enriched_at so the - # backfill skips it. Best-effort. - if rich.get("cast"): - plex_people.enrich_row_people(db, sh, rich["cast"]) - sh.people_enriched_at = datetime.now(timezone.utc) - try: - db.commit() - except Exception: - db.rollback() - + imdb_id = sh.imdb_id return { "show": { "id": sh.rating_key, @@ -1431,10 +1429,10 @@ def show_detail( "genres": sh.genres or [], "studio": sh.studio, "season_count": sh.child_count, - "imdb_rating": rich.get("imdb_rating"), - "imdb_id": rich.get("imdb_id"), - "imdb_url": rich.get("imdb_url"), - "cast": rich.get("cast", []), + "imdb_rating": sh.imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "cast": sh.full_cast or [], # Show-level rollup for the hero action buttons. "status": show_roll["status"], "resume": show_roll["resume"], @@ -1495,48 +1493,6 @@ def _related_show_cards( return out -def _rich_meta(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. - imdb_rating = None - for r in meta.get("Rating") or []: - if str(r.get("image") or "").startswith("imdb://"): - try: - imdb_rating = round(float(r.get("value")), 1) - 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 []: - gid = str(g.get("id") or "") - if gid.startswith("imdb://"): - imdb_id = gid.split("imdb://", 1)[1] - break - return { - "imdb_rating": imdb_rating, - "imdb_id": imdb_id, - "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, - "content_rating": meta.get("contentRating"), - "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], - "directors": [ - d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") - ][:4], - "studio": meta.get("studio"), - "tagline": meta.get("tagline"), - "cast": plex_people.cast_from_meta(meta), - } - - def _content_libs(db: Session) -> tuple[list[int], list[int]]: """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never @@ -1767,14 +1723,16 @@ def item_detail( ) ).scalars().first() - rich: dict = {} + # Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/ + # tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only + # parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay). + meta: dict = {} markers: list[dict] = [] audio_streams: list[dict] = [] subtitle_streams: list[dict] = [] try: with PlexClient(db) as plex: meta = plex.metadata(it.rating_key, markers=True) or {} - rich = _rich_meta(meta) for m in meta.get("Marker") or []: if m.get("type") in ("intro", "credits"): markers.append( @@ -1814,20 +1772,39 @@ def item_detail( } ) except Exception: - pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them + pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them - # Lazy enrichment: the rich fetch above carries the FULL cast (the sync only stored the listing's - # top ~3), so persist it onto this movie so every cast member shown here becomes filterable, and - # stamp people_enriched_at so the backfill skips it. Movie only — episodes aren't in the grid; a - # show's cast is enriched from /show. Best-effort. - if it.kind == "movie" and rich.get("cast"): - plex_people.enrich_row_people(db, it, rich["cast"]) + # Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync + # only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable + # and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show. + if it.kind == "movie" and it.people_enriched_at is None and meta: + cast = plex_people.cast_from_meta(meta) + if cast: + plex_people.enrich_row_people(db, it, cast) + plex_detail.apply_rich_detail(it, meta) it.people_enriched_at = datetime.now(timezone.utc) try: db.commit() except Exception: db.rollback() + # Movies serve rich detail from the DB row; episodes from the live meta just fetched. + if it.kind == "movie": + _imdb_id = it.imdb_id + rich = { + "cast": it.full_cast or [], + "imdb_rating": it.imdb_rating, + "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 [], + "directors": it.directors or [], + "studio": it.studio, + "tagline": it.tagline, + } + else: + rich = plex_detail.rich_detail(meta) + prev_id, next_id = _episode_nav(db, it) show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None return { diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py new file mode 100644 index 00000000..481ebee8 --- /dev/null +++ b/backend/tests/test_plex_detail.py @@ -0,0 +1,253 @@ +"""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.browse as browse +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 _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_falls_back_to_audience_rating(): + d = detail.rich_detail({"audienceRating": 7.25}) + assert d["imdb_rating"] == 7.2 # rounded to 1dp + 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_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, path): + monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) + finally: + app.dependency_overrides.clear() + + +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): + """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(browse, "PlexClient", lambda db: _StubClient(raise_=True)) + resp = _get(db, user, monkeypatch, "/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): + """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(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + resp = _get(db, user, monkeypatch, "/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): + """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(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + resp = _get(db, user, monkeypatch, "/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): + """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(browse, "_show_enrichment", _boom) + resp = _get(db, user, monkeypatch, "/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"] == []