diff --git a/api/video/detail.py b/api/video/detail.py index a16419f3..4e79e05c 100644 --- a/api/video/detail.py +++ b/api/video/detail.py @@ -96,6 +96,19 @@ def register_routes(bp): return jsonify({"error": "not found"}), 404 return jsonify(d) + @bp.route("/episode///", methods=["GET"]) + def video_episode_extra(tmdb_id, season, episode): + """Episode expand: guest stars + bigger still (by the SHOW's tmdb id).""" + try: + from core.video.enrichment.engine import get_video_enrichment_engine + d = get_video_enrichment_engine().episode_extra(tmdb_id, season, episode) + except Exception: + logger.exception("episode extra failed for %s S%sE%s", tmdb_id, season, episode) + d = None + if not d: + return jsonify({"error": "not found"}), 404 + return jsonify(d) + @bp.route("/person/", methods=["GET"]) def video_person_detail(tmdb_id): """In-app person page: bio + filmography (each credit annotated owned/not).""" diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 99adf40b..1278cca5 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -390,6 +390,24 @@ class TMDBClient: "poster_url": (self.IMG + data["poster_path"]) if data.get("poster_path") else None, "episodes": out} + def episode_detail(self, tv_id, season_number, episode_number): + """One episode's deeper detail (guest stars + a bigger still) for the + episode expand. Returns {guest_stars, still_url, rating, overview, ...}.""" + if not self.api_key or tv_id is None: + return None + import requests + r = requests.get(self.BASE + "/tv/%s/season/%s/episode/%s" % (tv_id, season_number, episode_number), + params={"api_key": self.api_key, "append_to_response": "credits"}, timeout=15) + r.raise_for_status() + d = r.json() or {} + guests = [{"name": g["name"], "character": g.get("character"), "tmdb_id": g.get("id"), + "photo": (self.PROFILE + g["profile_path"]) if g.get("profile_path") else None} + for g in (d.get("guest_stars") or [])[:20] if g.get("name")] + return {"guest_stars": guests, + "still_url": (self.IMG + d["still_path"]) if d.get("still_path") else None, + "rating": d.get("vote_average") or None, "overview": d.get("overview") or None, + "runtime_minutes": d.get("runtime"), "air_date": d.get("air_date") or None} + def search(self, query): """Multi-search (movies / TV / people) for the in-app search page. Returns a flat list of {kind, tmdb_id, title, year, poster, ...} — no external IDs, diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 849f7817..dd60a5ec 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -380,6 +380,25 @@ class VideoEnrichmentEngine: self._cache_put(key, out) return out + def episode_extra(self, tmdb_id, season_number, episode_number) -> dict | None: + """Deeper episode detail (guest stars + still) for the episode expand, + annotated owned/not + cached.""" + w = self.workers.get("tmdb") + if not w or not w.enabled or not tmdb_id: + return None + key = ("episode", tmdb_id, season_number, episode_number) + cached = self._cache_get(key) + if cached is None: + try: + cached = w.client.episode_detail(tmdb_id, season_number, episode_number) + except Exception: + logger.exception("episode_extra failed for %s S%sE%s", tmdb_id, season_number, episode_number) + return None + if cached is None: + return None + self._cache_put(key, cached) + return cached + def person_detail(self, tmdb_id) -> dict | None: """A person (actor/director) page — bio + filmography, each credit annotated with the library id if owned. Keeps cast clicks in-app.""" diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 043a5135..2ce69ed6 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -50,6 +50,7 @@ def test_blueprint_exposes_dashboard_route(): assert "/api/video/tmdb//" in rules assert "/api/video/tmdb/show//season/" in rules assert "/api/video/person/" in rules + assert "/api/video/episode///" in rules assert any(r.startswith("/api/video/backdrop/") for r in rules) assert "/api/video/img" in rules diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index f513908a..a48c6477 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -717,6 +717,20 @@ def test_item_extras_no_server_link_when_unowned(db, monkeypatch): assert "server" not in VideoEnrichmentEngine(db, {}).item_extras("movie", rid) +def test_tmdb_episode_detail_parses_guests(monkeypatch): + body = {"still_path": "/s.jpg", "vote_average": 8.4, "overview": "O", "runtime": 52, + "air_date": "2024-01-01", + "credits": {}, "guest_stars": [ + {"id": 5, "name": "Guest", "character": "Villain", "profile_path": "/g.jpg"}, + {"id": 6, "name": "NoPic"}]} + monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(get=lambda u, **k: _Resp(body))) + d = TMDBClient("KEY").episode_detail(1399, 1, 1) + assert d["still_url"] == "https://image.tmdb.org/t/p/original/s.jpg" and d["rating"] == 8.4 + assert d["guest_stars"][0] == {"name": "Guest", "character": "Villain", "tmdb_id": 5, + "photo": "https://image.tmdb.org/t/p/w185/g.jpg"} + assert d["guest_stars"][1]["photo"] is None + + def test_tmdb_season_episodes_parses(monkeypatch): class _Resp: def __init__(self, b): self._b = b diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 4166376f..a9d64d87 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -847,14 +847,65 @@ var still = stillSrc ? '' : ''; - return '
' + + if (ep.rating) meta.push('★ ' + (Math.round(ep.rating * 10) / 10)); + var key = selectedSeason + '_' + ep.episode_number; + // Row + a sibling expand panel (guest stars etc. load lazily on open). + return '
' + '
' + (ep.episode_number != null ? ep.episode_number : '') + '
' + '
' + still + '
' + '
' + esc(ep.title || 'Episode ' + ep.episode_number) + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
' + - '
' + (ep.owned ? 'Owned' : 'Missing') + '
'; + '
' + (ep.owned ? 'Owned' : 'Missing') + '
' + + '
' + + ''; + } + + function toggleEpisode(row) { + var key = row.getAttribute('data-vd-ep-key'); + var panel = q('[data-vd-ep-panel="' + key + '"]'); + if (!panel) return; + panel.hidden = !panel.hidden; + row.classList.toggle('vd-ep--open', !panel.hidden); + if (!panel.hidden && !panel.getAttribute('data-loaded')) { + panel.setAttribute('data-loaded', '1'); + loadEpisodeExtra(key, panel); + } + } + function loadEpisodeExtra(key, panel) { + var tmdb = data && data.tmdb_id; + var parts = key.split('_'); + if (!tmdb) { panel.innerHTML = '
No extra info.
'; return; } + panel.innerHTML = '
Loading…
'; + fetch('/api/video/episode/' + tmdb + '/' + parts[0] + '/' + parts[1], + { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (ex) { renderEpisodeExtra(panel, ex && !ex.error ? ex : {}); }) + .catch(function () { panel.innerHTML = ''; }); + } + function renderEpisodeExtra(panel, ex) { + var html = ''; + if (ex.still_url) { + html += ''; + } + html += '
'; + if (ex.overview) html += '

' + esc(ex.overview) + '

'; + if (ex.guest_stars && ex.guest_stars.length) { + html += '
Guest stars
' + + ex.guest_stars.map(function (g) { + var img = g.photo + ? '' + : '' + esc((g.name || '?').charAt(0)) + ''; + var inner = img + '' + esc(g.name) + '' + + (g.character ? '' + esc(g.character) + '' : ''); + return g.tmdb_id + ? '' + inner + '' + : '
' + inner + '
'; + }).join('') + '
'; + } + html += '
'; + panel.innerHTML = html || '
No extra info.
'; } function renderSeasonOverview() { @@ -1140,6 +1191,8 @@ if (body) { var open = body.classList.toggle('vd-review-body--open'); revMore.textContent = open ? 'Read less' : 'Read more'; } return; } + var epRow = e.target.closest('[data-vd-ep-key]'); + if (epRow && r.contains(epRow)) { toggleEpisode(epRow); 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 5dc3434b..98cce568 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -625,10 +625,13 @@ body[data-side="video"] .dashboard-header-sweep { .vd-ep-anim .vd-ep:nth-child(n+5) { animation-delay: 0.12s; } @keyframes vdEpIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } } .vd-ep { - display: grid; grid-template-columns: 48px 150px 1fr auto; align-items: center; gap: 20px; + display: grid; grid-template-columns: 48px 150px 1fr auto auto; align-items: center; gap: 20px; padding: 18px 14px; border-bottom: 1px solid rgba(255,255,255,0.06); - transition: background 0.2s ease; cursor: default; + transition: background 0.2s ease; cursor: pointer; } +.vd-ep-chev { color: rgba(255,255,255,0.35); font-size: 16px; transition: transform 0.25s ease, color 0.2s ease; } +.vd-ep:hover .vd-ep-chev { color: rgba(255,255,255,0.7); } +.vd-ep--open .vd-ep-chev { transform: rotate(180deg); color: rgb(var(--vd-accent-rgb)); } .vd-ep:hover { background: rgba(255,255,255,0.04); } .vd-ep-index { font-size: 22px; font-weight: 800; color: rgba(255,255,255,0.4); text-align: center; } .vd-ep:hover .vd-ep-index { color: rgb(var(--vd-accent-rgb)); } @@ -1425,3 +1428,33 @@ a.vd-prov:hover img, a.vd-prov:hover .vd-prov-ph { border-color: rgba(var(--vd-a font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.85); background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); } + +/* ── episode expand (guest stars + bigger still) ─────────────────────────── */ +.vd-ep-extra { + display: flex; gap: 22px; padding: 6px 14px 24px 82px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + animation: vsr-fade 0.3s ease; +} +.vd-ep-extra-still { width: 240px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 10px; flex: 0 0 auto; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); } +.vd-ep-extra-body { flex: 1; min-width: 0; } +.vd-ep-extra-ov { margin: 0 0 16px; font-size: 14px; line-height: 1.6; color: rgba(255, 255, 255, 0.8); } +.vd-ep-extra-empty { color: rgba(255, 255, 255, 0.45); font-size: 13.5px; padding: 4px 0; } +.vd-ep-extra-gh { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: rgba(255, 255, 255, 0.45); margin-bottom: 12px; } +.vd-ep-guests { display: flex; flex-wrap: wrap; gap: 16px; } +.vd-guest { width: 92px; text-align: center; text-decoration: none; color: inherit; } +a.vd-guest { cursor: pointer; transition: transform 0.18s ease; } +a.vd-guest:hover { transform: translateY(-3px); } +a.vd-guest:hover .vd-guest-name { color: rgb(var(--vd-accent-rgb)); } +.vd-guest-photo { + width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 7px; + border: 2px solid rgba(255, 255, 255, 0.08); +} +.vd-guest-photo--ph { display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; + color: rgba(255, 255, 255, 0.5); background: linear-gradient(135deg, rgba(var(--vd-accent-rgb), 0.3), rgba(var(--vd-accent-rgb), 0.08)); } +.vd-guest-name { display: block; font-size: 12px; font-weight: 700; color: #fff; line-height: 1.25; } +.vd-guest-char { display: block; font-size: 11px; color: rgba(255, 255, 255, 0.5); line-height: 1.25; } +@media (max-width: 720px) { + .vd-ep-extra { flex-direction: column; padding-left: 14px; } + .vd-ep-extra-still { width: 100%; } +}