video: featured TMDB review on movie/TV detail pages

extras() now returns a featured review (author, rating, snippet, date); the detail
page shows it in a card with a clamped body + Read more/less. In-app (no external
link).
This commit is contained in:
BoulderBadgeDad 2026-06-15 11:52:07 -07:00
parent df8f6a2acd
commit be54fccc63
6 changed files with 73 additions and 4 deletions

View file

@ -173,7 +173,7 @@ class TMDBClient:
creds = "aggregate_credits" if kind == "show" else "credits"
r = requests.get(self.BASE + path, params={
"api_key": self.api_key, "include_image_language": "en,null",
"append_to_response": "videos,watch/providers,similar,recommendations,images,keywords," + creds},
"append_to_response": "videos,watch/providers,similar,recommendations,images,keywords,reviews," + creds},
timeout=15)
r.raise_for_status()
out = self._parse_extras(kind, r.json() or {}, region)
@ -293,6 +293,16 @@ class TMDBClient:
out["cast_full"] = self._full_cast(d, kind)
if not out["cast_full"]:
out.pop("cast_full")
# A featured review (the first/top TMDB review).
revs = (d.get("reviews") or {}).get("results") or []
for rv in revs:
if rv.get("content"):
ad = rv.get("author_details") or {}
out["review"] = {"author": rv.get("author") or ad.get("username") or "Anonymous",
"content": rv["content"], "rating": ad.get("rating"),
"created": (rv.get("created_at") or "")[:10] or None}
break
return out
_VIDEO_ORDER = {"Trailer": 0, "Teaser": 1, "Clip": 2, "Featurette": 3, "Behind the Scenes": 4}
@ -450,7 +460,7 @@ class TMDBClient:
r = requests.get(self.BASE + path, params={
"api_key": self.api_key,
"append_to_response": "external_ids,credits,images,videos,watch/providers,similar,"
"recommendations,keywords" + agg,
"recommendations,keywords,reviews" + agg,
"include_image_language": "en,null"}, timeout=15)
r.raise_for_status()
dr = r.json() or {}

View file

@ -317,7 +317,7 @@ class VideoEnrichmentEngine:
"next_episode": ex.get("next_episode"), "last_episode": ex.get("last_episode"),
"gallery": ex.get("gallery"), "videos": ex.get("videos") or [],
"keywords": ex.get("keywords") or [], "facts": ex.get("facts"),
"cast_full": ex.get("cast_full") or []})
"cast_full": ex.get("cast_full") or [], "review": ex.get("review")})
if kind == "show":
seasons = d.pop("_seasons", []) or []
for s in seasons:

View file

@ -594,6 +594,16 @@ def test_tmdb_extras_gallery_videos_keywords_facts(monkeypatch):
assert ex["cast_full"][0]["character"] == "Neo"
def test_tmdb_extras_featured_review(monkeypatch):
detail = {"reviews": {"results": [
{"author": "Roger", "content": "A masterpiece.", "author_details": {"rating": 9},
"created_at": "2021-10-22T00:00:00.000Z"}]}}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(detail)))
ex = TMDBClient("KEY").extras("movie", 1)
assert ex["review"] == {"author": "Roger", "content": "A masterpiece.",
"rating": 9, "created": "2021-10-22"}
def test_tmdb_extras_tv_full_cast_episode_counts(monkeypatch):
detail = {"aggregate_credits": {"cast": [
{"id": 1, "name": "Actor", "total_episode_count": 42, "roles": [{"character": "Lead"}]}]}}

View file

@ -943,6 +943,10 @@
<h2 class="vd-section-h">Photos</h2>
<div class="vd-gallery" data-vd-gallery></div>
</div>
<div class="vd-review-section" data-vd-review-section hidden>
<h2 class="vd-section-h">Review</h2>
<div class="vd-review" data-vd-review></div>
</div>
<div class="vd-providers-section" data-vd-providers-section hidden>
<h2 class="vd-section-h">Where to Watch</h2>
<div class="vd-providers" data-vd-providers></div>
@ -1014,6 +1018,10 @@
<h2 class="vd-section-h">Photos</h2>
<div class="vd-gallery" data-vd-gallery></div>
</div>
<div class="vd-review-section" data-vd-review-section hidden>
<h2 class="vd-section-h">Review</h2>
<div class="vd-review" data-vd-review></div>
</div>
<div class="vd-providers-section" data-vd-providers-section hidden>
<h2 class="vd-section-h">Where to Watch</h2>
<div class="vd-providers" data-vd-providers></div>

