diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py
index 60359e11..21070534 100644
--- a/tests/test_video_side_shell.py
+++ b/tests/test_video_side_shell.py
@@ -327,6 +327,39 @@ def test_video_worker_orbs_referenced_and_isolated():
assert "/api/enrichment/" not in src
+def test_show_detail_subpage_present():
+ block = _block(
+ _INDEX, r'")
+ # Hero + season tree containers the renderer fills.
+ for hook in ('data-vd-backdrop', 'data-vd-poster', 'data-vd-title', 'data-vd-badges',
+ 'data-vd-overview', 'data-vd-stats', 'data-vd-seasons'):
+ assert hook in block, hook
+ # Back button reuses the shared data-video-goto nav (no inline handler).
+ assert 'data-video-goto="video-library"' in block
+ assert "onclick" not in block
+
+
+def test_video_detail_module_referenced_and_isolated():
+ assert "video/video-detail.js" in _INDEX
+ src = (_ROOT / "webui" / "static" / "video" / "video-detail.js").read_text(encoding="utf-8")
+ assert "(function" in src and "})();" in src
+ assert "window." not in src # declares no globals
+ assert "/api/video/detail/" in src # video API only
+ assert "/api/enrichment/" not in src and "artist-detail" not in src
+ assert "soulsync:video-open-detail" in src # opened via the shared event
+
+
+def test_library_cards_open_detail():
+ src = _LIB_JS
+ assert "soulsync:video-open-detail" in src # show cards drill in
+ assert "data-video-card-open" in src
+
+
+def test_video_side_registers_detail_pages_and_open_event():
+ assert "video-show-detail" in _JS and "video-movie-detail" in _JS
+ assert "soulsync:video-open-detail" in _JS # navigates on the event
+
+
def test_music_worker_orbs_untouched_by_video():
# The video orbs are a separate file; the music orbs must not learn about
# the video side (one-way isolation β music never depends on video).
diff --git a/webui/index.html b/webui/index.html
index b5ca5f61..fb7d80d4 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -812,6 +812,34 @@
+
+
+
+
+
+
+
+
+
+
+
πΊ
+
+
+
β
+
+
+
+
+
+
Loadingβ¦
+
+
+
+
@@ -9067,6 +9095,8 @@
+
+
diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js
new file mode 100644
index 00000000..b4662832
--- /dev/null
+++ b/webui/static/video/video-detail.js
@@ -0,0 +1,183 @@
+/*
+ * SoulSync β Video detail page (isolated).
+ *
+ * Drill-in TV-show detail: hero (backdrop + poster + title + badges + stats) and
+ * the seasons β episodes tree (season = album, episode = track β inspired by the
+ * music artist page). Opened by a card via the soulsync:video-open-detail event;
+ * video-side.js handles the navigation, this loads + renders the data.
+ *
+ * Self-contained IIFE, no globals, event-delegated, no inline handlers. Talks
+ * only to /api/video/* β the music side is never touched.
+ */
+(function () {
+ 'use strict';
+
+ var DETAIL_URL = '/api/video/detail/';
+ var loaded = { show: null }; // remember the last show id we rendered
+
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(//g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ function root() { return document.querySelector('[data-video-detail="show"]'); }
+ function q(sel) { var r = root(); return r ? r.querySelector(sel) : null; }
+
+ function setText(sel, text) { var n = q(sel); if (n) n.textContent = text || ''; }
+
+ function pill(label, cls) {
+ return '' + esc(label) + '';
+ }
+
+ function runtimeLabel(mins) {
+ if (!mins) return '';
+ var h = Math.floor(mins / 60), m = mins % 60;
+ return h ? (h + 'h' + (m ? ' ' + m + 'm' : '')) : (m + 'm');
+ }
+
+ // ββ hero ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ function renderHero(d) {
+ setText('[data-vd-title]', d.title);
+ setText('[data-vd-overview]', d.overview);
+
+ var backdrop = q('[data-vd-backdrop]');
+ if (backdrop) {
+ backdrop.style.backgroundImage = d.has_backdrop
+ ? "url('/api/video/backdrop/show/" + d.id + "')"
+ : (d.has_poster ? "url('/api/video/poster/show/" + d.id + "')" : '');
+ backdrop.classList.toggle('vd-backdrop--empty', !d.has_backdrop && !d.has_poster);
+ }
+
+ var poster = q('[data-vd-poster]');
+ var fallback = q('[data-vd-poster-fallback]');
+ if (poster && fallback) {
+ if (d.has_poster) {
+ poster.src = '/api/video/poster/show/' + d.id;
+ poster.style.display = '';
+ fallback.style.display = 'none';
+ poster.onerror = function () { poster.style.display = 'none'; fallback.style.display = ''; };
+ } else {
+ poster.style.display = 'none';
+ fallback.style.display = '';
+ }
+ }
+
+ var badges = [];
+ if (d.year) badges.push(pill(d.year));
+ if (d.content_rating) badges.push(pill(d.content_rating, 'vd-pill--rating'));
+ if (d.status) badges.push(pill(d.status === 'continuing' ? 'Continuing'
+ : d.status === 'ended' ? 'Ended' : d.status));
+ if (d.network) badges.push(pill(d.network));
+ var rt = runtimeLabel(d.runtime_minutes);
+ if (rt) badges.push(pill(rt));
+ var b = q('[data-vd-badges]');
+ if (b) b.innerHTML = badges.join('');
+
+ // Stat tiles β seasons / episodes / owned coverage.
+ var ownedPct = d.episode_total ? Math.round(d.episode_owned / d.episode_total * 100) : 0;
+ var stats = [
+ ['Seasons', d.season_count],
+ ['Episodes', d.episode_total],
+ ['Owned', d.episode_owned + ' / ' + d.episode_total],
+ ['Collected', ownedPct + '%'],
+ ];
+ var s = q('[data-vd-stats]');
+ if (s) {
+ s.innerHTML = stats.map(function (st) {
+ return '
' + esc(st[1]) +
+ '' + esc(st[0]) + '
';
+ }).join('');
+ }
+ }
+
+ // ββ seasons β episodes tree βββββββββββββββββββββββββββββββββββββββββββββββ
+ function episodeRow(ep) {
+ var num = ep.episode_number != null ? ('E' + ep.episode_number) : '';
+ var owned = ep.owned ? 'vd-ep--owned' : 'vd-ep--missing';
+ var meta = [];
+ if (ep.air_date) meta.push(ep.air_date);
+ var rt = runtimeLabel(ep.runtime_minutes);
+ if (rt) meta.push(rt);
+ return '