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:
@@ -0,0 +1,34 @@
|
|||||||
|
"""plex_people: a small cast/crew headshot cache for person filter-chips
|
||||||
|
|
||||||
|
Revision ID: 0056_plex_people
|
||||||
|
Revises: 0055_channel_keywords
|
||||||
|
Create Date: 2026-07-19
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0056_plex_people"
|
||||||
|
down_revision: Union[str, None] = "0055_channel_keywords"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"plex_people",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
# A representative proxied headshot URL (or NULL when none is available). Captured
|
||||||
|
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
|
||||||
|
sa.Column("photo_thumb", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_people_name", table_name="plex_people")
|
||||||
|
op.drop_table("plex_people")
|
||||||
@@ -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):
|
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
"""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
|
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,
|
PlexItem,
|
||||||
PlexLibrary,
|
PlexLibrary,
|
||||||
PlexLink,
|
PlexLink,
|
||||||
|
PlexPerson,
|
||||||
PlexPlaylist,
|
PlexPlaylist,
|
||||||
PlexPlaylistItem,
|
PlexPlaylistItem,
|
||||||
PlexSeason,
|
PlexSeason,
|
||||||
@@ -1221,6 +1222,14 @@ def show_detail(
|
|||||||
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
|
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
|
||||||
pass
|
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 {
|
return {
|
||||||
"show": {
|
"show": {
|
||||||
"id": sh.rating_key,
|
"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")
|
@router.get("/person-image")
|
||||||
def person_image(u: str, request: Request) -> Response:
|
def person_image(u: str, request: Request) -> Response:
|
||||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
"""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:
|
except Exception:
|
||||||
pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
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)
|
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
|
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user