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).
270 lines
11 KiB
Python
270 lines
11 KiB
Python
"""Plex people: cast/crew filter-chip cards + a single person's filmography page."""
|
|
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends
|
|
from sqlalchemy import func, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.collation import collated
|
|
from app import logs, sysconfig
|
|
from app.auth import current_user
|
|
from app.db import SessionLocal, get_db
|
|
from app.models import (
|
|
PlexItem,
|
|
PlexPerson,
|
|
PlexShow,
|
|
User,
|
|
)
|
|
from app.plex import rollup
|
|
from app.plex import tmdb as plex_tmdb
|
|
from app.plex.cards import movie_card, norm_lang, show_card
|
|
from app.plex.query import content_libs, split_csv, title_col
|
|
|
|
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
|
log = logs.get_logger(__name__)
|
|
|
|
|
|
# How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb).
|
|
_KNOWN_FOR_SHOWN = 15
|
|
|
|
|
|
def _resolve_known_for(
|
|
db: Session,
|
|
known_for: list | None,
|
|
uid: int,
|
|
movie_lib_ids: list[int],
|
|
show_lib_ids: list[int],
|
|
lang: str,
|
|
) -> list[dict]:
|
|
"""The person's TMDB "Known for" credits (most-voted first) that are IN our library, as cards, in
|
|
that order, at most _KNOWN_FOR_SHOWN. Matched by the canonical guid `tmdb:<id>`; out-of-library
|
|
credits are dropped."""
|
|
if not known_for:
|
|
return []
|
|
# Match each credit to a library row by guid, bucketed by media_type (movie ids and tv ids collide
|
|
# numerically) and scoped to the enabled libraries — the shared helper owns that collision-safe
|
|
# logic (the detail-page recs use the same one).
|
|
movies, shows = plex_tmdb.resolve_library_guids(
|
|
db, known_for, movie_lib_ids=movie_lib_ids, show_lib_ids=show_lib_ids
|
|
)
|
|
m_states = rollup.item_state_map(db, uid, [m.id for m in movies.values()])
|
|
show_statuses = rollup.state_map(db, uid, [s.id for s in shows.values()])
|
|
out: list[dict] = []
|
|
for k in known_for:
|
|
g = f"tmdb:{k.get('tmdb_id')}"
|
|
# A credit resolves against its OWN media_type's bucket only.
|
|
if k.get("media_type") == "tv":
|
|
if g in shows:
|
|
out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"), lang=lang))
|
|
elif g in movies:
|
|
out.append(movie_card(movies[g], m_states.get(movies[g].id), lang=lang))
|
|
return out[:_KNOWN_FOR_SHOWN]
|
|
|
|
|
|
@router.get("/people/cards")
|
|
def people_cards(
|
|
names: str,
|
|
scope: str = "both",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""Cards for the actor filter-chips currently applied: each name + its cached headshot + how many
|
|
movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated
|
|
as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names.
|
|
A name with no cached photo yet still returns a card (the UI shows a placeholder)."""
|
|
wanted = split_csv(names)[:16]
|
|
if not wanted:
|
|
return {"people": []}
|
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
|
photos = {
|
|
p.name: p.photo_thumb
|
|
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars()
|
|
}
|
|
out = []
|
|
for nm in wanted:
|
|
count = 0
|
|
if want_movies:
|
|
count += (
|
|
db.scalar(
|
|
select(func.count(PlexItem.id)).where(
|
|
PlexItem.kind == "movie",
|
|
PlexItem.library_id.in_(movie_lib_ids),
|
|
PlexItem.cast_names.contains([nm]),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
if want_shows:
|
|
count += (
|
|
db.scalar(
|
|
select(func.count(PlexShow.id)).where(
|
|
PlexShow.library_id.in_(show_lib_ids),
|
|
PlexShow.cast_names.contains([nm]),
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
out.append({"name": nm, "photo": photos.get(nm), "count": count})
|
|
return {"people": out}
|
|
|
|
|
|
# Cap each filmography section — a prolific person shouldn't return an unbounded list the client then
|
|
# mounts a card per row for (the grid paginates at PAGE; this page renders all at once). Ample for a
|
|
# personal library; the most-recent titles win via the year-desc order.
|
|
_PERSON_SECTION_MAX = 200
|
|
|
|
|
|
# A person's bio is enriched at most once per this window: it dedups concurrent opens of the same person
|
|
# (each would otherwise schedule its own 8s TMDB task) AND rate-limits re-triggering when TMDB is
|
|
# down/slow (a transient failure leaves tmdb_enriched_at NULL, so without this every reload would spawn a
|
|
# fresh task and pile up on the shared threadpool). In-process + best-effort: a restart just clears it, at
|
|
# worst costing a few extra fetches. A successful enrich stamps the watermark, so the reservation only
|
|
# ever expires harmlessly.
|
|
_ENRICH_COOLDOWN_S = 60.0
|
|
_enrich_inflight: dict[int, float] = {}
|
|
_enrich_lock = threading.Lock()
|
|
|
|
|
|
def _should_schedule_enrich(person_id: int) -> bool:
|
|
"""True (and reserves a cooldown) if this person isn't already being enriched / recently attempted."""
|
|
now = time.monotonic()
|
|
with _enrich_lock:
|
|
deadline = _enrich_inflight.get(person_id)
|
|
if deadline is not None and now < deadline:
|
|
return False
|
|
_enrich_inflight[person_id] = now + _ENRICH_COOLDOWN_S
|
|
# Bound the map: drop expired reservations once it grows (a large library has many people).
|
|
if len(_enrich_inflight) > 1024:
|
|
for pid in [p for p, d in _enrich_inflight.items() if d <= now]:
|
|
del _enrich_inflight[pid]
|
|
return True
|
|
|
|
|
|
def _enrich_person_bg(person_id: int) -> None:
|
|
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
|
|
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
|
|
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
|
|
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
|
|
next open, and the bio lands from the row by then."""
|
|
db = SessionLocal()
|
|
try:
|
|
person = db.get(PlexPerson, person_id)
|
|
if person is not None:
|
|
plex_tmdb.enrich_person(db, person)
|
|
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
|
|
db.rollback()
|
|
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
@router.get("/person/{name}")
|
|
def person_detail(
|
|
name: str,
|
|
background_tasks: BackgroundTasks,
|
|
lang: str = "en",
|
|
user: User = Depends(current_user),
|
|
db: Session = Depends(get_db),
|
|
) -> dict:
|
|
"""One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled
|
|
libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter
|
|
link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct
|
|
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
|
|
simply returns empty sections.
|
|
|
|
E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily
|
|
fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open.
|
|
The fetch runs OFF the request path (a background task); the first open serves whatever's in the row
|
|
with `enriching` set, and the page asks again until the bio lands. E30 S6: the people_enrich job
|
|
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
|
|
"""
|
|
lang = norm_lang(lang)
|
|
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
|
|
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
|
|
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
|
|
# can't pile background tasks onto the shared threadpool.
|
|
# `enriching` tells the page the bio is on its way (E30 S6): it asks again shortly instead of showing
|
|
# an empty page until the next open. True while a fetch is pending, including one an earlier open
|
|
# already reserved.
|
|
enriching = (
|
|
person is not None
|
|
and bool(person.tmdb_person_id)
|
|
and person.tmdb_enriched_at is None
|
|
and bool(sysconfig.get_str(db, "tmdb_api_key"))
|
|
)
|
|
if enriching and person is not None and _should_schedule_enrich(person.id):
|
|
background_tasks.add_task(_enrich_person_bg, person.id)
|
|
movie_lib_ids, show_lib_ids = content_libs(db)
|
|
|
|
movies: list[dict] = []
|
|
if movie_lib_ids:
|
|
items = (
|
|
db.execute(
|
|
select(PlexItem)
|
|
.where(
|
|
PlexItem.kind == "movie",
|
|
PlexItem.library_id.in_(movie_lib_ids),
|
|
or_(
|
|
PlexItem.cast_names.contains([name]),
|
|
PlexItem.directors.contains([name]),
|
|
),
|
|
)
|
|
.order_by(
|
|
PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang)
|
|
)
|
|
.limit(_PERSON_SECTION_MAX)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
states = rollup.item_state_map(db, user.id, [it.id for it in items])
|
|
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
|
|
|
|
shows: list[dict] = []
|
|
if show_lib_ids:
|
|
show_rows = (
|
|
db.execute(
|
|
select(PlexShow)
|
|
.where(
|
|
PlexShow.library_id.in_(show_lib_ids),
|
|
or_(
|
|
PlexShow.cast_names.contains([name]),
|
|
PlexShow.directors.contains([name]),
|
|
),
|
|
)
|
|
.order_by(
|
|
PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang)
|
|
)
|
|
.limit(_PERSON_SECTION_MAX)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
|
|
shows = [show_card(sh, statuses.get(sh.id, "new"), lang=lang) for sh in show_rows]
|
|
|
|
known_for = _resolve_known_for(
|
|
db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids, lang
|
|
)
|
|
return {
|
|
"name": name,
|
|
"photo": person.photo_thumb if person else None,
|
|
# The Hungarian biography for a Hungarian reader, the instance-language one when TMDB has none.
|
|
"biography": ((person.biography_hu if lang == "hu" else None) or person.biography)
|
|
if person
|
|
else None,
|
|
"birthday": person.birthday if person else None,
|
|
"deathday": person.deathday if person else None,
|
|
"place_of_birth": person.place_of_birth if person else None,
|
|
"known_for_department": person.known_for_department if person else None,
|
|
"also_known_as": (person.also_known_as if person else None) or [],
|
|
"known_for": known_for,
|
|
"enriching": enriching,
|
|
"movies": movies,
|
|
"shows": shows,
|
|
}
|