Files
siftlode/backend/tests/test_plex_person_bio.py
T
peter 3b0e365e26 refactor(plex): the browse, detail, people and collection pages read through queries and write through the service
The HTTP files browse, detail, people, collections and _shared do no database work of their own
now. New plex/queries.py (the rows a page shows, a user's watch state through userscope),
plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a
failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the
unified browse feed, its search and facets) and plex/collections.py (a collection's source,
editability, card, certificate and member order). Every watch-state join in the moved code names
its owner through owned_by.

Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
2026-10-19 22:11:20 +02:00

408 lines
15 KiB
Python

"""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