diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js
index 9dbd71bc..3d27985c 100644
--- a/webui/static/video/video-download-view.js
+++ b/webui/static/video/video-download-view.js
@@ -72,6 +72,7 @@
function render(container, opts) {
if (!container) return;
opts = opts || {};
+ if (opts.kind === 'show') { renderShow(container, opts); return; }
container.innerHTML = contentHTML();
if (!container._vdlWired) { container._vdlWired = true; container.addEventListener('click', onClick); }
@@ -197,5 +198,259 @@
toast('Automatic search isn’t wired up yet — coming soon', 'info');
}
+ // ── TV show download view ─────────────────────────────────────────────────
+ // A season→episode picker (everything you're missing pre-ticked), each season
+ // and episode searchable inline across your sources, plus a bulk "Search N
+ // selected". Searches are stubs (faux-scan) — same motion the engine will drive.
+ function isoToday() {
+ var n = new Date();
+ return n.getFullYear() + '-' + ('0' + (n.getMonth() + 1)).slice(-2) + '-' + ('0' + n.getDate()).slice(-2);
+ }
+ function epState(e, today) {
+ if (e && e.owned) return 'owned';
+ if (e && e.air_date && e.air_date > today) return 'upcoming';
+ return 'missing';
+ }
+
+ function renderShow(container, opts) {
+ var d = opts.detail || {};
+ var st = container._dl = {
+ sel: new Set(), today: isoToday(),
+ tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library',
+ sources: ['soulseek'], epMeta: {}
+ };
+ container.innerHTML =
+ '
Quality target
' +
+ '
Loading…
' +
+ '' +
+ '' +
+ 'Loading episodes…' +
+ '' +
+ '
' +
+ '';
+
+ if (!container._dlShowWired) {
+ container._dlShowWired = true;
+ container.addEventListener('click', onShowClick);
+ container.addEventListener('change', onShowChange);
+ }
+ getJSON('/api/video/downloads/quality').then(function (p) { if (container.isConnected && p) renderTarget(container, p, false); });
+ getJSON('/api/video/downloads/config').then(function (c) { if (container.isConnected) st.sources = sourcesFromConfig(c); });
+ buildSeasons(container, d, st);
+ }
+
+ function buildSeasons(container, d, st) {
+ var host = container.querySelector('[data-vdl-seasons]'); if (!host) return;
+ var seasons = (d.seasons || []).slice();
+ if (!seasons.length) { host.innerHTML = 'No season information available.
'; updateShowBar(container); return; }
+ host.innerHTML = seasons.map(function (s) { return seasonShellHTML(s); }).join('');
+ seasons.forEach(function (s) {
+ var card = host.querySelector('.vdl-season[data-vdl-season="' + s.season_number + '"]');
+ var eps = s.episodes || [];
+ if (eps.length) { fillSeason(container, card, s.season_number, eps, st); }
+ else if ((s.episode_total || 0) > 0 && st.tvId) { fetchSeason(container, card, s.season_number, st); }
+ else { var b = card.querySelector('.vdl-season-eps'); if (b) b.innerHTML = 'No episodes.
'; }
+ });
+ updateShowBar(container);
+ }
+
+ function seasonShellHTML(s) {
+ var sn = s.season_number;
+ var total = (s.episodes && s.episodes.length) || s.episode_total || 0;
+ return '' +
+ '
' +
+ '' +
+ '' + esc(s.title || ('Season ' + sn)) + '' +
+ '' + total + ' eps' +
+ '' +
+ '⌄' +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ function fillSeason(container, card, sn, eps, st) {
+ if (!card) return;
+ var body = card.querySelector('.vdl-season-eps'); if (!body) return;
+ var missing = 0;
+ eps.forEach(function (e) {
+ var es = epState(e, st.today);
+ st.epMeta[sn + '_' + e.episode_number] = { state: es };
+ if (es === 'missing') { missing++; st.sel.add(sn + '_' + e.episode_number); }
+ });
+ body.innerHTML = eps.map(function (e) { return epRowHTML(sn, e, st); }).join('');
+ var meta = card.querySelector('[data-vdl-season-meta]');
+ if (meta) meta.textContent = eps.length + ' eps' + (missing ? ' · ' + missing + ' missing' : '');
+ card.setAttribute('data-loaded', '1');
+ syncSeason(container, sn);
+ }
+
+ function fetchSeason(container, card, sn, st) {
+ if (!card || !st.tvId) return;
+ fetch('/api/video/tmdb/show/' + st.tvId + '/season/' + sn, { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
+ if (!container.isConnected) return;
+ var eps = (data && data.episodes) || [];
+ var b = card.querySelector('.vdl-season-eps');
+ if (!eps.length) { if (b) b.innerHTML = 'No episode info.
'; return; }
+ fillSeason(container, card, sn, eps, st);
+ updateShowBar(container);
+ })
+ .catch(function () { var b = card.querySelector('.vdl-season-eps'); if (b) b.innerHTML = 'Couldn’t load.
'; });
+ }
+
+ function epRowHTML(sn, e, st) {
+ var key = sn + '_' + e.episode_number;
+ var es = (st.epMeta[key] && st.epMeta[key].state) || epState(e, st.today);
+ var lock = (es === 'upcoming');
+ var ctrl = lock
+ ? '◷'
+ : '';
+ var badge = es === 'owned' ? 'In library'
+ : es === 'upcoming' ? 'Upcoming'
+ : 'Missing';
+ return '' +
+ '
' +
+ '' + ctrl + '' +
+ 'E' + (e.episode_number != null ? e.episode_number : '') + '' +
+ '' + esc(e.title || ('Episode ' + e.episode_number)) + '' +
+ '' +
+ badge +
+ (lock ? '' : '⌄') +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ function onShowClick(e) {
+ var container = e.currentTarget; if (!container._dl) return;
+ var srch = e.target.closest('[data-vdl-search]');
+ if (srch) { var row = srch.closest('.vdl-src'); if (row) scanRow(row, 0); toast('Automatic search isn’t wired up yet — coming soon', 'info'); return; }
+ var ss = e.target.closest('[data-vdl-season-search]');
+ if (ss) {
+ var sc = container.querySelector('.vdl-season[data-vdl-season="' + ss.getAttribute('data-vdl-season-search') + '"]');
+ if (sc) sc.classList.add('vdl-season--open'); // reveal the rows being scanned
+ searchScope(container, sc); return;
+ }
+ if (e.target.closest('[data-vdl-search-sel]')) { searchScope(container, container.querySelector('[data-vdl-seasons]')); return; }
+ var sh = e.target.closest('[data-vdl-season-toggle]');
+ if (sh && !e.target.closest('.vdl-season-cb') && !e.target.closest('[data-vdl-season-search]')) {
+ sh.closest('.vdl-season').classList.toggle('vdl-season--open'); return;
+ }
+ var eh = e.target.closest('[data-vdl-ep-toggle]');
+ if (eh && !e.target.closest('.vdl-ep-cell')) { toggleEp(container, eh.closest('.vdl-ep')); }
+ }
+
+ function onShowChange(e) {
+ var container = e.currentTarget; var st = container._dl; if (!st) return;
+ var ec = e.target.closest('[data-vdl-ep-cb]');
+ if (ec) {
+ var k = ec.getAttribute('data-vdl-ep-cb');
+ if (ec.checked) st.sel.add(k); else st.sel.delete(k);
+ syncSeason(container, k.split('_')[0]); updateShowBar(container); return;
+ }
+ var sa = e.target.closest('[data-vdl-season-all]');
+ if (sa) { setSeasonSel(container, sa.getAttribute('data-vdl-season-all'), sa.checked); updateShowBar(container); return; }
+ if (e.target.closest('[data-vdl-all]')) { setAllSel(container, e.target.checked); updateShowBar(container); }
+ }
+
+ function toggleEp(container, epEl) {
+ if (!epEl) return;
+ var open = !epEl.classList.contains('vdl-ep--open');
+ epEl.classList.toggle('vdl-ep--open', open);
+ if (open && !epEl.getAttribute('data-srcbuilt')) buildEpSearch(container, epEl);
+ }
+
+ function buildEpSearch(container, epEl) {
+ epEl.setAttribute('data-srcbuilt', '1');
+ var panel = epEl.querySelector('[data-vdl-ep-search]'); if (!panel) return;
+ var srcs = (container._dl.sources || []).filter(function (s) { return SRC_META[s]; });
+ if (!srcs.length) { panel.innerHTML = ''; return; }
+ panel.innerHTML = '' + srcs.map(function (s) {
+ var m = SRC_META[s];
+ return '
' +
+ '' + m.emoji + '' +
+ '' + esc(m.name) + '' +
+ 'Ready' +
+ '' +
+ '
';
+ }).join('') + '
';
+ }
+
+ function setSeasonSel(container, sn, on) {
+ var st = container._dl;
+ var cbs = container.querySelectorAll('.vdl-season[data-vdl-season="' + sn + '"] .vdl-ep-cb');
+ for (var i = 0; i < cbs.length; i++) {
+ cbs[i].checked = on;
+ var k = cbs[i].getAttribute('data-vdl-ep-cb');
+ if (on) st.sel.add(k); else st.sel.delete(k);
+ }
+ syncSeason(container, sn);
+ }
+
+ function setAllSel(container, on) {
+ var cards = container.querySelectorAll('.vdl-season');
+ for (var i = 0; i < cards.length; i++) setSeasonSel(container, cards[i].getAttribute('data-vdl-season'), on);
+ }
+
+ function syncSeason(container, sn) {
+ var card = container.querySelector('.vdl-season[data-vdl-season="' + sn + '"]'); if (!card) return;
+ var all = card.querySelector('[data-vdl-season-all]'); if (!all) return;
+ var cbs = card.querySelectorAll('.vdl-ep-cb'), checked = 0;
+ for (var i = 0; i < cbs.length; i++) if (cbs[i].checked) checked++;
+ all.checked = cbs.length > 0 && checked === cbs.length;
+ all.indeterminate = checked > 0 && checked < cbs.length;
+ all.disabled = cbs.length === 0;
+ }
+
+ function updateShowBar(container) {
+ var st = container._dl; if (!st) return;
+ var n = st.sel.size;
+ var cnt = container.querySelector('[data-vdl-selcount]'); if (cnt) cnt.textContent = n;
+ var btn = container.querySelector('[data-vdl-search-sel]'); if (btn) btn.disabled = n === 0;
+ var sum = container.querySelector('[data-vdl-summary]');
+ if (sum) {
+ var seasons = container.querySelectorAll('.vdl-season').length;
+ var owned = 0, missing = 0, total = 0;
+ for (var k in st.epMeta) { total++; if (st.epMeta[k].state === 'owned') owned++; else if (st.epMeta[k].state === 'missing') missing++; }
+ sum.textContent = seasons + ' season' + (seasons === 1 ? '' : 's') + ' · ' + total + ' episodes · ' +
+ owned + ' in library · ' + missing + ' missing';
+ }
+ var master = container.querySelector('[data-vdl-all]');
+ if (master) {
+ var all = container.querySelectorAll('.vdl-ep-cb'), c = 0;
+ for (var i = 0; i < all.length; i++) if (all[i].checked) c++;
+ master.checked = all.length > 0 && c === all.length;
+ master.indeterminate = c > 0 && c < all.length;
+ }
+ }
+
+ // Scan every SELECTED episode within a scope (a season card or the whole list).
+ function searchScope(container, scopeEl) {
+ if (!scopeEl || !container._dl) return;
+ var st = container._dl, eps = scopeEl.querySelectorAll('.vdl-ep'), picked = [];
+ for (var i = 0; i < eps.length; i++) { var k = eps[i].getAttribute('data-vdl-ep'); if (k && st.sel.has(k)) picked.push(eps[i]); }
+ if (!picked.length) { toast('Select at least one episode', 'info'); return; }
+ picked.forEach(function (epEl, i) { scanEp(epEl, i); });
+ toast('Automatic search isn’t wired up yet — coming soon', 'info');
+ }
+
+ function scanEp(epEl, i) {
+ if (epEl._scanning) return;
+ epEl._scanning = true;
+ epEl.classList.add('vdl-ep--scanning');
+ var s = epEl.querySelector('[data-vdl-ep-status]');
+ if (s) { s.textContent = 'Searching'; s.className = 'vdl-ep-status vdl-ep-status--scanning'; }
+ setTimeout(function () {
+ if (!epEl.isConnected) { epEl._scanning = false; return; }
+ epEl.classList.remove('vdl-ep--scanning');
+ epEl._scanning = false;
+ var x = epEl.querySelector('[data-vdl-ep-status]');
+ if (x) { x.textContent = 'Coming soon'; x.className = 'vdl-ep-status vdl-ep-status--soon'; }
+ }, 1100 + i * 180);
+ }
+
window.VideoDownload = { render: render };
})();
diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js
index b8ca3d64..8462752c 100644
--- a/webui/static/video/video-get-modal.js
+++ b/webui/static/video/video-get-modal.js
@@ -84,8 +84,15 @@
var dl = ov.querySelector('[data-vgm-dl]');
var content = ov.querySelector('[data-vgm-dl-content]');
if (!dl || !content || !window.VideoDownload) { toast('Download module not loaded', 'error'); return; }
- var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
- VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file });
+ if (o.kind === 'show') {
+ // Shows get a wider modal + a season/episode picker (not the movie layout).
+ VideoDownload.render(content, { kind: 'show', id: o.id, source: o.source || 'library',
+ detail: (modalState && modalState._detail) || null, tvId: (modalState && modalState._tvId) || null });
+ ov.classList.add('vgm-mode-dl-show');
+ } else {
+ var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
+ VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file });
+ }
setDownloadMode(ov, true);
dl.hidden = false;
var modal = ov.querySelector('.vgm-modal'); if (modal) modal.scrollTop = 0;
@@ -94,6 +101,7 @@
function exitDownload(ov) {
var dl = ov.querySelector('[data-vgm-dl]'); if (dl) dl.hidden = true;
setDownloadMode(ov, false);
+ ov.classList.remove('vgm-mode-dl-show');
}
// ── wishlist / watchlist writes ───────────────────────────────────────────
@@ -513,6 +521,8 @@
if (modalState) { // identity for the wishlist/watchlist writes
modalState.tmdbId = d.tmdb_id; modalState.title = d.title;
modalState.poster = pUrl || null; modalState.libraryId = libId;
+ modalState._detail = d; // feeds the show download tree (seasons/episodes)
+ modalState._tvId = (o.source === 'tmdb') ? parseInt(o.id, 10) : (d.tmdb_id || null);
}
} else {
modalState = { kind: 'movie', owned: !!d.owned, tmdbId: d.tmdb_id, title: d.title,
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index b49a99ba..2a148b91 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -2121,9 +2121,12 @@ body[data-side="video"] #soulsync-toggle { display: none; }
0 0 90px hsla(var(--vgm-h, 230), 75%, 55%, 0.20),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
transform: translateY(22px) scale(0.96);
- transition: transform 0.42s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
+ transition: transform 0.42s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease,
+ width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.vgm-overlay.vgm-open .vgm-modal { transform: none; }
+/* shows expand the modal for the season/episode picker */
+.vgm-overlay.vgm-mode-dl-show .vgm-modal { width: min(940px, 100%); }
/* hero — slow Ken Burns drift, hue-tinted scrim, and a drifting light sweep */
.vgm-hero { animation: vgmKen 22s ease-in-out infinite alternate; }
@@ -3174,3 +3177,74 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vdl-verdict--ok, .vdl-verdict--up, .vdl-verdict--pending, .vdl-search-all { animation: none !important; }
.vdl-chip::after, .vdl-search-all::after, .vdl-src--scanning::before { display: none; }
}
+
+/* ── TV show download view: season/episode picker ─────────────────────────── */
+.vdl-show-bar { display: flex; align-items: center; gap: 13px; flex-wrap: wrap; margin: 2px 0 16px;
+ padding: 11px 14px; border-radius: 13px; background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08); animation: vdlRise 0.45s both; }
+.vdl-allchk { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 12.5px;
+ font-weight: 800; color: rgba(255, 255, 255, 0.85); white-space: nowrap; }
+.vdl-show-summary { flex: 1; min-width: 120px; font-size: 12px; font-weight: 600; color: rgba(255, 255, 255, 0.5); }
+.vdl-search-sel:disabled { animation: none; opacity: 0.45; cursor: default; box-shadow: none; }
+.vdl-seasons input[type="checkbox"], .vdl-show-bar input[type="checkbox"] {
+ accent-color: rgb(var(--accent-rgb)); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0; margin: 0; }
+
+.vdl-seasons { display: flex; flex-direction: column; gap: 9px; }
+.vdl-season { border-radius: 13px; border: 1px solid rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.025); overflow: hidden; animation: vdlSlide 0.42s both; }
+.vdl-season:hover { border-color: rgba(255, 255, 255, 0.14); }
+.vdl-season-head { display: flex; align-items: center; gap: 11px; padding: 12px 14px; cursor: pointer;
+ transition: background 0.15s ease; }
+.vdl-season-head:hover { background: rgba(255, 255, 255, 0.04); }
+.vdl-season-name { font-size: 14px; font-weight: 800; color: #fff; letter-spacing: -0.01em; }
+.vdl-season-meta { flex: 1; font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.45); }
+.vdl-season-search { flex-shrink: 0; width: 32px; height: 28px; border-radius: 8px; cursor: pointer; font-size: 13px;
+ background: rgba(var(--accent-rgb), 0.16); border: 1px solid rgba(var(--accent-rgb), 0.35); color: #fff;
+ transition: all 0.15s ease; }
+.vdl-season-search:hover { background: rgba(var(--accent-rgb), 0.3); border-color: rgba(var(--accent-rgb), 0.6);
+ transform: translateY(-1px); }
+.vdl-season-chev { flex-shrink: 0; font-size: 13px; color: rgba(255, 255, 255, 0.5); transition: transform 0.25s ease; }
+.vdl-season--open .vdl-season-chev { transform: rotate(180deg); }
+.vdl-season-body { max-height: 0; overflow: hidden; transition: max-height 0.32s cubic-bezier(0.2, 0.7, 0.2, 1); }
+.vdl-season--open .vdl-season-body { max-height: 5000px; }
+.vdl-season-empty { padding: 12px 16px; font-size: 12.5px; color: rgba(255, 255, 255, 0.4); }
+
+.vdl-ep { position: relative; border-top: 1px solid rgba(255, 255, 255, 0.05); }
+.vdl-ep-head { display: flex; align-items: center; gap: 10px; padding: 9px 14px 9px 16px; cursor: pointer;
+ transition: background 0.14s ease; }
+.vdl-ep-head:hover { background: rgba(255, 255, 255, 0.035); }
+.vdl-ep--owned { opacity: 0.62; }
+.vdl-ep--owned:hover { opacity: 0.85; }
+.vdl-ep-cell { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 18px; }
+.vdl-ep-lock { color: rgba(255, 255, 255, 0.3); font-size: 13px; }
+.vdl-ep-num { flex-shrink: 0; font: 800 11px/1 'JetBrains Mono', ui-monospace, monospace;
+ color: rgba(255, 255, 255, 0.5); min-width: 26px; }
+.vdl-ep-title { flex: 1; min-width: 0; font-size: 13px; font-weight: 600; color: rgba(255, 255, 255, 0.92);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.vdl-ep-status { flex-shrink: 0; font-size: 11px; font-weight: 700; color: rgba(255, 255, 255, 0.4); white-space: nowrap; }
+.vdl-ep-status:empty { display: none; }
+.vdl-ep-status--scanning { color: hsl(var(--vgm-h, 220), 80%, 72%); }
+.vdl-ep-status--scanning::after { content: ''; animation: vdlDots 1.1s steps(1) infinite; }
+.vdl-ep-status--soon { color: #fbcd7a; }
+.vdl-ep-badge { flex-shrink: 0; font-size: 9.5px; font-weight: 800; letter-spacing: 0.02em; padding: 2px 8px;
+ border-radius: 999px; text-transform: uppercase; }
+.vdl-ep-badge--owned { background: rgba(108, 211, 145, 0.16); color: #8fe7af; }
+.vdl-ep-badge--missing { background: hsla(var(--vgm-h, 220), 50%, 55%, 0.2); color: #fff; }
+.vdl-ep-badge--soon { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.5); }
+.vdl-ep-chev { flex-shrink: 0; font-size: 12px; color: rgba(255, 255, 255, 0.38); transition: transform 0.22s ease; }
+.vdl-ep--open .vdl-ep-chev { transform: rotate(180deg); }
+.vdl-ep--scanning::before { content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 36%;
+ border-radius: 2px; background: linear-gradient(90deg, transparent, hsl(var(--vgm-h, 220), 85%, 62%), transparent);
+ box-shadow: 0 0 9px 1px hsla(var(--vgm-h, 220), 85%, 62%, 0.7); animation: vdlScan 0.9s ease-in-out infinite; }
+/* inline per-episode source search */
+.vdl-ep-search { max-height: 0; overflow: hidden; transition: max-height 0.3s cubic-bezier(0.2, 0.7, 0.2, 1); }
+.vdl-ep--open .vdl-ep-search { max-height: 360px; }
+.vdl-ep-srcs { display: flex; flex-direction: column; gap: 7px; padding: 3px 14px 12px 42px; }
+.vdl-src--mini { padding: 8px 11px; border-radius: 10px; animation: none; }
+.vdl-src--mini .vdl-src-icon { width: 32px; height: 32px; border-radius: 9px; }
+.vdl-src--mini .vdl-src-emoji { font-size: 15px; }
+.vdl-src--mini .vdl-src-name { font-size: 13px; }
+
+@media (prefers-reduced-motion: reduce) {
+ .vdl-show-bar, .vdl-season, .vdl-ep--scanning::before { animation: none !important; }
+}