diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 4897847a..99adf40b 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -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 {} diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index a78baf68..18c5c432 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -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: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 387f3c5b..a3b4ebce 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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"}]}]}} diff --git a/webui/index.html b/webui/index.html index 326fa3c7..1d22df30 100644 --- a/webui/index.html +++ b/webui/index.html @@ -943,6 +943,10 @@
' + esc(review.content) + '
' + + (long ? '' : ''); } // ── 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]'); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 3a6f18d5..eb938392 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -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; +}