View file

@ -371,7 +371,7 @@
['[data-vd-providers-section]', '[data-vd-similar-section]', '[data-vd-collection-section]',
'[data-vd-next-ep]', '[data-vd-crew-line]', '[data-vd-season-overview]',
'[data-vd-facts-section]', '[data-vd-videos-section]', '[data-vd-gallery-section]',
'[data-vd-cast-all]'].forEach(function (s) {
'[data-vd-review-section]', '[data-vd-cast-all]'].forEach(function (s) {
var n = q(s); if (n) n.hidden = true;
});
galleryImages = [];
@ -470,6 +470,21 @@
renderFacts(ex.facts, ex.keywords);
renderVideos(ex.videos);
renderGallery(ex.gallery);
renderReview(ex.review);
}
function renderReview(review) {
var sec = q('[data-vd-review-section]'), host = q('[data-vd-review]');
if (!sec || !host) return;
if (!review || !review.content) { sec.hidden = true; return; }
sec.hidden = false;
var rating = review.rating ? '<span class="vd-review-rating">★ ' + review.rating + '/10</span>' : '';
var date = review.created ? '<span class="vd-review-date">' + esc(review.created) + '</span>' : '';
var long = review.content.length > 420;
host.innerHTML = '<div class="vd-review-head">' +
'<span class="vd-review-author">' + esc(review.author) + '</span>' + rating + date + '</div>' +
'<p class="vd-review-body" data-vd-review-body>' + esc(review.content) + '</p>' +
(long ? '<button class="vd-review-more" type="button" data-vd-review-more>Read more</button>' : '');
}
// ── facts / keywords ──────────────────────────────────────────────────────
@ -1001,6 +1016,12 @@
if (vid && r.contains(vid)) { openTrailer(vid.getAttribute('data-vd-video')); return; }
var castAll = e.target.closest('[data-vd-cast-all]');
if (castAll && r.contains(castAll)) { openCastModal(); return; }
var revMore = e.target.closest('[data-vd-review-more]');
if (revMore && r.contains(revMore)) {
var body = q('[data-vd-review-body]');
if (body) { var open = body.classList.toggle('vd-review-body--open'); revMore.textContent = open ? 'Read less' : 'Read more'; }
return;
}
var seasonBtn = e.target.closest('[data-vd-season]');
if (seasonBtn && r.contains(seasonBtn)) { selectSeason(parseInt(seasonBtn.getAttribute('data-vd-season'), 10)); return; }
var viewBtn = e.target.closest('[data-vd-view]');

View file

@ -1375,3 +1375,23 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a
}
.vp-photo-thumb img { width: 100%; height: 100%; object-fit: cover; }
.vp-photo-thumb:hover { transform: translateY(-4px); box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55); }
/* ── featured review ─────────────────────────────────────────────────────── */
.vd-review-section { margin-top: 44px; }
.vd-review {
max-width: 880px; padding: 20px 22px; border-radius: 14px;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
}
.vd-review-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; }
.vd-review-author { font-size: 15px; font-weight: 800; color: #fff; }
.vd-review-rating { font-size: 13px; font-weight: 800; color: #fbbf24; }
.vd-review-date { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); }
.vd-review-body {
margin: 0; font-size: 14.5px; line-height: 1.7; color: rgba(255, 255, 255, 0.8); white-space: pre-line;
display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden;
}
.vd-review-body--open { -webkit-line-clamp: 999; }
.vd-review-more {
background: none; border: 0; color: rgb(var(--vd-accent-rgb)); font-weight: 700; font-size: 13.5px;
cursor: pointer; padding: 8px 0 0;
}