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:
2026-07-19 03:31:34 +02:00
parent d462834ed7
commit 0030f32a5d
3 changed files with 106 additions and 0 deletions
+17
View File
@@ -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
+55
View File
@@ -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 {