From 8803c1bd0c59f26ca8a5f085d256fe560a811f21 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 12:06:23 -0700 Subject: [PATCH] =?UTF-8?q?video=20download:=20dedicated=20TV=20show=20vie?= =?UTF-8?q?w=20=E2=80=94=20season/episode=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows now get their own download experience instead of the movie layout. Clicking Download on a show WIDENS the modal (~680→940px, animated) and renders a tree: - Quality target chips + a show summary (N seasons · M episodes · X in library · Y missing) + a master select-all + a primary 'Search N selected'. - Season cards (collapsible) with a tri-state season checkbox, 'N eps · M missing', and a per-season search button (auto-opens the season to reveal the scan). - Episode rows: checkbox, E# · title, In library/Missing/Upcoming badge. Default selection = everything you're MISSING (owned shown but unticked, upcoming locked). - Each episode expands inline to its branded per-source search strip (Soulseek/ Torrent/Usenet), so you can grab one episode à la carte; or batch via the season/ bulk buttons. Searches are the same faux-scan stub — no backend yet. Library shows use shipped episodes; tmdb shows prefetch seasons via the existing /tmdb/show//season/ endpoint. get-modal stashes the detail + tvId and routes shows to VideoDownload.render({kind:'show',…}); movie/youtube paths unchanged. Vibey: staggered entrance, branded scan bars, smooth collapse. Reduced-motion honored. JS + CSS balance clean. --- webui/static/video/video-download-view.js | 255 ++++++++++++++++++++++ webui/static/video/video-get-modal.js | 14 +- webui/static/video/video-side.css | 76 ++++++- 3 files changed, 342 insertions(+), 3 deletions(-) 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' + + '' + + '' + + '
' + + '
Loading…
' + + '
'; + } + + 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 = '
No source configured.
'; 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; } +}