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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user