video: episode detail expand (guest stars + bigger still)

Click any episode (owned or missing) to expand it: a larger still, full overview,
and the episode's guest stars (clickable to the person page). Lazy-loaded per
episode from TMDB by the show's tmdb_id and cached. New client.episode_detail +
engine.episode_extra + /api/video/episode/<show_tmdb>/<season>/<episode>.
This commit is contained in:
BoulderBadgeDad 2026-06-15 13:20:15 -07:00
parent 3359e3c111
commit b8de46d2ad
7 changed files with 155 additions and 4 deletions

View file

@ -96,6 +96,19 @@ def register_routes(bp):
return jsonify({"error": "not found"}), 404
return jsonify(d)
@bp.route("/episode/<int:tmdb_id>/<int:season>/<int: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/<int:tmdb_id>", methods=["GET"])
def video_person_detail(tmdb_id):
"""In-app person page: bio + filmography (each credit annotated owned/not)."""

View file

@ -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,

View file

@ -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."""

View file

@ -50,6 +50,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/tmdb/<kind>/<int:tmdb_id>" in rules
assert "/api/video/tmdb/show/<int:tv_id>/season/<int:season_number>" in rules
assert "/api/video/person/<int:tmdb_id>" in rules
assert "/api/video/episode/<int:tmdb_id>/<int:season>/<int:episode>" in rules
assert any(r.startswith("/api/video/backdrop/") for r in rules)
assert "/api/video/img" in rules

View file

@ -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

View file

@ -847,14 +847,65 @@
var still = stillSrc
? '<img class="vd-ep-still" src="' + stillSrc + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '';
return '<div class="vd-ep ' + owned + '">' +
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 '<div class="vd-ep ' + owned + '" data-vd-ep-key="' + key + '">' +
'<div class="vd-ep-index">' + (ep.episode_number != null ? ep.episode_number : '') + '</div>' +
'<div class="vd-ep-thumb">' + still + '<span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
esc(ep.title || 'Episode ' + ep.episode_number) + '</span>' +
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +
(ep.overview ? '<p class="vd-ep-desc">' + esc(ep.overview) + '</p>' : '') + '</div>' +
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div></div>';
'<div class="vd-ep-badge">' + (ep.owned ? 'Owned' : 'Missing') + '</div>' +
'<span class="vd-ep-chev" aria-hidden="true">⌄</span></div>' +
'<div class="vd-ep-extra" data-vd-ep-panel="' + key + '" hidden></div>';
}
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 = '<div class="vd-ep-extra-empty">No extra info.</div>'; return; }
panel.innerHTML = '<div class="vd-ep-extra-empty">Loading…</div>';
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 += '<img class="vd-ep-extra-still" src="' + esc(ex.still_url) + '" alt="" loading="lazy">';
}
html += '<div class="vd-ep-extra-body">';
if (ex.overview) html += '<p class="vd-ep-extra-ov">' + esc(ex.overview) + '</p>';
if (ex.guest_stars && ex.guest_stars.length) {
html += '<div class="vd-ep-extra-gh">Guest stars</div><div class="vd-ep-guests">' +
ex.guest_stars.map(function (g) {
var img = g.photo
? '<img class="vd-guest-photo" src="' + esc(g.photo) + '" alt="" loading="lazy" onerror="this.style.visibility=\'hidden\'">'
: '<span class="vd-guest-photo vd-guest-photo--ph">' + esc((g.name || '?').charAt(0)) + '</span>';
var inner = img + '<span class="vd-guest-name">' + esc(g.name) + '</span>' +
(g.character ? '<span class="vd-guest-char">' + esc(g.character) + '</span>' : '');
return g.tmdb_id
? '<a class="vd-guest" href="/video-detail/tmdb/person/' + g.tmdb_id + '" data-vd-person="' + g.tmdb_id + '">' + inner + '</a>'
: '<div class="vd-guest">' + inner + '</div>';
}).join('') + '</div>';
}
html += '</div>';
panel.innerHTML = html || '<div class="vd-ep-extra-empty">No extra info.</div>';
}
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]');

View file

@ -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%; }
}