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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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