/* * SoulSync β€” Video Downloads page. * * Reuses the music downloads page's .adl-* layout + look (full-width, segmented * filter pills, compact rows, status dots) for visual parity, driven by video data * via data-vdpg-* hooks. Filter tabs, per-row cancel + retry, cancel-all, clear. * Rows are created ONCE and patched in place so progress glides and nothing blinks. */ (function () { 'use strict'; var URL_ACTIVE = '/api/video/downloads/active'; var URL_CLEAR = '/api/video/downloads/clear'; var URL_CANCEL = '/api/video/downloads/cancel'; var URL_RETRY = '/api/video/downloads/retry'; var _timer = null, _wired = false, _filter = 'all'; var _cards = {}; var _expanded = {}; // id -> true while a card's detail drawer is open (survives re-patches) var _meta = {}; // id -> TMDB detail (overview/cast) once lazily fetched (null = in flight) function esc(s) { return String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function toast(m, t) { if (typeof showToast === 'function') showToast(m, t); } function setDownloadsBadge(n) { var b = document.querySelector('[data-video-downloads-badge]'); if (!b) return; if (n > 0) { b.textContent = n > 99 ? '99+' : n; b.classList.remove('hidden'); } else { b.classList.add('hidden'); } } function getJSON(u) { return fetch(u, { headers: { Accept: 'application/json' } }) .then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } function postJSON(u, b) { return fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(b || {}) }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }); } var KIND_ICON = { movie: '🎬', show: 'πŸ“Ί', episode: 'πŸ“Ί', season: 'πŸ“Ί', series: 'πŸ“Ί', youtube: '▢️' }; // collapse the kinds into the three colour groups (Cinema palette in CSS): movie / tv / youtube function dlType(kind) { var k = (kind || '').toLowerCase(); return k === 'youtube' ? 'youtube' : (k === 'movie' ? 'movie' : 'tv'); } // status -> { label, cls } where cls is the music .adl-row-/.adl-status-dot class var STATUS = { downloading: { label: 'Downloading', cls: 'active' }, queued: { label: 'Queued', cls: 'queued' }, searching: { label: 'Searching', cls: 'active' }, // retrying β€” finding another release importing: { label: 'Importing', cls: 'active' }, // post-processing β†’ moving into library completed: { label: 'Completed', cls: 'completed' }, failed: { label: 'Failed', cls: 'failed' }, import_failed: { label: 'Import failed', cls: 'failed' }, cancelled: { label: 'Cancelled', cls: 'cancelled' } }; function isActive(s) { return s === 'downloading' || s === 'queued' || s === 'searching' || s === 'importing'; } function isFail(s) { return s === 'failed' || s === 'cancelled' || s === 'import_failed'; } function matches(s) { return _filter === 'all' || (_filter === 'active' && isActive(s)) || (_filter === 'completed' && s === 'completed') || (_filter === 'failed' && isFail(s)); } function fmtSize(bytes) { var gb = (bytes || 0) / (1024 * 1024 * 1024); return gb >= 0.1 ? (Math.round(gb * 10) / 10) + ' GB' : Math.round((bytes || 0) / (1024 * 1024)) + ' MB'; } var X_SVG = ''; var R_SVG = ''; var OPEN_SVG = ''; function makeCard(d) { var el = document.createElement('div'); el.className = 'vdpg-card adl-row'; el.setAttribute('data-dl-id', d.id); el.innerHTML = '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + '
' + '
' + ''; return el; } function patchCard(el, d) { el._d = d; // remember the row data so the expand toggle can re-render its drawer var info = STATUS[d.status] || STATUS.downloading; var cls = info.cls, active = isActive(d.status); var showBar = active; // downloading/queued/searching/importing all get a bar // queued/searching/importing have no real % β†’ an indeterminate shimmer (not "frozen at 0/100") var indet = d.status === 'queued' || d.status === 'searching' || d.status === 'importing'; var pct = Math.max(0, Math.min(100, d.progress || 0)); var q = function (f) { return el.querySelector('[data-f="' + f + '"]'); }; var vt = dlType(d.kind); if (el.getAttribute('data-vtype') !== vt) el.setAttribute('data-vtype', vt); var want = 'vdpg-card adl-row adl-row-' + cls; if (el.className !== want) el.className = want; // type badge on the art corner (so you can tell movie / TV / youtube even with a poster) var tb = q('tbadge'); if (tb) { var tbi = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬'; if (tb.textContent !== tbi) tb.textContent = tbi; } // poster art tile (falls back to the kind emoji) var ic = q('ic'); if (d.poster_url) { if (ic._p !== d.poster_url) { ic._p = d.poster_url; ic.style.backgroundImage = "url('" + d.poster_url + "')"; } ic.classList.add('vdpg-has-poster'); ic.textContent = ''; } else { ic.classList.remove('vdpg-has-poster'); if (ic._p) { ic.style.backgroundImage = ''; ic._p = null; } var icon = KIND_ICON[(d.kind || '').toLowerCase()] || '🎬'; if (ic.textContent !== icon) ic.textContent = icon; } var name = (d.title || d.release_title || 'Download') + (d.year ? ' (' + d.year + ')' : ''); var nm = q('name'); if (nm.textContent !== name) nm.textContent = name; // meta: quality chip + a context line (release / sizeΒ·userΒ·pct / dest) var ctx; if (d.status === 'completed' && d.dest_path) ctx = 'β†’ ' + d.dest_path; else if (d.status === 'searching') ctx = 'Trying another release…'; else if (d.status === 'importing') ctx = 'Moving into your library…'; else if (d.status === 'queued') ctx = 'Waiting for a free slot…'; else if (showBar) ctx = [fmtSize(d.size_bytes), d.username ? ('πŸ‘€ ' + d.username) : '', Math.round(pct) + '%'].filter(Boolean).join(' Β· '); else ctx = (d.release_title && d.release_title !== (d.title || '')) ? d.release_title : fmtSize(d.size_bytes); var chip = d.quality_label ? '' + esc(d.quality_label) + '' : ''; var metaHTML = chip + '' + esc(ctx) + ''; var mt = q('meta'); if (mt.innerHTML !== metaHTML) mt.innerHTML = metaHTML; var err = q('error'); var errTxt = isFail(d.status) && d.error ? d.error : ''; if (err.textContent !== errTxt) err.textContent = errTxt; err.style.display = errTxt ? '' : 'none'; var bar = q('bar'); bar.style.display = showBar ? '' : 'none'; if (showBar) { bar.classList.toggle('vdpg-prog-indet', indet); q('fill').style.width = indet ? '100%' : pct + '%'; } var st = q('status'); var stWant = 'adl-row-status ' + cls; if (st.className !== stWant) st.className = stWant; var dot = q('dot'); var dotWant = 'adl-status-dot ' + cls; if (dot.className !== dotWant) dot.className = dotWant; var labelTxt = info.label + (d.attempts > 1 ? ' Β· ' + d.attempts + 'x' : ''); var lab = q('label'); if (lab.textContent !== labelTxt) lab.textContent = labelTxt; var act = q('actions'); // youtube videos aren't TMDB titles. Open the in-app CHANNEL page (channels-as-shows) // when we know the channel id; fall back to the YouTube video link otherwise. (The old // open-show button ran parseInt on the video id β†’ opened a random library show.) var ytCh = dlType(d.kind) === 'youtube' ? parseCtx(d).channel_id : null; var openBtn = !d.media_id ? '' : (dlType(d.kind) === 'youtube' ? (ytCh ? '' : '' + OPEN_SVG + '') : ''); var stateBtn = active ? '' : isFail(d.status) ? '' : ''; var actHTML = openBtn + stateBtn; if (act.innerHTML !== actHTML) act.innerHTML = actHTML; renderDrawer(el, d); // keep the expand drawer in sync (and open across re-patches) } // ── expand drawer ───────────────────────────────────────────────────────────── function parseCtx(d) { // the download's search_ctx (peer/season/episode/channel/…) try { return d.search_ctx ? (typeof d.search_ctx === 'string' ? JSON.parse(d.search_ctx) : d.search_ctx) : {}; } catch (e) { return {}; } } function fact(k, v) { return v ? '
' + esc(k) + '' + esc(v) + '
' : ''; } function fmtRuntime(m) { m = parseInt(m, 10); if (!m) return ''; var h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? ' ' + mm + 'm' : '')) : (mm + 'm'); } function fmtViews(n) { n = +n || 0; return n >= 1e6 ? (Math.round(n / 1e5) / 10 + 'M') : n >= 1e3 ? (Math.round(n / 100) / 10 + 'K') : String(n); } function fmtSpeed(bps) { bps = +bps || 0; if (!bps) return ''; return bps >= 1e6 ? (Math.round(bps / 1e5) / 10 + ' MB/s') : Math.max(1, Math.round(bps / 1e3)) + ' KB/s'; } function pad2(n) { n = parseInt(n, 10) || 0; return (n < 10 ? '0' : '') + n; } function castHTMLOf(meta) { var cast = (meta.cast || []).slice(0, 8); return cast.length ? '
Cast
' + cast.map(function (c) { var pic = c.photo ? '' : '' + esc((c.name || '?').charAt(0)) + ''; return '
' + pic + '' + esc(c.name) + '' + (c.character ? '' + esc(c.character) + '' : '') + '
'; }).join('') + '
' : ''; } function drawerHTML(d, meta) { var isYt = dlType(d.kind) === 'youtube', ctx = parseCtx(d); var loading = meta === null; meta = meta || {}; var back = '', head = '', lead = '', extra = ''; if (isYt) { // big thumbnail + channel Β· duration Β· views Β· upload date, then the description var thumb = meta.thumbnail_url || d.poster_url; var yb = []; if (ctx.channel || ctx.channel_title) yb.push(esc(ctx.channel || ctx.channel_title)); if (meta.duration) yb.push(esc(meta.duration)); if (meta.view_count) yb.push(fmtViews(meta.view_count) + ' views'); if (ctx.published_at) yb.push(esc(String(ctx.published_at).slice(0, 10))); head = '
' + (thumb ? '
' : '') + '
' + esc(d.title || meta.title || 'Video') + '
' + (yb.length ? '
' + yb.join(' Β· ') + '
' : '') + '
'; lead = ctx.description ? '

' + esc(ctx.description) + '

' : ''; } else { back = meta.backdrop_url ? '
' : ''; var titleHTML = meta.logo ? '' : '
' + esc(meta.title || d.title || 'Download') + '
'; var bits = []; if (meta.year || d.year) bits.push(esc(meta.year || d.year)); if (meta.rating) bits.push('⭐ ' + (Math.round(meta.rating * 10) / 10)); var rt = fmtRuntime(meta.runtime_minutes); if (rt) bits.push(rt); if (meta.network || meta.studio) bits.push(esc(meta.network || meta.studio)); if (meta.status) bits.push(esc(meta.status)); var tagline = meta.tagline ? '
' + esc(meta.tagline) + '
' : ''; head = '
' + titleHTML + (bits.length ? '
' + bits.join(' Β· ') + '
' : '') + tagline + '
'; var ep = meta.episode; if (ep) { // the SPECIFIC episode: still + SxE Β· air date + episode title + its own synopsis lead = '
' + (ep.still_url ? '
' : '') + '
S' + pad2(ep.season) + 'E' + pad2(ep.episode) + (ep.air_date ? ' Β· ' + esc(ep.air_date) : '') + '
' + '
' + esc(ep.title || '') + '
' + (ep.overview ? '

' + esc(ep.overview) + '

' : '') + '
'; } else { lead = loading ? '

Loading…

' : (meta.overview ? '

' + esc(meta.overview) + '

' : '

No synopsis available.

'); } var genres = (meta.genres || []).slice(0, 4).join(' Β· '); var watch = ''; if (meta.trailer_url) watch += 'β–Ά Trailer'; var provs = meta.providers || []; if (provs.length) watch += 'Watch on' + provs.map(function (p) { return p.logo ? '' + esc(p.name || '') + '' : '' + esc(p.name || '') + ''; }).join('') + ''; extra = (genres ? '
' + esc(genres) + '
' : '') + (watch ? '
' + watch + '
' : '') + castHTMLOf(meta); } // download facts (only the fields that exist render) var facts = ''; facts += fact('Status', (STATUS[d.status] || {}).label); if (isYt) { facts += fact('Channel', ctx.channel || ctx.channel_title); facts += fact('Quality', d.quality_label); } else { facts += fact(dlType(d.kind) === 'movie' ? 'Director' : 'Creator', meta.director); facts += fact('Quality target', d.quality_label); facts += fact('Release', d.release_title); facts += fact('Format', [d.resolution, d.source, d.codec].filter(Boolean).join(' Β· ')); facts += fact('Source', d.username ? ('πŸ‘€ ' + d.username) : ''); if (ctx.peer) { // the chosen source's availability snapshot at grab time var p = ctx.peer, av = []; if (p.slots != null) av.push(p.slots > 0 ? 'βœ“ free slot' : 'no free slot'); if (p.queue != null) av.push('queue ' + p.queue); var sp = fmtSpeed(p.speed); if (sp) av.push(sp); facts += fact('Availability', av.join(' Β· ')); } } facts += fact('Size', d.size_bytes ? fmtSize(d.size_bytes) : ''); facts += fact('Attempts', d.attempts > 1 ? (d.attempts + 'x') : ''); if (d.dest_path) facts += '
Path' + '' + esc(d.dest_path) + '' + '
'; if (isFail(d.status) && d.error) facts += '
Error' + esc(d.error) + '
'; var btns = []; if (d.media_id && !isYt) btns.push(''); var ytCh = meta.channel_id || ctx.channel_id; if (isYt && ytCh) btns.push(''); if (isYt && d.media_id) btns.push('Open on YouTube'); if (isActive(d.status)) btns.push(''); else if (isFail(d.status)) btns.push(''); var actions = btns.length ? '
' + btns.join('') + '
' : ''; return back + '
' + head + lead + extra + '
Download
' + facts + '
' + actions + '
'; } function metaURL(d) { var t = dlType(d.kind); if (t === 'youtube') return '/api/video/downloads/yt-meta/' + encodeURIComponent(d.media_id); if (d.media_source === 'library') return null; // owned re-grab: media_id isn't a tmdb id var url = '/api/video/downloads/meta/' + (t === 'movie' ? 'movie' : 'show') + '/' + encodeURIComponent(d.media_id); if (d.kind === 'episode') { var c = parseCtx(d); if (c.season != null && c.episode != null) url += '?season=' + encodeURIComponent(c.season) + '&episode=' + encodeURIComponent(c.episode); } return url; } function renderDrawer(el, d) { var dr = el.querySelector('[data-f="drawer"]'); if (!dr) return; var open = !!_expanded[d.id]; el.classList.toggle('vdpg-card-open', open); dr.hidden = !open; if (!open) { dr.innerHTML = ''; return; } // kick off the lazy detail fetch (TMDB for movie/TV, cached metadata for youtube) so the // first paint already shows 'Loading…' rather than 'no synopsis' flashing before content. if (_meta[d.id] === undefined && d.media_id) { var url = metaURL(d); if (!url) { _meta[d.id] = {}; } else { _meta[d.id] = null; getJSON(url).then(function (m) { _meta[d.id] = m || {}; if (_expanded[d.id]) { var dr2 = el.querySelector('[data-f="drawer"]'); if (dr2) dr2.innerHTML = drawerHTML(d, _meta[d.id]); } }); } } dr.innerHTML = drawerHTML(d, _meta[d.id]); } function render(list) { var host = document.querySelector('[data-vdpg-list]'); if (!host) return; list = list || []; var empty = host.querySelector('[data-vdpg-empty]'); var counts = { all: list.length, active: 0, completed: 0, failed: 0 }; list.forEach(function (d) { if (isActive(d.status)) counts.active++; else if (d.status === 'completed') counts.completed++; else counts.failed++; }); setDownloadsBadge(counts.active); // sidebar live count (this page's poll keeps it fresh) var cancelAll = document.querySelector('[data-vdpg-cancel-all]'); if (cancelAll) cancelAll.style.display = counts.active ? '' : 'none'; var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.style.display = (counts.completed + counts.failed) ? '' : 'none'; var sub = document.querySelector('[data-vdpg-sub]'); if (sub) { var parts = []; if (counts.active) parts.push(counts.active + ' active'); if (counts.completed) parts.push(counts.completed + ' done'); if (counts.failed) parts.push(counts.failed + ' failed'); sub.textContent = parts.join(' Β· '); } var seen = {}, shown = 0; list.forEach(function (d) { seen[d.id] = true; var el = _cards[d.id] || (_cards[d.id] = makeCard(d)); patchCard(el, d); var vis = matches(d.status); el.style.display = vis ? '' : 'none'; if (vis) shown++; host.appendChild(el); // keep order = server order (active first); no re-anim }); Object.keys(_cards).forEach(function (id) { if (!seen[id]) { var e = _cards[id]; if (e && e.parentNode) e.parentNode.removeChild(e); delete _cards[id]; } }); if (empty) { host.appendChild(empty); // keep the empty element last empty.style.display = shown === 0 ? '' : 'none'; empty.textContent = !list.length ? "No downloads yet. Hit Grab on a search result and it'll show up here." : 'Nothing ' + (_filter === 'all' ? 'here' : _filter) + ' right now.'; } } function setFilter(f) { _filter = f; Array.prototype.forEach.call(document.querySelectorAll('[data-vdpg-filter]'), function (b) { b.classList.toggle('active', b.getAttribute('data-vdpg-filter') === f); }); getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); }); } function anyActive() { return !!document.querySelector('.adl-row.adl-row-active, .adl-row.adl-row-queued'); } // Only poll while the Downloads page is actually on screen β€” not in the background // and not after switching to the music side (where the page-change event never fires). function _onPage() { return document.body.getAttribute('data-side') === 'video' && !!document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])'); } function poll() { if (!_onPage()) { stop(); return; } getJSON(URL_ACTIVE).then(function (d) { if (d) render(d.downloads || []); schedule(); }); } function schedule() { if (_timer) clearTimeout(_timer); _timer = setTimeout(poll, anyActive() ? 2000 : 6000); } function start() { wire(); if (_timer) clearTimeout(_timer); poll(); } function stop() { if (_timer) { clearTimeout(_timer); _timer = null; } } function wire() { if (_wired) return; _wired = true; var clearBtn = document.querySelector('[data-vdpg-clear]'); if (clearBtn) clearBtn.addEventListener('click', function () { postJSON(URL_CLEAR, {}).then(function () { toast('Cleared finished downloads', 'success'); poll(); }); }); var cancelAll = document.querySelector('[data-vdpg-cancel-all]'); if (cancelAll) cancelAll.addEventListener('click', function () { getJSON(URL_ACTIVE).then(function (d) { var ids = ((d && d.downloads) || []).filter(function (x) { return isActive(x.status); }).map(function (x) { return x.id; }); Promise.all(ids.map(function (id) { return postJSON(URL_CANCEL, { id: id }); })) .then(function () { toast('Cancelled ' + ids.length + ' download' + (ids.length === 1 ? '' : 's'), 'info'); poll(); }); }); }); var pills = document.querySelector('[data-vdpg-pills]'); if (pills) pills.addEventListener('click', function (e) { var b = e.target.closest('[data-vdpg-filter]'); if (b) setFilter(b.getAttribute('data-vdpg-filter')); }); var list = document.querySelector('[data-vdpg-list]'); if (list) list.addEventListener('click', function (e) { var op = e.target.closest('[data-vdpg-open]'); if (op) { var kind = op.getAttribute('data-kind') === 'movie' ? 'movie' : 'show'; var id = op.getAttribute('data-vdpg-open'); document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: kind, id: parseInt(id, 10) || id, source: op.getAttribute('data-source') || 'library' } })); return; } var och = e.target.closest('[data-vdpg-open-channel]'); if (och) { document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { detail: { kind: 'channel', source: 'youtube', id: och.getAttribute('data-vdpg-open-channel') } })); return; } var cp = e.target.closest('[data-vdpg-copy]'); if (cp) { var path = cp.getAttribute('data-vdpg-copy'); if (navigator.clipboard) navigator.clipboard.writeText(path).then(function () { toast('Path copied', 'success'); }, function () {}); else toast('Copy not supported here', 'info'); return; } var c = e.target.closest('[data-vdpg-cancel]'); if (c) { c.disabled = true; c.classList.add('adl-row-cancel-pending'); postJSON(URL_CANCEL, { id: +c.getAttribute('data-vdpg-cancel') }).then(function () { poll(); }); return; } var r = e.target.closest('[data-vdpg-retry]'); if (r) { r.disabled = true; postJSON(URL_RETRY, { id: +r.getAttribute('data-vdpg-retry') }).then(function (res) { if (res && res.ok) toast('Retrying', 'info'); else toast((res && res.error) || 'Retry failed', 'error'); poll(); }); return; } // click anywhere on the row (but not the drawer body or a control) β†’ toggle the detail drawer if (e.target.closest('[data-f="drawer"]') || e.target.closest('button, a')) return; var card = e.target.closest('.adl-row[data-dl-id]'); if (card && card._d) { var cid = card.getAttribute('data-dl-id'); _expanded[cid] = !_expanded[cid]; renderDrawer(card, card._d); } }); } document.addEventListener('soulsync:video-page-shown', function (e) { if (e.detail === 'video-downloads') start(); else stop(); }); document.addEventListener('soulsync:video-download-started', function () { if (document.querySelector('[data-video-subpage="video-downloads"]:not([hidden])')) setTimeout(poll, 350); }); // Keep the sidebar Downloads badge live even when you're NOT on the page (the on-page // poll already refreshes it, so skip the fetch then). Only runs on the video side. var _badgeTimer = null; function badgePoll() { var onVideo = document.body.getAttribute('data-side') === 'video'; if (onVideo && !_onPage() && !document.hidden) { getJSON(URL_ACTIVE).then(function (d) { if (d) setDownloadsBadge((d.downloads || []).filter(function (x) { return isActive(x.status); }).length); scheduleBadgePoll(); }); } else { scheduleBadgePoll(); } } function scheduleBadgePoll() { if (_badgeTimer) clearTimeout(_badgeTimer); _badgeTimer = setTimeout(badgePoll, 8000); } document.addEventListener('soulsync:video-wishlist-changed', function () { setTimeout(badgePoll, 200); }); scheduleBadgePoll(); window._vdpgAnyActive = anyActive; })();