feat(plex): lazy-enrich the full cast on info-page view (S1a)
The cheap section listing that drives the main sync only stores the top ~3 cast, so any cast member below that was unfilterable (clicking them AND-emptied the grid). Now the movie/show info fetch (/item, /show) — which already pulls the full rich cast — persists it onto cast_names + people_text (so every displayed cast member becomes filterable) and caches each headshot in the new plex_people table (migration 0056). Idempotent (only grows a thinner stored set); best-effort (never 500s a read). Verified: 10 Cloverfield Lane 3->8 cast after viewing; plex_people populated with photos. (Plex cast-explore S1a)
This commit is contained in:
@@ -1129,6 +1129,23 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||
)
|
||||
|
||||
|
||||
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
|
||||
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
|
||||
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
|
||||
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
|
||||
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
|
||||
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
|
||||
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
|
||||
row); this table is ONLY the headshot cache."""
|
||||
|
||||
__tablename__ = "plex_people"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
||||
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
|
||||
|
||||
|
||||
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
||||
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
||||
|
||||
@@ -34,6 +34,7 @@ from app.models import (
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexLink,
|
||||
PlexPerson,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
PlexSeason,
|
||||
@@ -1221,6 +1222,14 @@ def show_detail(
|
||||
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) 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). Best-effort.
|
||||
if _enrich_people_from_rich(db, sh, rich):
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
return {
|
||||
"show": {
|
||||
"id": sh.rating_key,
|
||||
@@ -1627,6 +1636,43 @@ def _rich_meta(meta: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _enrich_people_from_rich(db: Session, row, rich: dict) -> bool:
|
||||
"""Persist the FULLER cast (from an item's rich metadata) onto the row's `cast_names` + the
|
||||
`people_text` search blob, and cache each cast member's headshot in plex_people. The cheap section
|
||||
listing that drives the main sync only carries the top ~3 cast, so this — run when a rich fetch
|
||||
happens (info-page view + the rich re-sync) — is what makes the full displayed cast filterable.
|
||||
Returns True if anything was written (the caller commits). Best-effort: never the reason a read
|
||||
500s (the caller wraps the commit)."""
|
||||
cast = rich.get("cast") or []
|
||||
names = list(dict.fromkeys(c["name"] for c in cast if c.get("name")))[:24]
|
||||
if not names:
|
||||
return False
|
||||
dirty = False
|
||||
# Grow/refresh cast_names only when the rich set adds names the stored (listing top-3) set lacks —
|
||||
# avoid churn and never downgrade a fuller set back to the thin listing.
|
||||
if set(names) - set(row.cast_names or []):
|
||||
row.cast_names = names
|
||||
row.people_text = " ".join(dict.fromkeys(list(row.directors or []) + names)) or None
|
||||
dirty = True
|
||||
# Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a
|
||||
# whitelisted photo still get a row so a card can show a placeholder).
|
||||
existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))}
|
||||
for c in cast:
|
||||
nm, th = c.get("name"), c.get("thumb")
|
||||
if not nm:
|
||||
continue
|
||||
p = existing.get(nm)
|
||||
if p is None:
|
||||
p = PlexPerson(name=nm, photo_thumb=th)
|
||||
db.add(p)
|
||||
existing[nm] = p
|
||||
dirty = True
|
||||
elif th and not p.photo_thumb:
|
||||
p.photo_thumb = th
|
||||
dirty = True
|
||||
return dirty
|
||||
|
||||
|
||||
@router.get("/person-image")
|
||||
def person_image(u: str, request: Request) -> Response:
|
||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
||||
@@ -1779,6 +1825,15 @@ def item_detail(
|
||||
except Exception:
|
||||
pass # metadata extras 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. A
|
||||
# movie only — episodes aren't in the grid; their show's cast is enriched from /show. Best-effort.
|
||||
if it.kind == "movie" and _enrich_people_from_rich(db, it, rich):
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
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