/* * 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) { var o = container._opts || {}; var s = block.getAttribute('data-vdl-src-block'); searchInto(container, block.querySelector('[data-vdl-results-for="' + s + '"]'), { scope: 'movie', title: o.title || '', year: o.year || null, source: s }, [block.querySelector('.vdl-src')]); } function onClick(e) { var container = e.currentTarget; var grab = e.target.closest('[data-vdl-grab]'); if (grab) { doGrab(grab); return; } var sb = e.target.closest('[data-vdl-search]'); if (sb) { _movieSearch(container, sb.closest('[data-vdl-src-block]')); return; } if (e.target.closest('[data-vdl-search-all]')) { Array.prototype.forEach.call(container.querySelectorAll('[data-vdl-src-block]'), function (block) { _movieSearch(container, block); }); } } // 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); } } 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 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) { if (!resultsEl) return; triggerRows = (triggerRows || []).filter(Boolean); if (resultsEl._poll) { clearTimeout(resultsEl._poll); resultsEl._poll = null; } _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); return; } _pollSearch(resultsEl, params, d.id, triggerRows, d.poll_ms); }); } function _pollSearch(resultsEl, params, id, triggerRows, pollMs) { // 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; } else { resultsEl._poll = setTimeout(tick, 1500); } }); } tick(); } // 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 r = panel._rows[parseInt(btn.getAttribute('data-vdl-grab'), 10)]; if (!r) return; var p = panel._search || {}; btn.disabled = true; btn.classList.add('vdl-res-grab--busy'); btn.textContent = '…'; 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 }; }); postJSON('/api/video/downloads/grab', { 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 } }).then(function (res) { btn.classList.remove('vdl-res-grab--busy'); if (res && res.ok) { btn.textContent = '✓'; btn.classList.add('vdl-res-grab--done'); toast('Sent to Downloads', 'success'); document.dispatchEvent(new CustomEvent('soulsync:video-download-started')); } else { btn.disabled = false; btn.textContent = '⤓'; toast((res && res.error) || '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'; } // Readable card: a big resolution tile anchors it, a plain-English quality summary // leads, the raw release name is demoted to a muted one-liner, then size/seeders. function resultCardHTML(r, i) { var summary = [SRC_LABEL[r.source] || r.source, r.codec ? String(r.codec).toUpperCase() : '', r.audio ? String(r.audio).toUpperCase().replace('-', ' ') : ''].filter(Boolean).join(' · '); var tags = ''; if (r.hdr) tags += '' + esc(String(r.hdr).toUpperCase()) + ''; if (r.repack) tags += 'REPACK'; var verdict = r.accepted ? '✓ Meets profile' : '✕ ' + esc(r.rejected || 'Filtered') + ''; return '
' + '
' + esc(RES_LABEL[r.resolution] || r.resolution || '?') + '
' + '
' + '
' + '' + esc(summary) + '' + tags + verdict + '
' + '
' + esc(r.title) + '
' + '
' + '💾' + r.size_gb + ' GB' + resAvailHTML(r) + (r.group ? '' + esc(r.group) + '' : '') + '
' + '
' + (r.accepted && r.username ? '' : '') + '
'; } // ── 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); } 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 search (a source row inside an expanded episode → its own panel). 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; } // 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('') + '
'; } 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 }; })();