Address the /code-review high finding on S5b: gating the info-page cast enrich on `people_enriched_at is None` removed the old per-view regrow of `cast_names`, so a routine full sync that reset it to the listing top-20 (`_apply_facet_fields`) — while never clearing people_enriched_at — left it permanently truncated, silently dropping cast past the top ~20 from the S4 person-page filter. Root fix: `_apply_facet_fields` now only takes the listing's cast for a NOT-yet-enriched row; an enriched row keeps its fuller stored cast (people_text still rebuilt from the updated directors + the preserved cast). Makes the people_enriched_at flag the endpoints/backfill trust actually reliable. Tests: cast_names preserved for an enriched row vs applied for a fresh one; show_detail lazy-fill persists imdb/full_cast on first view. Full suite green.
302 lines
13 KiB
Python
302 lines
13 KiB
Python
"""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 _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_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_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_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"] == []
|
|
|
|
|
|
def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch):
|
|
"""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(browse, "_show_enrichment", lambda db_, rk: (RICH_META, []))
|
|
resp = _get(db, user, monkeypatch, "/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"]
|