video: detail extras now include collections, recommendations + next-episode
Backend groundwork for the best-in-class detail pages: - Movies: belongs_to_collection → the franchise's films (2nd /collection call, release-ordered). - Recommendations (better-curated than 'similar') alongside similar. - TV: next_episode_to_air / last_episode_to_air stubs (season/ep/name/air date). Shared by item_extras (owned) + full_detail/tmdb_detail (preview). Tests cover recommendations+collection ordering and next-episode parsing.
This commit is contained in:
parent
c5ff2567a8
commit
f725235f44
3 changed files with 103 additions and 12 deletions
|
|
@ -168,13 +168,27 @@ class TMDBClient:
|
||||||
import requests
|
import requests
|
||||||
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
|
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
|
||||||
r = requests.get(self.BASE + path, params={
|
r = requests.get(self.BASE + path, params={
|
||||||
"api_key": self.api_key, "append_to_response": "videos,watch/providers,similar"}, timeout=15)
|
"api_key": self.api_key,
|
||||||
|
"append_to_response": "videos,watch/providers,similar,recommendations"}, timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return self._parse_extras(kind, r.json() or {}, region)
|
out = self._parse_extras(kind, r.json() or {}, region)
|
||||||
|
self._fill_collection(out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _fill_collection(self, out):
|
||||||
|
"""Second call: pull the films of a movie collection (franchise)."""
|
||||||
|
coll = out.get("collection")
|
||||||
|
if coll and coll.get("id"):
|
||||||
|
try:
|
||||||
|
coll["items"] = self.collection(coll["id"])
|
||||||
|
except Exception:
|
||||||
|
logger.exception("TMDB collection fetch failed for %s", coll.get("id"))
|
||||||
|
|
||||||
def _parse_extras(self, kind, d, region="US"):
|
def _parse_extras(self, kind, d, region="US"):
|
||||||
"""Pull trailer / where-to-watch / similar out of a TMDB detail body. Shared
|
"""Pull trailer / where-to-watch / similar / recommendations / collection /
|
||||||
by extras() and full_detail() so the search detail can render them too."""
|
next-episode out of a TMDB detail body. Shared by extras() and full_detail()
|
||||||
|
so the search (preview) detail renders them too. (Collection PARTS need a
|
||||||
|
second call — see _fill_collection.)"""
|
||||||
out = {}
|
out = {}
|
||||||
|
|
||||||
# Trailer — prefer a YouTube "Trailer", fall back to a teaser.
|
# Trailer — prefer a YouTube "Trailer", fall back to a teaser.
|
||||||
|
|
@ -202,15 +216,62 @@ class TMDBClient:
|
||||||
out["providers_link"] = wp.get("link")
|
out["providers_link"] = wp.get("link")
|
||||||
out["region"] = region
|
out["region"] = region
|
||||||
|
|
||||||
# More like this.
|
# More like this — recommendations (better-curated) with similar as backup.
|
||||||
sim = []
|
out["recommendations"] = self._title_list((d.get("recommendations") or {}).get("results"), kind)
|
||||||
for s in ((d.get("similar") or {}).get("results") or [])[:14]:
|
out["similar"] = self._title_list((d.get("similar") or {}).get("results"), kind)
|
||||||
|
if not out["recommendations"]:
|
||||||
|
out.pop("recommendations")
|
||||||
|
if not out["similar"]:
|
||||||
|
out.pop("similar")
|
||||||
|
|
||||||
|
if kind == "movie":
|
||||||
|
bc = d.get("belongs_to_collection")
|
||||||
|
if bc and bc.get("id"):
|
||||||
|
out["collection"] = {
|
||||||
|
"id": bc["id"], "name": bc.get("name"),
|
||||||
|
"poster": (self.POSTER_W + bc["poster_path"]) if bc.get("poster_path") else None,
|
||||||
|
"items": []} # parts filled by _fill_collection (2nd call)
|
||||||
|
else:
|
||||||
|
if d.get("next_episode_to_air"):
|
||||||
|
out["next_episode"] = self._episode_stub(d["next_episode_to_air"])
|
||||||
|
if d.get("last_episode_to_air"):
|
||||||
|
out["last_episode"] = self._episode_stub(d["last_episode_to_air"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _title_list(self, results, kind):
|
||||||
|
out = []
|
||||||
|
for s in (results or [])[:14]:
|
||||||
title = s.get("title") or s.get("name")
|
title = s.get("title") or s.get("name")
|
||||||
if title and s.get("id"):
|
if title and s.get("id"):
|
||||||
sim.append({"title": title, "tmdb_id": s["id"], "kind": kind,
|
mt = s.get("media_type")
|
||||||
|
k = "movie" if mt == "movie" else "show" if mt == "tv" else kind
|
||||||
|
out.append({"title": title, "tmdb_id": s["id"], "kind": k,
|
||||||
"poster": (self.POSTER_W + s["poster_path"]) if s.get("poster_path") else None})
|
"poster": (self.POSTER_W + s["poster_path"]) if s.get("poster_path") else None})
|
||||||
if sim:
|
return out
|
||||||
out["similar"] = sim
|
|
||||||
|
@staticmethod
|
||||||
|
def _episode_stub(e):
|
||||||
|
return {"season_number": e.get("season_number"), "episode_number": e.get("episode_number"),
|
||||||
|
"name": e.get("name"), "air_date": e.get("air_date") or None,
|
||||||
|
"overview": e.get("overview") or None}
|
||||||
|
|
||||||
|
def collection(self, collection_id):
|
||||||
|
"""The films of a movie collection (franchise), ordered by release date."""
|
||||||
|
if not self.api_key or collection_id is None:
|
||||||
|
return []
|
||||||
|
import requests
|
||||||
|
r = requests.get(self.BASE + "/collection/" + str(collection_id),
|
||||||
|
params={"api_key": self.api_key}, timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
out = []
|
||||||
|
for p in ((r.json() or {}).get("parts") or []):
|
||||||
|
if not p.get("id"):
|
||||||
|
continue
|
||||||
|
out.append({"kind": "movie", "tmdb_id": p["id"], "title": p.get("title"),
|
||||||
|
"year": (p.get("release_date") or "")[:4] or None,
|
||||||
|
"date": p.get("release_date") or "",
|
||||||
|
"poster": (self.POSTER_W + p["poster_path"]) if p.get("poster_path") else None})
|
||||||
|
out.sort(key=lambda x: x["date"] or "zzzz")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
def season_episodes(self, tv_id, season_number):
|
def season_episodes(self, tv_id, season_number):
|
||||||
|
|
@ -307,7 +368,7 @@ class TMDBClient:
|
||||||
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
|
path = ("/movie/" if kind == "movie" else "/tv/") + str(tmdb_id)
|
||||||
r = requests.get(self.BASE + path, params={
|
r = requests.get(self.BASE + path, params={
|
||||||
"api_key": self.api_key,
|
"api_key": self.api_key,
|
||||||
"append_to_response": "external_ids,credits,images,videos,watch/providers,similar",
|
"append_to_response": "external_ids,credits,images,videos,watch/providers,similar,recommendations",
|
||||||
"include_image_language": "en,null"}, timeout=15)
|
"include_image_language": "en,null"}, timeout=15)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
dr = r.json() or {}
|
dr = r.json() or {}
|
||||||
|
|
@ -334,6 +395,7 @@ class TMDBClient:
|
||||||
for p in cmeta.get("crew") or []],
|
for p in cmeta.get("crew") or []],
|
||||||
"_extras": self._parse_extras(kind, dr),
|
"_extras": self._parse_extras(kind, dr),
|
||||||
}
|
}
|
||||||
|
self._fill_collection(out["_extras"])
|
||||||
if kind == "movie":
|
if kind == "movie":
|
||||||
out["year"] = (dr.get("release_date") or "")[:4] or None
|
out["year"] = (dr.get("release_date") or "")[:4] or None
|
||||||
out["release_date"] = dr.get("release_date") or None
|
out["release_date"] = dr.get("release_date") or None
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,9 @@ class VideoEnrichmentEngine:
|
||||||
"has_poster": bool(d.get("poster_url")), "has_backdrop": bool(d.get("backdrop_url"))})
|
"has_poster": bool(d.get("poster_url")), "has_backdrop": bool(d.get("backdrop_url"))})
|
||||||
ex = d.pop("_extras", {}) or {}
|
ex = d.pop("_extras", {}) or {}
|
||||||
d.update({"trailer": ex.get("trailer"), "providers": ex.get("providers") or [],
|
d.update({"trailer": ex.get("trailer"), "providers": ex.get("providers") or [],
|
||||||
"providers_link": ex.get("providers_link"), "similar": ex.get("similar") or []})
|
"providers_link": ex.get("providers_link"), "similar": ex.get("similar") or [],
|
||||||
|
"recommendations": ex.get("recommendations") or [], "collection": ex.get("collection"),
|
||||||
|
"next_episode": ex.get("next_episode"), "last_episode": ex.get("last_episode")})
|
||||||
if kind == "show":
|
if kind == "show":
|
||||||
seasons = d.pop("_seasons", []) or []
|
seasons = d.pop("_seasons", []) or []
|
||||||
for s in seasons:
|
for s in seasons:
|
||||||
|
|
|
||||||
|
|
@ -541,6 +541,33 @@ def _no_server_config(monkeypatch):
|
||||||
monkeypatch.setattr(cs, "config_manager", CM())
|
monkeypatch.setattr(cs, "config_manager", CM())
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_extras_recommendations_and_collection(monkeypatch):
|
||||||
|
detail = {
|
||||||
|
"recommendations": {"results": [
|
||||||
|
{"id": 7, "title": "Rec", "media_type": "movie", "poster_path": "/r.jpg"}]},
|
||||||
|
"belongs_to_collection": {"id": 99, "name": "Saga Collection", "poster_path": "/c.jpg"}}
|
||||||
|
collection = {"parts": [
|
||||||
|
{"id": 2, "title": "Second", "release_date": "2003-01-01"},
|
||||||
|
{"id": 1, "title": "First", "release_date": "2001-01-01", "poster_path": "/1.jpg"}]}
|
||||||
|
|
||||||
|
def fake_get(url, **k):
|
||||||
|
return _Resp(collection if "/collection/" in url else detail)
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=fake_get))
|
||||||
|
ex = TMDBClient("KEY").extras("movie", 1)
|
||||||
|
assert ex["recommendations"][0]["tmdb_id"] == 7 and ex["recommendations"][0]["kind"] == "movie"
|
||||||
|
assert ex["collection"]["name"] == "Saga Collection"
|
||||||
|
assert [c["title"] for c in ex["collection"]["items"]] == ["First", "Second"] # release order
|
||||||
|
|
||||||
|
|
||||||
|
def test_tmdb_extras_tv_next_episode(monkeypatch):
|
||||||
|
detail = {"next_episode_to_air": {"season_number": 3, "episode_number": 4,
|
||||||
|
"name": "Finale", "air_date": "2026-12-25"}}
|
||||||
|
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
|
||||||
|
ex = TMDBClient("KEY").extras("show", 5)
|
||||||
|
assert ex["next_episode"] == {"season_number": 3, "episode_number": 4, "name": "Finale",
|
||||||
|
"air_date": "2026-12-25", "overview": None}
|
||||||
|
|
||||||
|
|
||||||
def test_item_extras_needs_tmdb_and_id(db, monkeypatch):
|
def test_item_extras_needs_tmdb_and_id(db, monkeypatch):
|
||||||
_no_server_config(monkeypatch)
|
_no_server_config(monkeypatch)
|
||||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "seasons": []}) # no tmdb_id
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue