Files
siftlode/backend/app/plex/people.py
T
peter 705377c797 feat(plex): the people are local — bios and portraits fetched ahead, not on the click (E30 S6)
A person's TMDB bio was fetched only when their page was first opened, off the request path, so the first
open showed no bio and the page never asked again (2 of 18,202 linked people were ever enriched).

- people_enrich scheduler job (every 10 min, 300 per run): linked people's bios from TMDB and their
  portraits into the image cache, the most-credited people first. A failed TMDB lookup ends the batch.
- Portraits are resized through Plex's photo transcoder to a 320 box (~15 KB instead of ~170 KB; the
  person-image proxy uses the same cache key, and a full-size original is removed as its small twin lands).
- The person response says `enriching` while a bio is on its way (a person the job hasn't reached yet).
- "Known for" is ranked by vote count, without guest visits as themselves (talk shows had crowded out the
  films: Tom Cruise had 14 of 15), with directed works, from a 40-deep pool; the page shows up to 15.
  Migration 0093 un-stamps enriched people so the job refetches them.
- A phased job result (backfill, people_enrich) now counts as a change for the audit trail; before, an
  automatic backfill run that added videos was never audited.
- HU: the watch reconcile job reads "egyeztetés" (a leftover from the S5 sweep).
2026-09-30 02:19:54 +02:00

240 lines
11 KiB
Python

"""Shared Plex people helpers: parse cast/crew from rich metadata, persist the full cast onto a movie
or show (cast_names + the people_text search blob + the plex_people headshot cache), and prune
orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich
re-sync (plex.sync), so the two paths never diverge."""
import hashlib
from typing import Literal
from urllib.parse import quote, unquote
import httpx
from sqlalchemy import select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from app import logs
from app.models import PlexPerson
from app.plex import imgcache
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
log = logs.get_logger(__name__)
# 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 person_thumb_url(thumb: str | None) -> str | None:
"""Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN."""
t = str(thumb or "")
return (
f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
)
def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]:
"""[{name, role, thumb}] for the cast (Role tags) in a full Plex metadata dict — thumb is the
proxied person-image URL or None. Shared by the info-page response, the lazy enrichment, and the
re-sync so the parsing lives in one place."""
out: list[dict] = []
for role in (meta.get("Role") or [])[:limit]:
name = role.get("tag")
if not name:
continue
out.append(
{"name": name, "role": role.get("role"), "thumb": person_thumb_url(role.get("thumb"))}
)
return out
def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
"""Persist the FULLER cast onto the row's `cast_names` + `people_text`, 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 whenever 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 by contract: never the reason a read 500s."""
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.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
}
missing: dict[str, str | None] = {}
for c in cast:
nm, th = c.get("name"), c.get("thumb")
if not nm:
continue
p = existing.get(nm)
if p is None:
# Not yet cached — queue an insert, deduped by name (keep the first non-null thumb) so a
# single INSERT statement can't hit the same conflict target twice.
missing[nm] = missing.get(nm) or th
elif th and not p.photo_thumb:
p.photo_thumb = th
dirty = True
if missing:
# on_conflict_do_nothing keeps a cold sync from crashing when one person appears across two
# items in the same batch: SessionLocal is autoflush=False, so the ORM query above can't see a
# same-batch pending insert, and this path also races the concurrent info-page enrichment. The
# Core execute runs immediately (not deferred to the caller's commit), so a later row in the
# batch sees the row and skips it. Mirrors app/sync/videos.py:insert_stubs.
db.execute(
pg_insert(PlexPerson)
.values([{"name": nm, "photo_thumb": th} for nm, th in missing.items()])
.on_conflict_do_nothing(index_elements=[PlexPerson.name])
)
dirty = True
return dirty
# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside
# this set are orphans left by a library removal.
_REFERENCED_PEOPLE_SQL = """
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
"""
def prune_orphan_people(db: Session) -> int:
"""Delete plex_people (headshot cache) rows whose name no longer appears in any movie/show cast or
directors — orphaned by a library removal. Returns the number pruned. Commits its own work."""
# `WHERE ref IS NOT NULL` matters: a JSON-null array element surfaces as a SQL NULL in the
# referenced set, and `name NOT IN (…, NULL)` is NULL (never TRUE) for every row — the classic
# NOT IN trap that would silently prune nothing. Filtering NULLs out keeps the delete correct.
res = db.execute(
text(
"DELETE FROM plex_people WHERE name NOT IN "
f"(SELECT ref FROM ({_REFERENCED_PEOPLE_SQL}) AS refs(ref) WHERE ref IS NOT NULL)"
)
)
db.commit()
return res.rowcount or 0
# How often each person is credited across movies, shows and episodes (cast OR director) — the order the
# people_enrich job works in, so the faces met most often are local first.
_CREDIT_COUNTS_SQL = """
SELECT ref, count(*) AS n FROM (
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION ALL SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION ALL SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
) AS refs(ref) GROUP BY ref
"""
_NEEDS = {
# linked to TMDB, bio never fetched
"bio": "p.tmdb_person_id IS NOT NULL AND p.tmdb_enriched_at IS NULL",
# has a Plex headshot (whether it is cached is the disk's answer, not the row's)
"portrait": "p.photo_thumb IS NOT NULL",
}
def people_by_credits(
db: Session, need: Literal["bio", "portrait"], limit: int | None
) -> list[int]:
"""plex_people ids that `need` something, the most-credited first (ties by id)."""
rows = db.execute(
text(
f"SELECT p.id FROM plex_people p LEFT JOIN ({_CREDIT_COUNTS_SQL}) c ON c.ref = p.name "
f"WHERE {_NEEDS[need]} ORDER BY coalesce(c.n, 0) DESC, p.id"
+ (" LIMIT :limit" if limit is not None else "")
),
{"limit": limit} if limit is not None else {},
)
return [r[0] for r in rows]
# --- Portraits (E30 S6) ---------------------------------------------------------------------------
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder
# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's
# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
# a new box re-caches instead of serving a stale size, and the full-size original under the old key is
# removed as its small twin lands.
PORTRAIT_BOX = 320
def _portrait_keys(url: str) -> tuple[str, str]:
base = "person_" + hashlib.sha1(url.encode()).hexdigest()
return f"{base}_{PORTRAIT_BOX}", base
def portrait_cached(url: str) -> tuple[bytes, str] | None:
return imgcache.read(_portrait_keys(url)[0])
def cache_portrait(plex: PlexClient | None, url: str) -> tuple[bytes, str]:
"""Fetch one CDN headshot — resized through Plex when a Plex client is given, else the original
straight from the public CDN — and cache it. Raises PlexError / httpx.HTTPError on a failed fetch."""
key, legacy = _portrait_keys(url)
if plex is not None:
data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX)
else:
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data, ct = r.content, r.headers.get("content-type", "image/jpeg")
imgcache.write(key, data, ct)
if plex is not None:
imgcache.drop(legacy)
return data, ct
def cdn_url(photo_thumb: str) -> str | None:
"""The CDN url inside a stored proxied `/person-image?u=` value (see person_thumb_url)."""
_, _, q = photo_thumb.partition("?u=")
url = unquote(q)
return url if url.startswith(PERSON_IMG_HOST) else None
def warm_portraits(db: Session, limit: int) -> dict:
"""Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a
person's face comes from local disk. Needs Plex (the resize); without it, nothing is prefetched —
full-size originals for every person would cost gigabytes. A failed fetch is skipped; one failing
Plex ends the run."""
try:
plex = PlexClient(db)
# silent-ok: Plex off is a setting, not a failure; the run's result says "disabled".
except PlexNotConfigured:
return {"cached": 0, "skipped": "disabled"}
photos: dict[int, str] = {
pid: thumb
for pid, thumb in db.execute(
select(PlexPerson.id, PlexPerson.photo_thumb).where(PlexPerson.photo_thumb.is_not(None))
).tuples()
if thumb
}
done = failed = 0
with plex:
for pid in people_by_credits(db, "portrait", None):
if done + failed >= limit:
break
url = cdn_url(photos.get(pid) or "")
if url is None or imgcache.has(_portrait_keys(url)[0]):
continue
try:
cache_portrait(plex, url)
done += 1
except (PlexError, httpx.HTTPError) as exc:
failed += 1
log.warning(
"a portrait could not be cached",
extra=logs.event(
"plex.people.portrait_failed", person_id=pid, reason=type(exc).__name__
),
)
if failed >= 5:
break
return {"cached": done, **({"failed": failed} if failed else {})}