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).
407 lines
15 KiB
Python
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
|