diff --git a/webui/static/video/video-download-view.js b/webui/static/video/video-download-view.js index 3d27985c..f00be214 100644 --- a/webui/static/video/video-download-view.js +++ b/webui/static/video/video-download-view.js @@ -58,14 +58,21 @@ '' + '' + '
Loading sources…
' + + '' + ''; } function onClick(e) { var container = e.currentTarget; + if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet — coming soon', 'info'); return; } + var o = container._opts || {}; + var params = { scope: 'movie', title: o.title || '', year: o.year || null }; + var results = container.querySelector('[data-vdl-results]'); var sb = e.target.closest('[data-vdl-search]'); - if (sb) { stubSearch(container, sb.getAttribute('data-vdl-search')); return; } - if (e.target.closest('[data-vdl-search-all]')) { stubSearch(container, '*'); } + if (sb) { searchInto(container, results, params, [sb.closest('.vdl-src')]); return; } + if (e.target.closest('[data-vdl-search-all]')) { + searchInto(container, results, params, Array.prototype.slice.call(container.querySelectorAll('.vdl-src'))); + } } // Render the download view into `container`. Re-callable (resets each time). @@ -73,6 +80,7 @@ if (!container) return; opts = opts || {}; if (opts.kind === 'show') { renderShow(container, opts); return; } + container._opts = opts; container.innerHTML = contentHTML(); if (!container._vdlWired) { container._vdlWired = true; container.addEventListener('click', onClick); } @@ -173,29 +181,72 @@ // Scaffold: a satisfying faux-scan (animated) that resolves to "coming soon". // No backend yet — this is the motion the real engine will drive. - function scanRow(row, i) { - if (row._scanning) return; - row._scanning = true; - var st = row.querySelector('[data-vdl-status]'); - var btn = row.querySelector('[data-vdl-search]'); - row.classList.add('vdl-src--scanning'); - if (btn) btn.disabled = true; - if (st) { st.textContent = 'Searching'; st.className = 'vdl-src-status vdl-src-status--scanning'; } - setTimeout(function () { - if (!row.isConnected) { row._scanning = false; return; } - row.classList.remove('vdl-src--scanning'); - row._scanning = false; - if (btn) btn.disabled = false; + // ── search + results ────────────────────────────────────────────────────── + var SRC_LABEL = { remux: 'Remux', bluray: 'BluRay', 'web-dl': 'WEB-DL', webrip: 'WEBRip', + hdtv: 'HDTV', dvd: 'DVD', cam: 'CAM', screener: 'Screener', workprint: 'Workprint' }; + var RES_LABEL = { '2160p': '4K', '1080p': '1080p', '720p': '720p', '480p': 'SD' }; + + function _setScanning(rows, on) { + rows.forEach(function (row) { + if (!row) return; + if (row.matches && row.matches('button')) { row.disabled = on; row.classList.toggle('vdl-btn--busy', on); return; } + row.classList.toggle('vdl-src--scanning', on); + var b = row.querySelector('[data-vdl-search]'); if (b) b.disabled = on; var s = row.querySelector('[data-vdl-status]'); - if (s) { s.textContent = 'Search engine coming soon'; s.className = 'vdl-src-status vdl-src-status--soon'; } - }, 1300 + i * 280); // staggered finish so a "search all" ripples + if (s) { s.textContent = on ? 'Searching' : 'Ready'; s.className = 'vdl-src-status' + (on ? ' vdl-src-status--scanning' : ''); } + }); } - function stubSearch(container, which) { - var sel = which === '*' ? '[data-vdl-src]' : '[data-vdl-src="' + which + '"]'; - var rows = container.querySelectorAll(sel); - for (var i = 0; i < rows.length; i++) scanRow(rows[i], i); - toast('Automatic search isn’t wired up yet — coming soon', 'info'); + // Run a search (mock indexer → real parse/evaluate/rank) and render the cards. + function searchInto(container, resultsEl, params, triggerRows) { + if (!resultsEl) return; + triggerRows = (triggerRows || []).filter(Boolean); + _setScanning(triggerRows, true); + resultsEl.hidden = false; + resultsEl.innerHTML = '
Searching ' + esc(scopeWord(params.scope)) + '…
'; + postJSON('/api/video/downloads/search', params).then(function (d) { + _setScanning(triggerRows, false); + if (!resultsEl.isConnected) return; + var rows = (d && d.results) || []; + if (!rows.length) { resultsEl.innerHTML = '
No matching releases found.
'; return; } + var okN = rows.filter(function (r) { return r.accepted; }).length; + resultsEl.innerHTML = + '
' + rows.length + ' result' + (rows.length === 1 ? '' : 's') + + ' · ' + okN + ' meet your profile
' + + rows.map(resultCardHTML).join(''); + }); + } + + function scopeWord(s) { + return s === 'season' ? 'for the season pack' : s === 'series' ? 'for the full series' + : s === 'episode' ? 'this episode' : 'for the movie'; + } + + function rb(text, mod) { return '' + esc(text) + ''; } + + function resultCardHTML(r) { + var badges = []; + if (r.resolution) badges.push(rb(RES_LABEL[r.resolution] || r.resolution, 'res')); + if (r.source) badges.push(rb(SRC_LABEL[r.source] || r.source, 'src')); + if (r.codec) badges.push(rb(String(r.codec).toUpperCase())); + if (r.hdr) badges.push(rb(String(r.hdr).toUpperCase(), 'hdr')); + if (r.audio) badges.push(rb(String(r.audio).toUpperCase().replace('-', ' '))); + if (r.repack) badges.push(rb('REPACK')); + var verdict = r.accepted + ? '✓ Meets profile' + : '✕ ' + esc(r.rejected || 'Filtered') + ''; + return '
' + + '
' + + '
' + esc(r.title) + '
' + + (r.accepted ? '' : '') + + '
' + + '
' + badges.join('') + '
' + + '
' + + '' + r.size_gb + ' GB' + + '▲ ' + (r.seeders || 0) + '' + + verdict + + '
' + + '
'; } // ── TV show download view ───────────────────────────────────────────────── @@ -214,10 +265,13 @@ function renderShow(container, opts) { var d = opts.detail || {}; + var maxSeason = 0; + (d.seasons || []).forEach(function (s) { if ((s.season_number || 0) > maxSeason) maxSeason = s.season_number; }); var st = container._dl = { sel: new Set(), today: isoToday(), tvId: opts.tvId || d.tmdb_id || null, source: opts.source || 'library', - sources: ['soulseek'], epMeta: {} + sources: ['soulseek'], epMeta: {}, + title: d.title || opts.title || '', maxSeason: maxSeason }; container.innerHTML = '
Quality target
' + @@ -225,9 +279,9 @@ '
' + '' + 'Loading episodes…' + - '' + + '' + '
' + + '' + '
'; if (!container._dlShowWired) { @@ -266,7 +320,10 @@ '' + '' + '
' + - '
Loading…
' + + '
' + + '' + + '
Loading…
' + + '
' + ''; } @@ -325,16 +382,35 @@ } function onShowClick(e) { - var container = e.currentTarget; if (!container._dl) return; + var container = e.currentTarget; var st = container._dl; if (!st) return; + if (e.target.closest('[data-vdl-grab]')) { toast('Grabbing isn’t wired up yet — coming soon', 'info'); return; } + // Episode-scope search (a source row inside an expanded episode). 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; } + if (srch) { + var epEl = srch.closest('.vdl-ep'); if (!epEl) return; + var parts = (epEl.getAttribute('data-vdl-ep') || '').split('_'); + searchInto(container, epEl.querySelector('[data-vdl-ep-results]'), + { scope: 'episode', title: st.title, season: +parts[0], episode: +parts[1] }, + [srch.closest('.vdl-src')]); + return; + } + // Season-scope search → season PACK. 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; + var sn = ss.getAttribute('data-vdl-season-search'); + var sc = container.querySelector('.vdl-season[data-vdl-season="' + sn + '"]'); + if (sc) sc.classList.add('vdl-season--open'); + searchInto(container, sc && sc.querySelector('[data-vdl-season-results]'), + { scope: 'season', title: st.title, season: +sn }, [ss]); + return; + } + // Whole-show search → complete-series pack. + if (e.target.closest('[data-vdl-search-show]')) { + searchInto(container, container.querySelector('[data-vdl-show-results]'), + { scope: 'series', title: st.title, season_end: st.maxSeason || 5 }, + [e.target.closest('[data-vdl-search-show]')]); + 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; @@ -376,7 +452,8 @@ 'Ready' + '' + ''; - }).join('') + ''; + }).join('') + '' + + ''; } function setSeasonSel(container, sn, on) { @@ -407,9 +484,6 @@ 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; @@ -427,30 +501,5 @@ } } - // 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 8462752c..97d073c1 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -91,7 +91,8 @@ 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 }); + VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file, + title: (modalState && modalState.title) || o.title || '', year: (modalState && modalState.year) || null }); } setDownloadMode(ov, true); dl.hidden = false; diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 2a148b91..07cbcb8b 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3248,3 +3248,48 @@ body[data-side="video"] #soulsync-toggle { display: none; } @media (prefers-reduced-motion: reduce) { .vdl-show-bar, .vdl-season, .vdl-ep--scanning::before { animation: none !important; } } + +/* ── search results (movie/episode/season/series) ─────────────────────────── */ +.vdl-results { margin-top: 12px; } +.vdl-results[hidden] { display: none; } +.vdl-results--season { margin: 2px 14px 12px 14px; } +.vdl-ep-search .vdl-results { padding: 0 14px 12px 42px; margin-top: 2px; } +.vdl-ep--open .vdl-ep-search { max-height: 1800px; } /* room for result cards */ +.vdl-res-loading { display: flex; align-items: center; gap: 9px; font-size: 12.5px; font-weight: 600; + color: rgba(255, 255, 255, 0.6); padding: 9px 2px; } +.vdl-res-spin { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; + border: 2px solid rgba(255, 255, 255, 0.2); border-top-color: rgb(var(--accent-rgb)); animation: vdlSpin 0.7s linear infinite; } +@keyframes vdlSpin { to { transform: rotate(360deg); } } +.vdl-res-empty { font-size: 12.5px; color: rgba(255, 255, 255, 0.45); padding: 9px 2px; } +.vdl-res-head { font-size: 11.5px; font-weight: 700; color: rgba(255, 255, 255, 0.5); margin: 4px 0 10px; } +.vdl-res-head strong { color: #fff; } +.vdl-res-okn { color: #8fe7af; } +.vdl-res { border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.03); + padding: 11px 13px; margin-bottom: 8px; animation: vdlRise 0.4s both; + transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; } +.vdl-res:hover { border-color: rgba(var(--accent-rgb), 0.4); background: rgba(255, 255, 255, 0.055); transform: translateY(-1px); } +.vdl-res--rejected { opacity: 0.5; } +.vdl-res--rejected:hover { opacity: 0.78; } +.vdl-res-top { display: flex; align-items: flex-start; gap: 10px; } +.vdl-res-title { flex: 1; min-width: 0; font: 600 11.5px/1.45 'JetBrains Mono', ui-monospace, monospace; + color: rgba(255, 255, 255, 0.9); word-break: break-all; } +.vdl-res-grab { flex-shrink: 0; width: 30px; height: 30px; border-radius: 9px; cursor: pointer; font-size: 15px; + font-weight: 900; background: rgb(var(--accent-rgb)); color: #06210f; border: none; + transition: filter 0.15s ease, transform 0.15s ease; } +.vdl-res-grab:hover { filter: brightness(1.12); transform: translateY(-1px); } +.vdl-res-badges { display: flex; flex-wrap: wrap; gap: 6px; margin: 9px 0; } +.vdl-rb { font-size: 10px; font-weight: 800; letter-spacing: 0.02em; padding: 2px 8px; border-radius: 6px; + text-transform: uppercase; background: rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.8); } +.vdl-rb--res { background: rgba(var(--accent-rgb), 0.2); border-color: rgba(var(--accent-rgb), 0.42); color: #fff; } +.vdl-rb--src { background: rgba(255, 255, 255, 0.12); color: #fff; } +.vdl-rb--hdr { background: linear-gradient(100deg, rgba(245, 158, 11, 0.28), rgba(168, 85, 247, 0.28)); + border-color: rgba(245, 158, 11, 0.4); color: #fde2b3; } +.vdl-res-foot { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; } +.vdl-res-stat { font-size: 11.5px; font-weight: 700; color: rgba(255, 255, 255, 0.55); } +.vdl-res-seed { color: #8fe7af; } +.vdl-res-verdict { margin-left: auto; font-size: 10.5px; font-weight: 800; padding: 3px 9px; border-radius: 999px; } +.vdl-res-verdict--ok { background: rgba(108, 211, 145, 0.16); color: #8fe7af; } +.vdl-res-verdict--no { background: rgba(255, 255, 255, 0.06); color: rgba(255, 255, 255, 0.5); } +.vdl-btn--busy { opacity: 0.7; cursor: progress; } +@media (prefers-reduced-motion: reduce) { .vdl-res, .vdl-res-spin { animation: none !important; } }