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:
parent
3359e3c111
commit
b8de46d2ad
7 changed files with 155 additions and 4 deletions
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]');
|
||||
|
|
|
|||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue