From 91be3d05356474287c241c065a4ef7b31ead3117 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 20 Aug 2026 09:24:25 +0200 Subject: [PATCH] E5 S2: prioritise TMDB crew, and capture the TV showrunner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/plex/tmdb.py | 27 +++++++++++++++++++++++---- backend/tests/test_plex_tmdb.py | 8 ++++++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py index 5280900b..ec12c9d3 100644 --- a/backend/app/plex/tmdb.py +++ b/backend/app/plex/tmdb.py @@ -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] = [] diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py index 07dac0dd..853f7b89 100644 --- a/backend/tests/test_plex_tmdb.py +++ b/backend/tests/test_plex_tmdb.py @@ -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"]}