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:
parent
df8f6a2acd
commit
be54fccc63
6 changed files with 73 additions and 4 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}]}]}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue