feat(plex): S5b persist rich detail + serve item/show detail from DB

Persist the info-page rich fields that were previously read LIVE from plex.metadata() on every view —
imdb_rating (IMDb-specific, vs `rating`=audienceRating), imdb_id, tagline, and full_cast
([{name,role,thumb}]) — onto plex_items/plex_shows, and serve the detail endpoints from the row.

- migration 0065: ADD imdb_rating/imdb_id/tagline/full_cast (nullable → no rewrite/lock, unlike 0064);
  NULL people_enriched_at on movie/show rows so the existing cast backfill re-fetches each once and
  fills the new columns.
- new app/plex/detail.py: rich_detail(meta) (moved out of browse._rich_meta so sync + serve share one
  parser) + apply_rich_detail(row, meta); wired into _enrich_people_batch and the info-page lazy fill.
- item_detail: movies serve cast/IMDb/tagline/genres from the row and keep plex.metadata(markers=True)
  ONLY for markers + audio/subtitle streams (so detail survives a live-Plex outage); episodes (never
  enriched) still parse detail from that live meta.
- show_detail: serves cast/IMDb from the row; `related` stays live best-effort and also lazily fills a
  not-yet-enriched show.
- tests: rich_detail/apply_rich_detail parsing + persistence, backfill stamping, item_detail
  outage-resilience + lazy fill + episode-live, show_detail row serving. Full suite 285 pass / 2 skip;
  migration verified reversible (up→down→up).

Precondition for S6's pure-DB language-aware serving.
This commit is contained in:
2026-08-16 23:11:15 +02:00
parent 02a2102523
commit 4e97fc1a84
6 changed files with 423 additions and 74 deletions
+50 -73
View File
@@ -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 {