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).
215 lines
9.5 KiB
Python
215 lines
9.5 KiB
Python
"""TMDB enrichment engine (E5 S2): the pure parser + persister, guid→tmdb-id resolution, and the
|
|
DB-lane batch/serve integration (enrich stamps the watermark + links people; serve_block resolves
|
|
recommendations to library rows; the whole thing no-ops without an API key).
|
|
|
|
The DB tests use the `db` fixture (real Postgres) — skipped when none is reachable."""
|
|
from datetime import datetime, timezone
|
|
|
|
from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow
|
|
from app.plex import tmdb
|
|
|
|
# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload.
|
|
MOVIE_PAYLOAD = {
|
|
"id": 238,
|
|
"vote_average": 8.7,
|
|
"vote_count": 20000,
|
|
"budget": 6000000,
|
|
"revenue": 245066411,
|
|
"keywords": {"keywords": [{"id": 1, "name": "mafia"}, {"id": 2, "name": "crime family"}]},
|
|
"credits": {
|
|
"cast": [
|
|
{"id": 3084, "name": "Marlon Brando", "character": "Vito", "profile_path": "/a.jpg", "order": 0},
|
|
{"id": 1158, "name": "Al Pacino", "character": "Michael", "profile_path": "/b.jpg", "order": 1},
|
|
],
|
|
"crew": [
|
|
{"id": 1776, "name": "Francis Ford Coppola", "job": "Director", "profile_path": "/c.jpg"},
|
|
{"id": 1776, "name": "Francis Ford Coppola", "job": "Writer", "profile_path": "/c.jpg"},
|
|
{"id": 9, "name": "Some Gaffer", "job": "Gaffer"}, # not a key job → dropped
|
|
],
|
|
},
|
|
"production_companies": [{"name": "Paramount"}, {"name": "Alfran"}],
|
|
"production_countries": [{"name": "United States of America"}],
|
|
"recommendations": {
|
|
"results": [
|
|
{"id": 240, "title": "The Godfather Part II", "poster_path": "/p.jpg", "media_type": "movie"},
|
|
{"id": 999, "title": "Not In Library", "poster_path": "/q.jpg", "media_type": "movie"},
|
|
]
|
|
},
|
|
"images": {"backdrops": [{"file_path": "/bd1.jpg"}, {"file_path": "/bd2.jpg"}]},
|
|
"external_ids": {"imdb_id": "tt0068646"},
|
|
}
|
|
|
|
# A TMDB /tv payload: keywords under `results`, aggregate_credits with per-person `jobs`, no budget.
|
|
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"}],
|
|
"crew": [
|
|
{"id": 66633, "name": "Vince Gilligan", "jobs": [{"job": "Executive Producer"}]},
|
|
{"id": 5, "name": "Someone", "jobs": [{"job": "Sound"}]}, # dropped
|
|
],
|
|
},
|
|
"production_companies": [{"name": "Sony Pictures Television"}],
|
|
"production_countries": [{"name": "United States of America"}],
|
|
"recommendations": {"results": []},
|
|
"images": {"backdrops": []},
|
|
}
|
|
|
|
|
|
class FakeTmdb:
|
|
"""Stand-in for TmdbClient in the parse/batch tests — no network."""
|
|
|
|
def __init__(self, payloads=None, find_map=None):
|
|
self.payloads = payloads or {} # (media_type, tmdb_id) -> payload
|
|
self.find_map = find_map or {} # external_id -> /find result
|
|
|
|
def fetch(self, tmdb_id, media_type):
|
|
return self.payloads.get((media_type, tmdb_id))
|
|
|
|
def find(self, external_id, source):
|
|
return self.find_map.get(external_id)
|
|
|
|
|
|
# ---- pure parse/persist --------------------------------------------------------------------------
|
|
|
|
def test_parse_movie_pulls_all_fields():
|
|
d = tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")
|
|
assert d["tmdb_id"] == 238
|
|
assert d["tmdb_rating"] == 8.7
|
|
assert d["tmdb_vote_count"] == 20000
|
|
assert d["budget"] == 6000000
|
|
assert d["revenue"] == 245066411
|
|
assert d["keywords"] == ["mafia", "crime family"]
|
|
assert d["crew"] == [
|
|
{"name": "Francis Ford Coppola", "job": "Director", "tmdb_id": 1776, "profile_path": "/c.jpg"},
|
|
{"name": "Francis Ford Coppola", "job": "Writer", "tmdb_id": 1776, "profile_path": "/c.jpg"},
|
|
] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job)
|
|
assert d["studios"] == ["Paramount", "Alfran"]
|
|
assert d["production_countries"] == ["United States of America"]
|
|
assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999]
|
|
assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"]
|
|
# cast + crew names → tmdb person ids for the plex_people foundation
|
|
links = {(p["name"], p["tmdb_id"]) for p in d["people"]}
|
|
assert ("Marlon Brando", 3084) in links
|
|
assert ("Al Pacino", 1158) in links
|
|
assert ("Francis Ford Coppola", 1776) in links
|
|
|
|
|
|
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": "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"]}
|
|
|
|
|
|
def test_parse_zero_votes_and_money_are_unknown():
|
|
d = tmdb.parse_tmdb({"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie")
|
|
assert d["tmdb_rating"] is None # 0.0 with no votes is meaningless
|
|
assert d["tmdb_vote_count"] is None
|
|
assert d["budget"] is None and d["revenue"] is None
|
|
|
|
|
|
def test_apply_writes_row_fields_without_touching_plex_owned():
|
|
it = PlexItem(rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}])
|
|
tmdb.apply_tmdb_detail(it, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
|
|
assert it.tmdb_id == 238
|
|
assert it.budget == 6000000
|
|
assert it.keywords == ["mafia", "crime family"]
|
|
assert it.imdb_rating == 8.7 and it.full_cast == [{"x": 1}] # Plex-owned, untouched
|
|
|
|
|
|
def test_guid_to_tmdb_id_direct_find_and_bad():
|
|
fake = FakeTmdb(find_map={"tt0068646": {"movie_results": [{"id": 238}], "tv_results": []}})
|
|
assert tmdb.guid_to_tmdb_id("tmdb:238", "movie", fake) == 238
|
|
assert tmdb.guid_to_tmdb_id("imdb:tt0068646", "movie", fake) == 238 # resolved via /find
|
|
assert tmdb.guid_to_tmdb_id("imdb:tt0000000", "movie", fake) is None # not in find_map
|
|
assert tmdb.guid_to_tmdb_id("tmdb:notanumber", "movie", fake) is None
|
|
assert tmdb.guid_to_tmdb_id(None, "movie", fake) is None
|
|
assert tmdb.guid_to_tmdb_id("local://abc", "movie", fake) is None
|
|
|
|
|
|
# ---- DB-lane: batch enrich + serve --------------------------------------------------------------
|
|
|
|
def _lib(db, key="1", kind="movie"):
|
|
lib = PlexLibrary(plex_key=key, title="L", kind=kind, enabled=True)
|
|
db.add(lib)
|
|
db.flush()
|
|
return lib
|
|
|
|
|
|
def test_enrich_batch_persists_stamps_and_links_people(db):
|
|
lib = _lib(db)
|
|
mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
|
|
unmatched = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777")
|
|
db.add_all([mv, unmatched])
|
|
db.commit()
|
|
|
|
fake = FakeTmdb(payloads={("movie", 238): MOVIE_PAYLOAD}) # 777 has no payload → definitive no-match
|
|
n = tmdb.enrich_batch(db, fake, 50)
|
|
assert n == 2 # both processed (one matched, one definitively unmatched)
|
|
|
|
db.refresh(mv)
|
|
assert mv.tmdb_id == 238
|
|
assert mv.keywords == ["mafia", "crime family"]
|
|
assert mv.revenue == 245066411
|
|
assert mv.tmdb_enriched_at is not None
|
|
|
|
db.refresh(unmatched)
|
|
assert unmatched.tmdb_enriched_at is not None # stamped so it isn't re-fetched every run
|
|
assert unmatched.tmdb_id is None # but nothing stored
|
|
|
|
brando = db.execute(
|
|
PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando")
|
|
).mappings().first()
|
|
assert brando is not None and brando["tmdb_person_id"] == 3084
|
|
|
|
|
|
def test_serve_block_none_until_enriched_and_resolves_recs(db):
|
|
lib = _lib(db)
|
|
# A recommendation target that IS in our library (guid tmdb:240) → deep-linkable.
|
|
sequel = PlexItem(rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240")
|
|
fresh = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238")
|
|
db.add_all([sequel, fresh])
|
|
db.commit()
|
|
|
|
assert tmdb.serve_block(db, fresh) is None # not enriched yet
|
|
|
|
tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie"))
|
|
fresh.tmdb_enriched_at = datetime.now(timezone.utc)
|
|
db.commit()
|
|
|
|
block = tmdb.serve_block(db, fresh)
|
|
assert block is not None
|
|
assert block["budget"] == 6000000
|
|
assert block["keywords"] == ["mafia", "crime family"]
|
|
recs = {r["tmdb_id"]: r for r in block["recommendations"]}
|
|
assert recs[240]["in_library"] is True and recs[240]["rating_key"] == "m240"
|
|
assert recs[999]["in_library"] is False and recs[999]["rating_key"] is None
|
|
|
|
|
|
def test_run_enrich_noops_without_key(db):
|
|
# No tmdb_api_key configured (default "") → clean no-op, never an error.
|
|
out = tmdb.run_enrich(db)
|
|
assert out["enriched"] == 0 and "skipped" in out
|
|
|
|
|
|
def test_enrich_batch_skips_shows_without_guid(db):
|
|
lib = _lib(db, key="2", kind="show")
|
|
sh = PlexShow(rating_key="s1", library_id=lib.id, title="No Guid Show") # guid None → not selected
|
|
db.add(sh)
|
|
db.commit()
|
|
assert tmdb.enrich_batch(db, FakeTmdb(), 50) == 0
|
|
db.refresh(sh)
|
|
assert sh.tmdb_enriched_at is None
|