Files
siftlode/backend/tests/test_plex_person_bio.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

407 lines
15 KiB
Python

"""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
def test_parse_person_keeps_same_id_across_media_types():
# TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works
# and BOTH must survive the dedup (keyed on (id, media_type), not the bare id).
payload = {
"combined_credits": {
"cast": [
{"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90},
{"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80},
{
"id": 42,
"media_type": "movie",
"title": "dup movie 42",
"popularity": 5,
}, # real dup
]
}
}
kf = plex_tmdb.parse_person(payload)["known_for"]
assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [
(42, "movie"),
(42, "tv"),
] # both kept, dup dropped
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, timeout=None: _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]
class _FakeTmdbResult:
"""Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload."""
def __init__(self, *, raises=False, payload=None):
self._raises = raises
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *a):
return False
def fetch_person(self, pid):
if self._raises:
raise plex_tmdb.TmdbError("429 rate limited")
return self._payload
def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch):
# A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently
# blank a person after one blip).
person = PlexPerson(name="Transient P", 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, timeout=None: _FakeTmdbResult(raises=True)
)
assert plex_tmdb.enrich_person(db, person) is False
assert person.tmdb_enriched_at is None # left NULL → retried next open
def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch):
# A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open.
person = PlexPerson(name="Gone P", 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, timeout=None: _FakeTmdbResult(payload=None)
)
assert plex_tmdb.enrich_person(db, person) is True
assert person.tmdb_enriched_at is not None # stamped
assert person.biography is None # nothing fetched
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, override_dep, path):
monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user)
return TestClient(app).get(path)
def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, override_dep):
# The bio fetch runs OFF the request path (a background task after the response), so the serve never
# makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules
# the enrich and the page still renders immediately with whatever's in the row (bio still null).
from app.routes.plex import people
lib = PlexLibrary(plex_key="9", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(PlexPerson(name="Lazy P", tmdb_person_id=777))
user = User(email="lazybg@example.com")
db.add(user)
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key")
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Lazy P")
assert resp.status_code == 200, resp.text
assert resp.json()["biography"] is None # not enriched yet — the page asks again (`enriching`)
assert len(scheduled) == 1 # the enrich was scheduled off the request path
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
from app.routes.plex import people
calls = {"rollback": 0, "close": 0, "enriched": 0}
class _FakeSession:
def __init__(self, person):
self._person = person
def get(self, model, pid):
return self._person
def rollback(self):
calls["rollback"] += 1
def close(self):
calls["close"] += 1
# Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr(
people.plex_tmdb,
"enrich_person",
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
)
people._enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1}
# Error path: enrich raises → rollback + close, no exception escapes.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
def _boom(db, p):
raise RuntimeError("enrich blew up")
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom)
people._enrich_person_bg(5) # must not raise
assert calls["rollback"] == 1 and calls["close"] == 1
# Missing person (deleted id): None-guard skips enrich, session still closed.
calls.update({"rollback": 0, "close": 0, "enriched": 0})
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None))
people._enrich_person_bg(9)
assert calls == {"rollback": 0, "close": 1, "enriched": 0}
def test_should_schedule_enrich_dedups_within_cooldown():
# Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window.
from app.routes.plex import people
people._enrich_inflight.clear()
assert people._should_schedule_enrich(12345) is True # first open reserves
assert people._should_schedule_enrich(12345) is False # within cooldown → deduped
assert people._should_schedule_enrich(99999) is True # a different person is independent
people._enrich_inflight.clear()
def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override_dep):
# An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch.
from datetime import datetime, timezone
from app.routes.plex import people
lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
db.add(
PlexPerson(
name="Done P",
tmdb_person_id=888,
tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
biography="Bio.",
)
)
user = User(email="donebg@example.com")
db.add(user)
db.commit()
scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text
assert resp.json()["biography"] == "Bio."
assert scheduled == [] # already enriched → no background work
def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch, override_dep):
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, override_dep, "/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"]
# --- E30 S4: the biography in the reader's language --------------------------------------------------
# The same /person request appends `translations`; the Hungarian one is kept beside the instance-language
# biography, and a Hungarian reader is served it, falling back to the English one when TMDB has none.
def _translation(lang: str, bio: str) -> dict:
return {"iso_639_1": lang, "iso_3166_1": lang.upper(), "data": {"biography": bio}}
def test_parse_person_keeps_the_hungarian_biography():
payload = {
**PAYLOAD,
"translations": {
"translations": [
_translation("de", "Ein Schauspieler."),
_translation("hu", " Ünnepelt színész. "),
]
},
}
assert plex_tmdb.parse_person(payload)["biography_hu"] == "Ünnepelt színész."
def test_parse_person_without_a_hungarian_biography():
assert plex_tmdb.parse_person(PAYLOAD)["biography_hu"] is None
blank = {**PAYLOAD, "translations": {"translations": [_translation("hu", " ")]}}
assert plex_tmdb.parse_person(blank)["biography_hu"] is None
def test_fetch_person_asks_for_the_translations():
client = plex_tmdb.TmdbClient.__new__(plex_tmdb.TmdbClient)
seen: list[dict] = []
client._get = lambda path, params=None: seen.append(params or {}) or {} # type: ignore[method-assign]
client.fetch_person(500)
assert "translations" in seen[0]["append_to_response"].split(",")
def test_person_detail_serves_the_biography_in_the_readers_language(db, monkeypatch, override_dep):
from datetime import datetime, timezone
done = datetime(2024, 1, 1, tzinfo=timezone.utc)
db.add(PlexLibrary(plex_key="7", title="Movies", kind="movie", enabled=True))
db.add_all(
[
PlexPerson(
name="Both P",
tmdb_person_id=1,
tmdb_enriched_at=done,
biography="English bio.",
biography_hu="Magyar életrajz.",
),
PlexPerson(
name="Only EN P", tmdb_person_id=2, tmdb_enriched_at=done, biography="Only English."
),
]
)
user = User(email="biolang@example.com")
db.add(user)
db.commit()
def bio(name: str, lang: str) -> str | None:
resp = _get(db, user, monkeypatch, override_dep, f"/api/plex/person/{name}?lang={lang}")
assert resp.status_code == 200, resp.text
return resp.json()["biography"]
assert bio("Both P", "hu") == "Magyar életrajz."
assert bio("Both P", "en") == "English bio."
assert (
bio("Only EN P", "hu") == "Only English."
) # TMDB has no Hungarian one: the English stands in