"""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 User from app.plex.models import PlexItem, PlexLibrary, PlexPerson 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.service, "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.plex import service 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(service, "SessionLocal", lambda: _FakeSession(object())) monkeypatch.setattr( service.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1), ) service.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(service.plex_tmdb, "enrich_person", _boom) service.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(service, "SessionLocal", lambda: _FakeSession(None)) service.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.service, "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"] # --- 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