E5 S2: prioritise TMDB crew, and capture the TV showrunner

A live smoke against the real TMDB API showed two crew problems the mocked
tests missed: (1) a TV show's creator/showrunner lives in the payload's
top-level `created_by`, not the crew list, so it was dropped entirely; and
(2) TMDB's crew order is arbitrary — a TV show's per-episode Directors (dozens)
flooded the key-crew list and buried the show-level roles.

Fix: split the accepted crew jobs by media type — movies key on
Director/Writer/…; TV keys on Creator (from created_by) + the show-level
Executive Producer/Writer/Producer/Composer, deliberately excluding the
per-episode Director/Editor/DP noise. Stored crew is sorted by display
priority and capped, so the lead roles come first.

Verified live: Breaking Bad -> [Gilligan (Creator), Johnson (EP), … , Writer];
The Godfather -> [Coppola (Director), Puzo (Screenplay), …]. test_plex_tmdb
green (9).
This commit is contained in:
2026-08-20 09:24:25 +02:00
parent 3bde114b14
commit 91be3d0535
2 changed files with 29 additions and 6 deletions
+23 -4
View File
@@ -27,8 +27,12 @@ log = logging.getLogger("siftlode.tmdb")
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
_MIN_INTERVAL = 0.03
# The crew jobs worth surfacing on an info page (TMDB lists dozens per title). Order-preserving pick.
_CREW_JOBS = (
# The crew jobs worth surfacing on an info page, in DISPLAY-priority order (the stored crew is sorted by
# this — TMDB's own crew order is arbitrary and buries the lead roles). MOVIE and TV differ on purpose:
# on a TV show `Director`/`Editor`/`Director of Photography` are PER-EPISODE credits (dozens of names
# that would flood the list and bury the showrunner), so TV keys on `Creator` (from the payload's
# top-level `created_by`, not the crew list) + the show-level producers/writers instead.
_MOVIE_CREW_JOBS = (
"Director",
"Writer",
"Screenplay",
@@ -39,6 +43,9 @@ _CREW_JOBS = (
"Director of Photography",
"Original Music Composer",
)
_TV_CREW_JOBS = ("Creator", "Executive Producer", "Writer", "Producer", "Original Music Composer")
_MOVIE_PRIORITY = {job: i for i, job in enumerate(_MOVIE_CREW_JOBS)}
_TV_PRIORITY = {job: i for i, job in enumerate(_TV_CREW_JOBS)}
# append_to_response bundles the sub-resources into ONE request per title (keywords + credits +
# recommendations + images + external ids). TV uses aggregate_credits (per-person aggregated jobs).
@@ -159,16 +166,28 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
kw_list = kw_container.get("results") if is_tv else kw_container.get("keywords")
keywords = [k.get("name") for k in (kw_list or []) if k.get("name")][:20]
priority = _TV_PRIORITY if is_tv else _MOVIE_PRIORITY
credits = payload.get("aggregate_credits" if is_tv else "credits") or {}
crew: list[dict] = []
# A TV show's creators live at the top level (created_by), not in the crew list — surface them.
if is_tv:
for c in payload.get("created_by") or []:
if c.get("name"):
crew.append(
{"name": c["name"], "job": "Creator", "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
)
for c in credits.get("crew") or []:
# Movie crew entries carry `job`; TV aggregate_credits carry a `jobs` array per person.
job = (c.get("jobs") or [{}])[0].get("job") if is_tv else c.get("job")
if job in _CREW_JOBS and c.get("name"):
if job in priority and c.get("name"):
crew.append(
{"name": c["name"], "job": job, "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")}
)
crew = _dedup_crew(crew)[:12]
# Dedup by (name, job) — a person can hold two distinct key roles (Coppola: Director + Writer) — then
# order by display priority so the lead roles come first, and cap.
crew = _dedup_crew(crew)
crew.sort(key=lambda c: priority.get(c["job"], 99))
crew = crew[:12]
# name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids).
people: list[dict] = []
+6 -2
View File
@@ -44,6 +44,7 @@ TV_PAYLOAD = {
"id": 1396,
"vote_average": 9.0,
"vote_count": 12000,
"created_by": [{"id": 66633, "name": "Vince Gilligan", "profile_path": "/vg.jpg"}],
"keywords": {"results": [{"name": "drugs"}, {"name": "new mexico"}]},
"aggregate_credits": {
"cast": [{"id": 17419, "name": "Bryan Cranston"}],
@@ -102,9 +103,12 @@ def test_parse_tv_uses_aggregate_credits_and_no_money():
d = tmdb.parse_tmdb(TV_PAYLOAD, "tv")
assert d["budget"] is None and d["revenue"] is None # TV has neither
assert d["keywords"] == ["drugs", "new mexico"] # from keywords.results
# The showrunner (created_by → Creator) leads; the aggregate-credits EP follows; the Sound person is
# filtered; TV jobs are read from jobs[0]. Crew is display-priority ordered (Creator before EP).
assert d["crew"] == [
{"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None}
] # the Sound person is filtered; job read from jobs[0]
{"name": "Vince Gilligan", "job": "Creator", "tmdb_id": 66633, "profile_path": "/vg.jpg"},
{"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None},
]
assert d["tmdb_rating"] == 9.0
assert ("Bryan Cranston", 17419) in {(p["name"], p["tmdb_id"]) for p in d["people"]}