feat(plex): full-library cast backfill via a batched rich re-sync (S1b)

Lazy enrichment only covers viewed titles, so an actor's count/results were
partial. Add a background backfill: each plex_sync rich-fetches a bounded batch
(60) of not-yet-enriched movies/shows and persists their FULL cast, stamping
people_enriched_at so each is fetched once — the whole library is covered over
successive runs (migration 0057 adds the marker). Extracted the enrichment into a
shared app/plex/people.py (cast_from_meta / enrich_row_people / prune_orphan_people
+ the person-image host/proxy) so the info-page lazy path and the batch share ONE
implementation — no duplication; the info page also stamps the marker so the batch
skips it. Verified: an 8-item batch grows those movies' cast_names 3->24 and fills
plex_people (32->224); the info-page cast is intact after the _rich_meta refactor.
(Plex cast-explore S1b)
This commit is contained in:
2026-07-19 04:50:45 +02:00
parent 12d17ec7b0
commit af6faf476f
5 changed files with 184 additions and 87 deletions
+14 -65
View File
@@ -17,7 +17,6 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
@@ -43,6 +42,7 @@ from app.models import (
User,
)
from app.plex import paths as plex_paths
from app.plex import people as plex_people
from app.plex import stream as plex_stream
from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch
@@ -1227,8 +1227,11 @@ def show_detail(
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):
# 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:
@@ -1582,11 +1585,6 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
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
@@ -1612,21 +1610,6 @@ def _rich_meta(meta: dict) -> dict:
if gid.startswith("imdb://"):
imdb_id = gid.split("imdb://", 1)[1]
break
cast = []
for role in (meta.get("Role") or [])[:24]:
name = role.get("tag")
if not name:
continue
thumb = str(role.get("thumb") or "")
cast.append(
{
"name": name,
"role": role.get("role"),
"thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None,
}
)
return {
"imdb_rating": imdb_rating,
"imdb_id": imdb_id,
@@ -1636,47 +1619,10 @@ def _rich_meta(meta: dict) -> dict:
"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": cast,
"cast": plex_people.cast_from_meta(meta),
}
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("/people/cards")
def people_cards(
names: str,
@@ -1727,7 +1673,7 @@ def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(_PERSON_IMG_HOST):
if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = _img_cache_read(cache_key)
@@ -1875,9 +1821,12 @@ def item_detail(
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):
# 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"])
it.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception: