/* * SoulSync — Download VIEW renderer (movie / TV show / YouTube). * * NOT its own modal: it renders the direct-download content INTO a container the * caller owns — the get-modal swaps its detail body for this view (with a Back * button) when you click "Download", and a future YouTube trigger can reuse it. * * v1 is VISUAL scaffolding: it shows the quality TARGET (read from the Settings → * Downloads profile), judges any copy you ALREADY own against that target (real — * via /downloads/evaluate), and lists each attached source with a per-source * "Search" affordance. The searches are stubs — no backend yet (engine phase). * * VideoDownload.render(containerEl, { kind, id, source, isYt, file }). Self-contained. */ (function () { 'use strict'; function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); } function resLabel(res) { if (!res) return ''; res = String(res).toLowerCase(); if (res.indexOf('2160') > -1 || res === '4k') return '4K'; if (res.indexOf('1080') > -1) return '1080p'; if (res.indexOf('720') > -1) return '720p'; if (res.indexOf('480') > -1 || res.indexOf('576') > -1) return 'SD'; return res.toUpperCase(); } var CUT_LABEL = { '2160p': '4K', '1080p': '1080p', '720p': '720p', '480p': 'SD' }; var SRC_META = { soulseek: { name: 'Soulseek', emoji: '🎵' }, torrent: { name: 'Torrent', emoji: '🧲' }, usenet: { name: 'Usenet', emoji: '📰' }, youtube: { name: 'YouTube', emoji: '▶' } }; function getJSON(url) { return fetch(url, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } function postJSON(url, body) { return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } function contentHTML() { return '' + '
' + '
Quality target
' + '
Loading…
' + '
' + '' + '
' + '
' + '
Sources
' + '' + '' + '' + '
' + '
Loading sources…
' + '
'; } // One source = a row + its OWN results panel (so each source shows its own hits). function srcRowHTML(s, mini) { var m = SRC_META[s]; return '
' + '' + m.emoji + '' + '' + esc(m.name) + '' + 'Ready' + '' + '' + '' + '' + '
'; } function srcBlockHTML(s, mini) { return '
' + srcRowHTML(s, mini) + '' + '
'; } function _movieSearch(container, block, auto, onSettled) { var o = container._opts || {}; var s = block.getAttribute('data-vdl-src-block'); var resultsEl = block.querySelector('[data-vdl-results-for="' + s + '"]'); var statusRow = block.querySelector('.vdl-src'); // In per-source auto mode, grab this source's best when it settles; an // optional onSettled lets a coordinator (header Auto) wait for every source. var onDone = (auto || onSettled) ? function () { if (auto) _autoPick(resultsEl, statusRow); if (onSettled) onSettled(resultsEl); } : null; searchInto(container, resultsEl, { scope: 'movie', title: o.title || '', year: o.year || null, source: s }, [statusRow], onDone); } // Header "Auto": search EVERY source, then download the single best release // across all of them (NOT one per source). Each source's results still show so // the pick is transparent; the winner gets the auto ring + live tracker. function _autoBest(container) { var blocks = Array.prototype.slice.call(container.querySelectorAll('[data-vdl-src-block]')); if (!blocks.length) return; var hb = container.querySelector('[data-vdl-auto-best]'); if (hb) hb.disabled = true; var pending = blocks.length, done = false; blocks.forEach(function (block) { _movieSearch(container, block, false, function () { if (done) return; if (--pending > 0) return; done = true; _grabBestAcross(container, blocks, hb); }); }); } function _grabBestAcross(container, blocks, hb) { var best = null, bestPanel = null, bestIdx = -1; blocks.forEach(function (block) { var s = block.getAttribute('data-vdl-src-block'); var panel = block.querySelector('[data-vdl-results-for="' + s + '"]'); var rows = (panel && panel._rows) || []; for (var i = 0; i < rows.length; i++) { var r = rows[i]; if (!(r.accepted && r.username)) continue; // grabbable (Soulseek) + meets profile var avail = r.peers || r.seeders || 0; var bAvail = best ? (best.peers || best.seeders || 0) : -1; if (!best || (r.score || 0) > (best.score || 0) || ((r.score || 0) === (best.score || 0) && avail > bAvail)) { best = r; bestPanel = panel; bestIdx = i; } } }); if (hb) hb.disabled = false; if (!best) { toast('Auto: no release met your quality profile on any source', 'error'); return; } var card = bestPanel.querySelector('[data-vdl-card="' + bestIdx + '"]'); if (card) { card.classList.add('vdl-res--auto'); if (card.scrollIntoView) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } var gbtn = card && card.querySelector('[data-vdl-grab]'); if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); } toast('Auto-picked best across sources: ' + (best.quality_label || best.title || 'release'), 'info'); sendGrab(buildGrabPayload(bestPanel, best)).then(function (res) { if (res && res.ok) { toast('Sent to Downloads', 'success'); beginTracking(card, res.id); document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } toast((res && res.error) || 'Auto: couldn’t start the download', 'error'); } }); } function onClick(e) { var container = e.currentTarget; var grab = e.target.closest('[data-vdl-grab]'); if (grab) { doGrab(grab); return; } if (e.target.closest('[data-vdl-auto-best]')) { _autoBest(container); return; } var ab = e.target.closest('[data-vdl-auto]'); if (ab) { _movieSearch(container, ab.closest('[data-vdl-src-block]'), true); return; } var sb = e.target.closest('[data-vdl-search]'); if (sb) { _movieSearch(container, sb.closest('[data-vdl-src-block]')); return; } } // Render the download view into `container`. Re-callable (resets each time). function render(container, opts) { 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); } var isYt = !!opts.isYt; getJSON(isYt ? '/api/video/downloads/youtube-quality' : '/api/video/downloads/quality') .then(function (p) { if (container.isConnected && p) renderTarget(container, p, isYt); }); if (isYt) { renderSources(container, ['youtube']); } else { getJSON('/api/video/downloads/config').then(function (c) { if (container.isConnected) renderSources(container, sourcesFromConfig(c)); }); if (opts.file) renderOwned(container, opts.file); } // Resume tracking: if this title already has a download in flight (e.g. the // user grabbed it, closed the modal, and re-opened), show a live banner. watchActiveDownload(container, opts); } // Poll for an active/just-finished download of THIS title (by media identity) and // surface a live banner at the top of the view — so re-opening the modal knows a // download is already running. Suppressed while a result card is already tracking // it inline (fresh-grab case), to avoid a duplicate indicator. function watchActiveDownload(container, opts) { var box = container.querySelector('[data-vdl-active]'); if (!box) return; var mediaId = (opts.id != null ? opts.id : opts.mediaId); if (mediaId == null) { box.hidden = true; return; } var mediaSource = opts.source || opts.mediaSource || 'library'; if (container._activeT) { clearTimeout(container._activeT); container._activeT = null; } (function tick() { if (!container.isConnected) return; // modal closed → stop getJSON('/api/video/downloads/status?media_id=' + encodeURIComponent(mediaId) + '&media_source=' + encodeURIComponent(mediaSource)).then(function (d) { if (!container.isConnected) return; var dl = d && d.download; var inlineTracker = !!container.querySelector('[data-vdl-track]'); // a card is already tracking renderActiveBanner(box, (dl && !inlineTracker) ? dl : null); var active = dl && ['downloading', 'queued', 'searching'].indexOf(dl.status) > -1; if (active) container._activeT = setTimeout(tick, 1800); }); })(); } function renderActiveBanner(box, dl) { var show = dl && ['downloading', 'queued', 'searching', 'completed', 'failed'].indexOf(dl.status) > -1; if (!show) { box.hidden = true; box.innerHTML = ''; box._wired = false; return; } box.hidden = false; var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0)); if (st === 'completed') pct = 100; box.className = 'vdl-active vdl-active--' + (st === 'completed' ? 'done' : (st === 'failed' ? 'fail' : 'active')); var label = st === 'completed' ? 'Downloaded' : st === 'failed' ? 'Download failed' : st === 'searching' ? 'Finding a release…' : st === 'queued' ? 'Queued' : 'Downloading'; var ic = st === 'completed' ? '✓' : st === 'failed' ? '✕' : '⤓'; var pctTxt = (st === 'downloading' || st === 'queued') ? pct + '%' : ''; box.innerHTML = '
' + '
' + '' + ic + '' + '' + esc(label) + '' + (dl.release_title ? ' · ' + esc(dl.release_title) + '' : '') + '' + '' + pctTxt + '' + '' + '
'; if (!box._wired) { box._wired = true; box.addEventListener('click', function (e) { if (e.target.closest('[data-vdl-active-go]')) gotoDownloads(); }); } } function sourcesFromConfig(c) { c = c || {}; if (c.download_mode === 'hybrid' && Array.isArray(c.hybrid_order) && c.hybrid_order.length) return c.hybrid_order; if (c.download_mode) return [c.download_mode]; return ['soulseek']; } function chip(text, mod) { return '' + esc(text) + ''; } function renderTarget(container, p, isYt) { var box = container.querySelector('[data-vdl-target]'); if (!box) return; var chips = []; if (isYt) { chips.push(chip('Up to ' + (p.max_resolution === 'best' ? 'best' : (p.max_resolution || '1080p')))); if (p.video_codec && p.video_codec !== 'any') chips.push(chip('Prefer ' + p.video_codec.toUpperCase())); if (p.container) chips.push(chip(p.container.toUpperCase())); if (p.prefer_60fps) chips.push(chip('60fps')); chips.push(chip(p.allow_hdr ? 'HDR ok' : 'SDR')); } else { chips.push(chip(p.cutoff_resolution ? 'Stop at ' + (CUT_LABEL[p.cutoff_resolution] || p.cutoff_resolution) : 'Always upgrade')); if (p.prefer_codec && p.prefer_codec !== 'any') chips.push(chip('Prefer ' + (p.prefer_codec === 'hevc' ? 'HEVC' : p.prefer_codec.toUpperCase()))); if (p.prefer_hdr === 'prefer') chips.push(chip('Prefer HDR')); else if (p.prefer_hdr === 'require') chips.push(chip('HDR required', 'req')); if (Array.isArray(p.rejects) && p.rejects.length) chips.push(chip('Reject ' + p.rejects.join(', '), 'rej')); if (p.max_movie_gb) chips.push(chip('Movie ≤ ' + p.max_movie_gb + ' GB')); if (p.max_episode_gb) chips.push(chip('Episode ≤ ' + p.max_episode_gb + ' GB')); } box.innerHTML = chips.join(''); } // Owned copy → "In your library · 720p · BluRay · X265" + a verdict against the // quality target (real: /downloads/evaluate). meets → reassuring; else upgrade. function renderOwned(container, file) { var box = container.querySelector('[data-vdl-owned]'); if (!box) return; var bits = [resLabel(file.resolution), file.release_source, (file.video_codec || '').toUpperCase()].filter(Boolean); box.innerHTML = '
' + '' + 'In your library' + (bits.length ? ' · ' + esc(bits.join(' · ')) : '') + '' + 'checking…' + '
' + '
'; box.hidden = false; postJSON('/api/video/downloads/evaluate', { file: file }).then(function (v) { if (!container.isConnected || !v) return; var badge = box.querySelector('[data-vdl-verdict]'); if (badge) { badge.classList.remove('vdl-verdict--pending'); badge.classList.add(v.meets ? 'vdl-verdict--ok' : 'vdl-verdict--up'); badge.textContent = v.meets ? 'Meets your target' : 'Eligible for upgrade'; } var rs = box.querySelector('[data-vdl-reasons]'); if (rs && v.reasons && v.reasons.length) { rs.innerHTML = v.reasons.map(function (r) { return '
' + (r.ok ? '✓' : '↑') + ' ' + esc(r.text) + '
'; }).join(''); } }); } function renderSources(container, list) { var box = container.querySelector('[data-vdl-sources]'); if (!box) return; list = (list || []).filter(function (s) { return SRC_META[s]; }); if (!list.length) { box.innerHTML = '
No download source configured — pick one on Settings → Downloads.
'; return; } box.innerHTML = list.map(function (s) { return srcBlockHTML(s, false); }).join(''); } // ── 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 a = row.querySelector('[data-vdl-auto]'); if (a) a.disabled = on; var s = row.querySelector('[data-vdl-status]'); if (s) { s.textContent = on ? 'Searching' : 'Ready'; s.className = 'vdl-src-status' + (on ? ' vdl-src-status--scanning' : ''); } }); } // Render the result cards (shared by the immediate mock path and live polling). function renderResults(resultsEl, params, rows, live, done, totalFiles) { rows = rows || []; if (!rows.length) { if (!done) { resultsEl.innerHTML = '
Searching ' + esc(scopeWord(params.scope)) + '…
'; } else if (totalFiles > 0) { resultsEl.innerHTML = '
Soulseek returned ' + totalFiles + ' file' + (totalFiles === 1 ? '' : 's') + ', but none are video releases — likely audio/other for this title. Try a different title or source.
'; } else { resultsEl.innerHTML = '
No matching releases found.
'; } return; } resultsEl._rows = rows; resultsEl._search = params; // for the Grab button resultsEl.classList.toggle('vdl-res-noanim', !!live); // live re-renders → no per-card blink var okN = rows.filter(function (r) { return r.accepted; }).length; var badge = !live ? 'demo data' : done ? '● live' : 'searching…'; resultsEl.innerHTML = '
' + rows.length + ' result' + (rows.length === 1 ? '' : 's') + ' · ' + okN + ' meet your profile' + badge + '
' + rows.map(resultCardHTML).join(''); } // Start a search; for Soulseek, stream results in (poll like the music side — // results trickle in over ~30s, so a single short wait misses them). function searchInto(container, resultsEl, params, triggerRows, onDone) { if (!resultsEl) return; triggerRows = (triggerRows || []).filter(Boolean); if (resultsEl._poll) { clearTimeout(resultsEl._poll); resultsEl._poll = null; } resultsEl._rows = null; // drop any prior search's rows so Auto can't grab a stale hit _setScanning(triggerRows, true); resultsEl.hidden = false; resultsEl.classList.remove('vdl-res-noanim'); resultsEl.innerHTML = '
Searching ' + esc(scopeWord(params.scope)) + '…
'; postJSON('/api/video/downloads/search/start', params).then(function (d) { if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; } if (d && d.error) { _setScanning(triggerRows, false); resultsEl.innerHTML = '
⚠ ' + esc(d.error) + '
'; return; } if (!d || !d.id) { // mock / immediate _setScanning(triggerRows, false); renderResults(resultsEl, params, d ? d.results : [], !!(d && d.live), true); if (onDone) onDone(); return; } _pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms, onDone); }); } function _pollSearch(resultsEl, params, id, triggerRows, pollMs, onDone) { // slskd keeps searching for the whole search_timeout (~60s) and results // trickle in over ~50s — poll that long (the music side does), streaming // results as they arrive. Stop early only once results clearly plateau. var started = Date.now(), lastN = -1, stable = 0, total = 0; var MAX_MS = Math.min(80000, pollMs || 60000); function tick() { if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; } var qs = '?id=' + encodeURIComponent(id) + '&scope=' + encodeURIComponent(params.scope || 'movie') + '&title=' + encodeURIComponent(params.title || '') + (params.season != null ? '&season=' + params.season : '') + (params.episode != null ? '&episode=' + params.episode : ''); getJSON('/api/video/downloads/search/poll' + qs).then(function (d) { if (!resultsEl.isConnected) { _setScanning(triggerRows, false); return; } var rows = (d && d.results) || []; total = (d && d.total_files) || total; if (rows.length === lastN) { stable++; } else { stable = 0; lastN = rows.length; } var elapsed = Date.now() - started; // done = full timeout, OR plenty of results, OR results plateaued after ≥20s. var done = elapsed >= MAX_MS || rows.length >= 25 || (rows.length > 0 && elapsed > 20000 && stable >= 6); renderResults(resultsEl, params, rows, true, done, total); if (done) { _setScanning(triggerRows, false); resultsEl._poll = null; if (onDone) onDone(); } else { resultsEl._poll = setTimeout(tick, 1500); } }); } tick(); } // Build the /grab payload for a chosen release row `r` in `panel` (the results // element). Shared by the manual grab button and the auto-pick path so both // send an identical request (incl. the auto-retry candidate pool). function buildGrabPayload(panel, r) { var p = panel._search || {}; var container = panel.closest('[data-vgm-dl-content]'); var o = (container && (container._opts || container._dl)) || {}; // the other accepted (live slskd) hits become the auto-retry pool var pool = (panel._rows || []).filter(function (x) { return x.accepted && x.username && x.filename !== r.filename; }) .map(function (x) { return { username: x.username, filename: x.filename, size_bytes: x.size_bytes, quality_label: x.quality_label, title: x.title }; }); return { kind: p.scope || 'movie', title: p.title || '', release_title: r.title, source: 'soulseek', username: r.username, filename: r.filename, size_bytes: r.size_bytes, quality_label: r.quality_label, media_id: o.id || o.mediaId, media_source: o.source || o.mediaSource, year: o.year, poster_url: o.poster, candidates: pool, search_ctx: { scope: p.scope || 'movie', title: p.title || '', year: o.year, season: p.season != null ? p.season : null, episode: p.episode != null ? p.episode : null } }; } function sendGrab(payload) { return postJSON('/api/video/downloads/grab', payload); } // Grab → start a real download (Soulseek only for now), then it lives on the // Downloads page. Reads the card's row + the panel's search context. function doGrab(btn) { var panel = btn.closest('.vdl-results'); if (!panel || !panel._rows) return; var card = btn.closest('.vdl-res'); var r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return; btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); sendGrab(buildGrabPayload(panel, r)).then(function (res) { if (res && res.ok) { toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // selected card → live tracker + Track button var _ep = panel.closest && panel.closest('.vdl-ep'); if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { btn.disabled = false; btn.classList.remove('vdl-res-grab--busy'); toast((res && res.error) || 'Couldn’t start the download', 'error'); } }); } // Auto-pick: after an auto-search settles, grab the BEST grabbable release. // Results arrive already ranked best-first (server sorts by accepted → score → // availability), so the first accepted hit with an uploader is the best pick. function _autoPick(panel, statusRow) { if (!panel || !panel.isConnected) return; var rows = panel._rows || []; var best = null, bestIdx = -1; for (var i = 0; i < rows.length; i++) { if (rows[i].accepted && rows[i].username) { best = rows[i]; bestIdx = i; break; } } var statusEl = statusRow && statusRow.querySelector('[data-vdl-status]'); if (!best) { if (statusEl) { statusEl.textContent = 'No match'; statusEl.className = 'vdl-src-status vdl-src-status--none'; } toast('Auto: no release met your quality profile', 'error'); return; } // Spotlight the card we're auto-grabbing so the choice is obvious, and // scroll it into view (it may be below the fold among many results). var card = panel.querySelector('[data-vdl-card="' + bestIdx + '"]'); if (card) { card.classList.add('vdl-res--auto'); if (card.scrollIntoView) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } var gbtn = card && card.querySelector('[data-vdl-grab]'); if (gbtn) { gbtn.disabled = true; gbtn.classList.add('vdl-res-grab--busy'); } if (statusEl) { statusEl.textContent = 'Auto-grabbing'; statusEl.className = 'vdl-src-status vdl-src-status--scanning'; } toast('Auto-picked best: ' + (best.quality_label || best.title || 'release'), 'info'); sendGrab(buildGrabPayload(panel, best)).then(function (res) { if (res && res.ok) { if (statusEl) { statusEl.textContent = 'Sent'; statusEl.className = 'vdl-src-status vdl-src-status--done'; } toast('Sent to Downloads', 'success'); beginTracking(card, res.id); // chosen card → live tracker + Track button var _ep = panel.closest && panel.closest('.vdl-ep'); if (_ep) epTrack(_ep, res.id); // also light the collapsed episode row document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { if (gbtn) { gbtn.disabled = false; gbtn.classList.remove('vdl-res-grab--busy'); } if (statusEl) { statusEl.textContent = 'Ready'; statusEl.className = 'vdl-src-status'; } toast((res && res.error) || 'Auto: couldn’t start the download', 'error'); } }); } function scopeWord(s) { return s === 'season' ? 'for the season pack' : s === 'series' ? 'for the full series' : s === 'episode' ? 'this episode' : 'for the movie'; } function resKind(res) { return res === '2160p' ? '4k' : res === '1080p' ? '1080' : res === '720p' ? '720' : 'sd'; } // Availability differs by source: slskd has a peer (uploader); torrent/usenet seeders. function resAvailHTML(r) { if (r.username) { return '👤' + esc(r.username) + (r.peers > 1 ? ' · ' + r.peers + ' peers' : '') + (r.slots ? ' · ' + r.slots + ' slots' : '') + ''; } return '▲ ' + (r.seeders || 0) + ' seeders'; } // Result card: a colour-coded resolution badge anchors the left; the headline is // a plain-English quality summary with the verdict pill; the raw release name is // demoted to a mono one-liner; a stat strip (size / uploader / group) sits below. // The card is a column so a live download tracker can drop in under it on grab. function resultCardHTML(r, i) { // Flat / brutalist release card: a bracketed quality block + release name on // line 1, an UPPERCASE dot-separated spec line, then the verdict + a hard // [ GET ] button. Monospace, sharp, no chrome. .vdl-res stays a column so the // live download tracker docks under it on grab. var meta = [SRC_LABEL[r.source] || r.source || '']; if (r.codec) meta.push(String(r.codec).toUpperCase()); if (r.audio) meta.push(String(r.audio).toUpperCase().replace('-', ' ')); if (r.hdr) meta.push(String(r.hdr).toUpperCase()); if (r.repack) meta.push('REPACK'); meta.push(r.username ? r.username + (r.peers > 1 ? ' (' + r.peers + ')' : '') : (r.seeders || 0) + ' SEED'); if (r.group) meta.push(r.group); meta.push(r.size_gb + ' GB'); var verdict = r.accepted ? '✓ MEETS PROFILE' : '✗ ' + esc((r.rejected || 'FILTERED').toUpperCase()) + ''; var grab = (r.accepted && r.username) ? '' : ''; return '
' + '
' + '
' + '' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '' + '' + esc(r.title) + '' + '
' + '
' + esc(meta.filter(Boolean).join(' · ')) + '
' + '
' + verdict + grab + '
' + '
' + '
'; } // States the result-card tracker shows while a grabbed release downloads. var TRACK_LABEL = { downloading: 'Downloading', queued: 'Queued', searching: 'Finding another release…', completed: 'Downloaded', failed: 'Failed', cancelled: 'Cancelled' }; var TRACK_DONE = { completed: 1, failed: 1, cancelled: 1 }; // Close the modal (if any) and jump to the Downloads page. function gotoDownloads() { if (window.VideoGet && VideoGet.close) VideoGet.close(); document.dispatchEvent(new CustomEvent('soulsync:video-navigate', { detail: 'video-downloads' })); } // After a grab, turn the chosen card into a live tracker: a progress bar that // follows the real download + a button that jumps to the Downloads page. Polls // /downloads/status?id= until the download reaches a terminal state. function beginTracking(card, dlId) { if (!card) return; card.classList.add('vdl-res--grabbed'); var gb = card.querySelector('[data-vdl-grab]'); if (gb) gb.remove(); var main = card.querySelector('.vdl-res-main') || card; var foot = card.querySelector('[data-vdl-track]'); if (!foot) { foot = document.createElement('div'); foot.className = 'vdl-res-track vdl-res-track--active'; foot.setAttribute('data-vdl-track', ''); foot.innerHTML = '
' + 'Starting…' + '' + '' + '
' + '
'; if (main.nextSibling) card.insertBefore(foot, main.nextSibling); else card.appendChild(foot); var go = foot.querySelector('[data-vdl-track-go]'); if (go) go.addEventListener('click', gotoDownloads); } _trackPoll(card, foot, dlId); } function _trackPoll(card, foot, dlId) { if (foot._t) { clearTimeout(foot._t); foot._t = null; } function tick() { if (!card.isConnected) return; // modal closed → stop getJSON('/api/video/downloads/status?id=' + encodeURIComponent(dlId)).then(function (d) { if (!card.isConnected) return; var dl = d && d.download; if (!dl) { foot._t = setTimeout(tick, 2000); return; } var st = dl.status; var pct = Math.max(0, Math.min(100, dl.progress || 0)); if (st === 'completed') pct = 100; var active = !(st in TRACK_DONE); foot.className = 'vdl-res-track vdl-res-track--' + (st === 'completed' ? 'done' : (st === 'failed' || st === 'cancelled' ? 'fail' : 'active')); var fill = foot.querySelector('[data-vdl-track-fill]'); if (fill) fill.style.width = pct + '%'; var pctEl = foot.querySelector('[data-vdl-track-pct]'); if (pctEl) pctEl.textContent = (st === 'downloading' || st === 'queued') ? pct + '%' : ''; var stEl = foot.querySelector('[data-vdl-track-state]'); if (stEl) { var spin = (st === 'downloading' || st === 'queued' || st === 'searching') ? '' : ''; var ic = st === 'completed' ? '✓ ' : (st === 'failed' || st === 'cancelled' ? '✕ ' : ''); stEl.innerHTML = spin + ic + esc(TRACK_LABEL[st] || 'Downloading'); } if (active) foot._t = setTimeout(tick, 1700); }); } tick(); } // ── 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 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: {}, title: d.title || opts.title || '', maxSeason: maxSeason, mediaId: opts.id, mediaSource: opts.source, poster: opts.poster || null, year: d.year || null }; 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); resumeEpisodeTracking(container, st); // light up any in-flight episode rows } 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; var st = container._dl; if (!st) return; var grab = e.target.closest('[data-vdl-grab]'); if (grab) { doGrab(grab); return; } // Episode-scope Manual search (a source row inside an expanded episode). var srch = e.target.closest('[data-vdl-search]'); if (srch) { var epEl = srch.closest('.vdl-ep'); if (!epEl) return; var parts = (epEl.getAttribute('data-vdl-ep') || '').split('_'); var s = srch.getAttribute('data-vdl-search'); var block = srch.closest('[data-vdl-src-block]'); searchInto(container, block.querySelector('[data-vdl-results-for="' + s + '"]'), { scope: 'episode', title: st.title, season: +parts[0], episode: +parts[1], source: s }, [srch.closest('.vdl-src')]); return; } // Episode-scope Auto (search this source, then auto-grab the best) — mirrors the // movie per-source Auto, scoped to the episode. var au = e.target.closest('[data-vdl-auto]'); if (au) { var epA = au.closest('.vdl-ep'); if (!epA) return; var pa = (epA.getAttribute('data-vdl-ep') || '').split('_'); var sa = au.getAttribute('data-vdl-auto'); var resA = au.closest('[data-vdl-src-block]').querySelector('[data-vdl-results-for="' + sa + '"]'); var rowA = au.closest('.vdl-src'); searchInto(container, resA, { scope: 'episode', title: st.title, season: +pa[0], episode: +pa[1], source: sa }, [rowA], function () { _autoPick(resA, rowA); }); return; } // Grab whole season — auto-grab every MISSING episode individually (episode-level). var sg = e.target.closest('[data-vdl-season-grab]'); if (sg) { grabSeason(container, st, +sg.getAttribute('data-vdl-season-grab')); return; } // Season-scope search → season PACK. var ss = e.target.closest('[data-vdl-season-search]'); if (ss) { 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; } 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) { return srcBlockHTML(s, true); }).join('') + '
'; } // ── per-episode live tracking ────────────────────────────────────────────── // Every episode ROW carries its own live download status (Searching → Downloading // % → Downloaded / Failed), so season grabs aren't headless and the modal shows // exactly what each episode is doing — matching the inline movie tracker. function _epEl(container, sn, en) { return container.querySelector('.vdl-ep[data-vdl-ep="' + sn + '_' + en + '"]'); } function epStatusRender(epEl, dl) { var stEl = epEl.querySelector('[data-vdl-ep-status]'); if (!stEl) return; var st = dl.status, pct = Math.max(0, Math.min(100, dl.progress || 0)); if (st === 'completed') pct = 100; epEl.classList.remove('vdl-ep--dl-active', 'vdl-ep--dl-done', 'vdl-ep--dl-fail'); if (st === 'completed') { epEl.classList.add('vdl-ep--dl-done'); stEl.innerHTML = '✓ Downloaded'; } else if (st === 'failed' || st === 'cancelled') { epEl.classList.add('vdl-ep--dl-fail'); stEl.innerHTML = '✗ ' + esc(TRACK_LABEL[st] || 'Failed') + ''; } else { epEl.classList.add('vdl-ep--dl-active'); var label = st === 'downloading' ? ('Downloading ' + pct + '%') : (st === 'searching' ? 'Searching…' : (TRACK_LABEL[st] || 'Queued')); stEl.innerHTML = '' + '' + esc(label) + ''; } } function epSearching(epEl) { if (!epEl) return; epEl.classList.remove('vdl-ep--dl-done', 'vdl-ep--dl-fail'); epEl.classList.add('vdl-ep--dl-active'); var s = epEl.querySelector('[data-vdl-ep-status]'); if (s) s.innerHTML = 'Searching…'; } function epNoRelease(epEl) { if (!epEl) return; epEl.classList.remove('vdl-ep--dl-active'); var s = epEl.querySelector('[data-vdl-ep-status]'); if (s) s.innerHTML = 'No release found'; } // Poll one episode's download by id and paint its row until it finishes. Robust to // the modal closing (stops) and to a newer grab on the same row (id guard). function epTrack(epEl, dlId) { if (!epEl || dlId == null) return; if (epEl._eptimer) { clearTimeout(epEl._eptimer); epEl._eptimer = null; } epEl._epdl = dlId; (function tick() { if (!epEl.isConnected || epEl._epdl !== dlId) return; getJSON('/api/video/downloads/status?id=' + encodeURIComponent(dlId)).then(function (d) { if (!epEl.isConnected || epEl._epdl !== dlId) return; var dl = d && d.download; if (!dl) { epEl._eptimer = setTimeout(tick, 2200); return; } epStatusRender(epEl, dl); if (!(dl.status in TRACK_DONE)) epEl._eptimer = setTimeout(tick, 2000); }); })(); } // Pick the best accepted+grabbable hit in a results panel and grab it. Returns // {ok, id} — the id is what the episode row tracks. (Same payload as a manual grab.) function _pickAndGrab(panel) { var rows = (panel && panel._rows) || []; var best = null; for (var i = 0; i < rows.length; i++) { if (rows[i].accepted && rows[i].username) { best = rows[i]; break; } } if (!best) return Promise.resolve({ ok: false }); return sendGrab(buildGrabPayload(panel, best)).then(function (res) { return (res && res.ok) ? { ok: true, id: res.id } : { ok: false }; }); } // ── Grab whole season (episode level) ────────────────────────────────────── // Run the per-episode auto-grab for every MISSING episode, throttled — each takes // the same path a manual per-episode Auto would, and each ROW shows live status. function ensureScratch(container) { var s = container.querySelector('[data-vdl-grab-scratch]'); if (!s) { s = document.createElement('div'); s.setAttribute('data-vdl-grab-scratch', ''); s.style.display = 'none'; container.appendChild(s); } return s; } function autoGrabEpisode(container, st, sn, en, src) { // Resolves once the search SETTLES (throttle the searches, not the grabs); the // row then tracks the live download itself. return new Promise(function (resolve) { var epEl = _epEl(container, sn, en); epSearching(epEl); var panel = document.createElement('div'); panel.className = 'vdl-results vdl-res-noanim'; ensureScratch(container).appendChild(panel); searchInto(container, panel, { scope: 'episode', title: st.title, season: sn, episode: en, source: src }, [], function () { _pickAndGrab(panel).then(function (r) { if (r.ok) { epTrack(epEl, r.id); document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { epNoRelease(epEl); } resolve(r); }); }); }); } function grabSeason(container, st, sn) { var src = (st.sources || []).filter(function (s) { return SRC_META[s]; })[0]; if (!src) { toast('No download source configured', 'error'); return; } var eps = []; for (var k in st.epMeta) { if (k.indexOf(sn + '_') === 0 && st.epMeta[k].state === 'missing') eps.push(+k.split('_')[1]); } eps.sort(function (a, b) { return a - b; }); if (!eps.length) { toast('No missing episodes in this season', 'info'); return; } // make sure the season is open so the user sees the rows light up var card = container.querySelector('.vdl-season[data-vdl-season="' + sn + '"]'); if (card) card.classList.add('vdl-season--open'); eps.forEach(function (en) { epSearching(_epEl(container, sn, en)); }); // immediate feedback var btn = container.querySelector('[data-vdl-season-grab="' + sn + '"]'); if (btn) { btn.disabled = true; btn.textContent = 'Grabbing…'; } toast('Grabbing ' + eps.length + ' episode' + (eps.length > 1 ? 's' : '') + ' — each row shows live status', 'info'); var idx = 0, active = 0, done = 0, MAX = 3; function pump() { while (active < MAX && idx < eps.length) { active++; autoGrabEpisode(container, st, sn, eps[idx++], src).then(function () { active--; done++; if (done >= eps.length) { if (btn) { btn.disabled = false; btn.textContent = 'Grab season'; } } else pump(); }); } } pump(); } // On (re)open, resume live tracking for any episodes of THIS show already in flight, // so closing/reopening the modal never loses the per-episode progress. function resumeEpisodeTracking(container, st) { getJSON('/api/video/downloads/active').then(function (d) { ((d && d.downloads) || []).forEach(function (dl) { var ctx = dl.search_ctx; if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (e) { ctx = null; } } ctx = ctx || {}; if (String(ctx.title || dl.title) !== String(st.title)) return; if (ctx.season == null || ctx.episode == null) return; var epEl = _epEl(container, ctx.season, ctx.episode); if (epEl && epEl._epdl == null) { epStatusRender(epEl, dl); epTrack(epEl, dl.id); } }); }); } 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 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; } } window.VideoDownload = { render: render }; })();