E5 S6: enriched person page (TMDB bio + birth facts + Known-for rail)

The person page now leads with a TMDB bio, lazily fetched the first time it's
opened (off the S2-linked `tmdb_person_id`) and then served from the row:

- Migration 0072: ADD-only nullable person columns on plex_people — biography,
  birthday, deathday, place_of_birth, known_for_department, also_known_as,
  known_for ([{tmdb_id, media_type, title}]), tmdb_enriched_at.
- tmdb.py: TmdbClient.fetch_person (/person/{id}+combined_credits), parse_person
  (bio + a popularity-ranked, deduped known_for), apply_person, enrich_person
  (lazy one-shot, key+id guarded, commits, 404 stamps the watermark).
- people.py person_detail: loads the full person row, lazily enriches (best-effort
  — never fails the page), serves the bio fields + resolves known_for to library
  rows by guid (in-library only, popularity order).
- FE PlexPersonView: bio/birth-facts/aka hero + a "Known for" Carousel rail above
  the existing Movies/Shows filmography; PlexPersonDetail type + i18n (en+hu).

Tests: test_plex_person_bio.py (6: parse / lazy-enrich guards / serve) + extended
PlexPersonView.dom.test.tsx + the unknown-name contract in test_plex_person.py.
Browser-E2E'd on Jim Carrey (live TMDB enrich → bio 1937 chars, born 1962-01-17
Newmarket, Known-for resolved). Gate: vitest 337, backend lane 402.
This commit is contained in:
2026-08-21 00:08:19 +02:00
parent f10915c223
commit 187b8007f8
11 changed files with 462 additions and 30 deletions
+129
View File
@@ -0,0 +1,129 @@
"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a
popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a
key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows.
DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed.
"""
from fastapi.testclient import TestClient
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexItem, PlexLibrary, PlexPerson, User
from app.plex import tmdb as plex_tmdb
PAYLOAD = {
"biography": " A celebrated actor. ",
"birthday": "1970-01-01",
"deathday": None,
"place_of_birth": "Springfield",
"known_for_department": "Acting",
"also_known_as": ["AKA One", "AKA Two"],
"combined_credits": {
"cast": [
{"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50},
{"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80},
{"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id
{"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1},
{"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped
]
},
}
def test_parse_person_shapes_bio_and_ranked_known_for():
p = plex_tmdb.parse_person(PAYLOAD)
assert p["biography"] == "A celebrated actor." # stripped
assert p["birthday"] == "1970-01-01"
assert p["known_for_department"] == "Acting"
assert p["also_known_as"] == ["AKA One", "AKA Two"]
# Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1).
assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30]
assert p["known_for"][0]["media_type"] == "tv"
def test_parse_person_empty_payload():
p = plex_tmdb.parse_person({})
assert p["biography"] is None and p["known_for"] is None
class _FakeTmdb:
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fetch_person(self, pid):
return self.payload
def test_enrich_person_persists_and_stamps(db, monkeypatch):
person = PlexPerson(name="Actor X", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "")
monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d: _FakeTmdb(PAYLOAD))
assert plex_tmdb.enrich_person(db, person) is True
assert person.biography == "A celebrated actor."
assert person.tmdb_enriched_at is not None
assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30]
def test_enrich_person_noop_without_key(db, monkeypatch):
person = PlexPerson(name="Actor Y", tmdb_person_id=555)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None
def test_enrich_person_noop_without_tmdb_id(db, monkeypatch):
person = PlexPerson(name="Actor Z", tmdb_person_id=None)
db.add(person)
db.commit()
monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key")
assert plex_tmdb.enrich_person(db, person) is False
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
# A library movie that IS one of the person's known_for credits (matched by guid tmdb:100).
db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie",
guid="tmdb:100", cast_names=["Actor X"]))
# Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool.
from datetime import datetime, timezone
db.add(PlexPerson(name="Actor X", tmdb_person_id=555,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio here.", birthday="1970-01-01", known_for_department="Acting",
also_known_as=["Alt Name"],
known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"},
{"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}]))
user = User(email="personbio@example.com")
db.add(user)
db.commit()
resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X")
assert resp.status_code == 200, resp.text
j = resp.json()
assert j["biography"] == "Bio here."
assert j["birthday"] == "1970-01-01"
assert j["also_known_as"] == ["Alt Name"]
# Only the in-library known_for credit resolves to a card; the out-of-library one is dropped.
assert [c["title"] for c in j["known_for"]] == ["Known Movie"]