After a grab (manual or Auto) the user now SEES what happened and can follow it: - The chosen result card is spotlighted (Auto scrolls it into view) and grows a live tracker: a progress bar that follows the real download + a 'Track on Downloads ↗' button that closes the modal and jumps to the Downloads page. Polls the new GET /api/video/downloads/status?id= until the download reaches a terminal state. - A movie's detail page shows a live download chip (progress bar + %) for any in-flight download of that title; clicking it jumps to Downloads. Looks up by media identity via /downloads/status?media_id=&media_source=, polls while active, clears on navigate-away. (video-detail.js) - Result CARDS redesigned (the part you didn't like): a column card with a colour resolution badge, a green 'accepted' edge, cleaner hierarchy, and the grab button is now an accent 'Get' pill matching the source Auto button language. Plumbing: new lightweight /downloads/status endpoint (by id, or by media for detail pages); soulsync:video-navigate event in video-side.js to reach a top-level page from anywhere; VideoGet.close exported so the tracker can dismiss the modal. All video-only. Tests: status endpoint (by id + by media + null cases) in test_video_api.py; tracking/detail/nav wiring in test_video_download_tracking.py. node --check clean; isolation guards green.
654 lines
37 KiB
JavaScript
654 lines
37 KiB
JavaScript
/*
|
||
* SoulSync — Video "get" button + detail/download modal (shared).
|
||
*
|
||
* The terminal-content counterpart to the watchlist eye: movies and ENDED shows
|
||
* can't be "watched for new episodes," so instead of an eye they get a download
|
||
* symbol that opens a rich detail modal — the future home of "Add to Wishlist"
|
||
* / "Download". v1 is VISUAL ONLY: the modal renders real detail data, but the
|
||
* action buttons are stubs (no backend wiring yet).
|
||
*
|
||
* Renderers call VideoGet.btn({kind, source, openId, title}); VideoGet.isAiring()
|
||
* is the shared status test that decides eye-vs-get. Self-contained.
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
function toast(msg, type) { if (typeof showToast === 'function') showToast(msg, type); }
|
||
// A stable per-title hue so each modal glows in its own colour (the "vibe").
|
||
function hueOf(s) { var h = 0, t = String(s || ''); for (var i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0; return h % 360; }
|
||
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();
|
||
}
|
||
|
||
// A show is "airing" (eye) unless its status says it's finished (get-symbol).
|
||
function isAiring(status) {
|
||
var s = String(status == null ? '' : status).trim().toLowerCase();
|
||
if (!s) return false; // unknown status → treat as terminal (get), not watch
|
||
return ['ended', 'canceled', 'cancelled', 'completed'].indexOf(s) === -1;
|
||
}
|
||
|
||
function dlSvg() {
|
||
return '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
||
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' +
|
||
'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>';
|
||
}
|
||
|
||
function btn(opts) {
|
||
if (!opts || !opts.openId) return '';
|
||
var kind = opts.kind === 'movie' ? 'movie' : 'show';
|
||
return '<button type="button" class="vget-btn"' +
|
||
' data-vget-kind="' + kind + '" data-vget-source="' + esc(opts.source || 'library') + '"' +
|
||
' data-vget-id="' + esc(opts.openId) + '" data-vget-title="' + esc(opts.title || '') + '"' +
|
||
' title="Get this ' + kind + '" aria-label="Get this ' + kind + '">' + dlSvg() + '</button>';
|
||
}
|
||
|
||
// ── modal ─────────────────────────────────────────────────────────────────
|
||
var modalEl = null, keyHandler = null;
|
||
|
||
function closeModal() {
|
||
if (!modalEl) return;
|
||
modalEl.classList.remove('vgm-open');
|
||
document.body.style.removeProperty('overflow');
|
||
if (keyHandler) { document.removeEventListener('keydown', keyHandler); keyHandler = null; }
|
||
var el = modalEl; modalEl = null;
|
||
setTimeout(function () { if (el && el.parentNode) el.parentNode.removeChild(el); }, 220);
|
||
}
|
||
|
||
// ── download view (in-place, same modal) ──────────────────────────────────
|
||
// Clicking "Download" swaps the detail body for the download view (target +
|
||
// owned verdict + per-source search) without leaving the modal; Back restores
|
||
// it. Selection-only sections collapse; the all-related movie bits stay.
|
||
var DL_HIDE = ['[data-vgm-eps]', '[data-vgm-next]', '[data-vgm-follow]'];
|
||
|
||
function setDownloadMode(ov, on) {
|
||
DL_HIDE.forEach(function (sel) {
|
||
var el = ov.querySelector(sel); if (!el) return;
|
||
if (on) { el.setAttribute('data-vgm-washidden', el.hidden ? '1' : '0'); el.hidden = true; }
|
||
else { el.hidden = el.getAttribute('data-vgm-washidden') === '1'; el.removeAttribute('data-vgm-washidden'); }
|
||
});
|
||
var act = ov.querySelector('.vgm-actions'); if (act) act.hidden = on;
|
||
ov.classList.toggle('vgm-mode-download', on);
|
||
}
|
||
|
||
function enterDownload(ov, o) {
|
||
var dl = ov.querySelector('[data-vgm-dl]');
|
||
var content = ov.querySelector('[data-vgm-dl-content]');
|
||
if (!dl || !content || !window.VideoDownload) { toast('Download module not loaded', 'error'); return; }
|
||
if (o.kind === 'show') {
|
||
// Shows get a wider modal + a season/episode picker (not the movie layout).
|
||
VideoDownload.render(content, { kind: 'show', id: o.id, source: o.source || 'library',
|
||
detail: (modalState && modalState._detail) || null, tvId: (modalState && modalState._tvId) || null,
|
||
poster: (modalState && modalState.poster) || null });
|
||
ov.classList.add('vgm-mode-dl-show');
|
||
} else {
|
||
var file = (modalState && modalState.kind === 'movie' && modalState.owned) ? (modalState.file || null) : null;
|
||
VideoDownload.render(content, { kind: o.kind, id: o.id, source: o.source || 'library', isYt: false, file: file,
|
||
title: (modalState && modalState.title) || o.title || '', year: (modalState && modalState.year) || null,
|
||
poster: (modalState && modalState.poster) || null });
|
||
}
|
||
setDownloadMode(ov, true);
|
||
dl.hidden = false;
|
||
var modal = ov.querySelector('.vgm-modal'); if (modal) modal.scrollTop = 0;
|
||
}
|
||
|
||
function exitDownload(ov) {
|
||
var dl = ov.querySelector('[data-vgm-dl]'); if (dl) dl.hidden = true;
|
||
setDownloadMode(ov, false);
|
||
ov.classList.remove('vgm-mode-dl-show');
|
||
}
|
||
|
||
// ── wishlist / watchlist writes ───────────────────────────────────────────
|
||
function postJSON(url, body) {
|
||
return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body) }).then(function (r) { return r.ok ? r.json() : null; });
|
||
}
|
||
// Build the wishlist payload from the modal's selection and POST it; for a
|
||
// show, optionally also follow it (the "Add to watchlist" tick).
|
||
function submitWishlist(ov) {
|
||
if (!modalState || !modalState.tmdbId) { toast('Missing title info', 'error'); return; }
|
||
var btn = ov.querySelector('[data-vgm-wishlist]');
|
||
if (btn) btn.disabled = true;
|
||
var fail = function () { if (btn) btn.disabled = false; toast('Could not add to wishlist', 'error'); };
|
||
var done = function (msg) {
|
||
toast(msg, 'success');
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
|
||
closeModal();
|
||
};
|
||
|
||
if (modalState.kind === 'movie') {
|
||
postJSON('/api/video/wishlist/add', { movie: { tmdb_id: modalState.tmdbId, title: modalState.title,
|
||
year: modalState.year, poster_url: modalState.poster, library_id: modalState.libraryId } })
|
||
.then(function (d) { if (d && d.success) done(modalState.owned ? 'Queued for re-download' : 'Added to wishlist'); else fail(); })
|
||
.catch(fail);
|
||
return;
|
||
}
|
||
|
||
// show: turn the selected "S_E" keys into episode rows
|
||
var eps = [];
|
||
modalState.sel.forEach(function (key) {
|
||
var p = key.split('_'), m = (modalState.epMeta || {})[key] || {};
|
||
eps.push({ season_number: parseInt(p[0], 10), episode_number: parseInt(p[1], 10),
|
||
title: m.title, air_date: m.air_date, still_url: m.still, overview: m.overview,
|
||
season_poster_url: (modalState.seasonPoster || {})[p[0]] });
|
||
});
|
||
if (!eps.length) { if (btn) btn.disabled = false; toast('Select at least one episode', 'info'); return; }
|
||
|
||
var aw = ov.querySelector('[data-vgm-add-watch]');
|
||
var alsoWatch = !!(aw && aw.checked);
|
||
if (alsoWatch) {
|
||
postJSON('/api/video/watchlist/add', { kind: 'show', tmdb_id: modalState.tmdbId, title: modalState.title,
|
||
poster_url: modalState.poster, library_id: modalState.libraryId })
|
||
.then(function () { document.dispatchEvent(new CustomEvent('soulsync:video-watchlist-changed')); })
|
||
.catch(function () { /* best-effort */ });
|
||
}
|
||
postJSON('/api/video/wishlist/add', { show: { tmdb_id: modalState.tmdbId, title: modalState.title,
|
||
poster_url: modalState.poster, library_id: modalState.libraryId }, episodes: eps })
|
||
.then(function (d) {
|
||
if (!d || !d.success) return fail();
|
||
done(eps.length + ' episode' + (eps.length === 1 ? '' : 's') + ' added' + (alsoWatch ? ' · now watching' : ''));
|
||
})
|
||
.catch(fail);
|
||
}
|
||
|
||
var modalState = null; // { kind, sel:Set<key> } for the open show modal
|
||
|
||
function isoToday() {
|
||
var n = new Date();
|
||
return n.getFullYear() + '-' + ('0' + (n.getMonth() + 1)).slice(-2) + '-' + ('0' + n.getDate()).slice(-2);
|
||
}
|
||
var MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
function fmtDate(iso) {
|
||
var p = String(iso || '').split('-');
|
||
if (p.length < 3) return '';
|
||
return MO[(+p[1] || 1) - 1] + ' ' + (+p[2] || 1) + ', ' + p[0];
|
||
}
|
||
// owned (have it) | upcoming (airs in the future) | missing (aired, not owned)
|
||
function epState(e, today) {
|
||
if (e.owned) return 'owned';
|
||
if (e.air_date && e.air_date > today) return 'upcoming';
|
||
return 'missing';
|
||
}
|
||
|
||
function openModal(o) {
|
||
closeModal();
|
||
var ov = document.createElement('div');
|
||
ov.className = 'vgm-overlay';
|
||
ov.style.setProperty('--vgm-h', hueOf(o.title || '')); // vibe colour (refined in fill)
|
||
ov.innerHTML =
|
||
'<div class="vgm-modal" role="dialog" aria-modal="true">' +
|
||
'<button class="vgm-close" type="button" data-vgm-close aria-label="Close">×</button>' +
|
||
'<div class="vgm-hero" data-vgm-hero>' +
|
||
'<div class="vgm-hero-scrim"></div>' +
|
||
'<img class="vgm-poster" data-vgm-poster alt="" hidden>' +
|
||
'<div class="vgm-hero-content">' +
|
||
'<div class="vgm-eyebrow" data-vgm-eyebrow></div>' +
|
||
'<h2 class="vgm-title" data-vgm-title>' + esc(o.title || 'Loading…') + '</h2>' +
|
||
'<div class="vgm-meta" data-vgm-meta></div>' +
|
||
'<div class="vgm-genres" data-vgm-genres></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="vgm-body">' +
|
||
'<div class="vgm-ratings" data-vgm-ratings hidden></div>' +
|
||
'<p class="vgm-overview" data-vgm-overview>Loading details…</p>' +
|
||
'<div class="vgm-owned" data-vgm-owned hidden></div>' +
|
||
'<div class="vgm-next" data-vgm-next hidden></div>' +
|
||
'<div class="vgm-eps" data-vgm-eps hidden></div>' +
|
||
'<div class="vgm-follow" data-vgm-follow hidden></div>' +
|
||
'<div class="vgm-dl" data-vgm-dl hidden>' +
|
||
'<button class="vgm-back" type="button" data-vgm-back>← Back to details</button>' +
|
||
'<div class="vgm-dl-content" data-vgm-dl-content></div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="vgm-actions">' +
|
||
'<span class="vgm-sel-count" data-vgm-count></span>' +
|
||
'<button class="discog-cancel-btn" type="button" data-vgm-open>Full page →</button>' +
|
||
'<button class="discog-cancel-btn vgm-download-btn" type="button" data-vgm-download>' +
|
||
'<span class="vgm-download-ic">⤓</span> Download</button>' +
|
||
'<button class="discog-submit-btn" type="button" data-vgm-wishlist>' +
|
||
'<span class="discog-submit-icon">⬇</span>' +
|
||
'<span class="discog-submit-text" data-vgm-add-label>+ Add to Wishlist</span>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'</div>';
|
||
document.body.appendChild(ov);
|
||
document.body.style.overflow = 'hidden';
|
||
modalEl = ov;
|
||
modalState = null;
|
||
requestAnimationFrame(function () { ov.classList.add('vgm-open'); });
|
||
|
||
ov.addEventListener('click', function (e) {
|
||
if (e.target === ov || e.target.closest('[data-vgm-close]')) { closeModal(); return; }
|
||
if (e.target.closest('[data-vgm-open]')) {
|
||
closeModal();
|
||
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
|
||
detail: { kind: o.kind, id: parseInt(o.id, 10), source: o.source || 'library' },
|
||
}));
|
||
return;
|
||
}
|
||
// Collapse/expand a season (ignore clicks on its checkbox).
|
||
var sh = e.target.closest('[data-vgm-season-toggle]');
|
||
if (sh && !e.target.closest('.vgm-season-check')) {
|
||
var seasonEl = sh.parentNode;
|
||
var opening = !seasonEl.classList.contains('vgm-season--open');
|
||
seasonEl.classList.toggle('vgm-season--open');
|
||
// First expand of an un-owned (tmdb) season → fetch its episodes.
|
||
if (opening && seasonEl.getAttribute('data-vgm-lazy') === '1' &&
|
||
!seasonEl.getAttribute('data-vgm-loaded')) {
|
||
loadSeason(seasonEl);
|
||
}
|
||
return;
|
||
}
|
||
if (e.target.closest('[data-vgm-download]')) { enterDownload(ov, o); return; }
|
||
if (e.target.closest('[data-vgm-back]')) { exitDownload(ov); return; }
|
||
if (e.target.closest('[data-vgm-wishlist]')) { submitWishlist(ov); }
|
||
});
|
||
ov.addEventListener('change', function (e) {
|
||
var sa = e.target.closest('[data-vgm-season-all]');
|
||
if (sa) { // season select-all → toggle its currently-actionable episodes
|
||
var sn = sa.getAttribute('data-vgm-season-all');
|
||
sa.indeterminate = false;
|
||
seasonBoxes(ov, sn).forEach(function (b) {
|
||
b.checked = sa.checked;
|
||
toggleSel(b.getAttribute('data-vgm-ep'), sa.checked);
|
||
});
|
||
updateFooter();
|
||
return;
|
||
}
|
||
var ep = e.target.closest('.vgm-ep-cb');
|
||
if (ep) {
|
||
toggleSel(ep.getAttribute('data-vgm-ep'), ep.checked);
|
||
syncSeasonCheck(ov, ep.getAttribute('data-vgm-ep').split('_')[0]);
|
||
updateFooter();
|
||
return;
|
||
}
|
||
if (e.target.closest('[data-vgm-missing-only]')) {
|
||
var wrap = ov.querySelector('[data-vgm-eps]');
|
||
if (wrap) wrap.classList.toggle('vgm-eps--missing-only', e.target.checked);
|
||
// the actionable set changed → re-derive every season checkbox
|
||
var sas = ov.querySelectorAll('[data-vgm-season-all]');
|
||
for (var k = 0; k < sas.length; k++) syncSeasonCheck(ov, sas[k].getAttribute('data-vgm-season-all'));
|
||
}
|
||
});
|
||
keyHandler = function (e) { if (e.key === 'Escape') closeModal(); };
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
var url = (o.source === 'tmdb')
|
||
? '/api/video/tmdb/' + o.kind + '/' + o.id
|
||
: '/api/video/detail/' + o.kind + '/' + o.id;
|
||
fetch(url, { headers: { Accept: 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (d) { if (modalEl && d) fill(d, o); })
|
||
.catch(function () { /* keep the title-only shell */ });
|
||
}
|
||
|
||
function toggleSel(key, on) { if (!modalState) return; if (on) modalState.sel.add(key); else modalState.sel.delete(key); }
|
||
|
||
function missingOnlyOn(ov) { var m = ov.querySelector('[data-vgm-missing-only]'); return !m || m.checked; }
|
||
// Episode checkboxes for a season that the season-select-all should act on:
|
||
// when "Missing only" is on, owned episodes are hidden, so exclude them.
|
||
function seasonBoxes(ov, sn) {
|
||
var skipOwned = missingOnlyOn(ov);
|
||
var all = ov.querySelectorAll('.vgm-ep-cb[data-vgm-ep^="' + sn + '_"]'), out = [];
|
||
for (var i = 0; i < all.length; i++) {
|
||
if (skipOwned && all[i].closest('.vgm-ep--owned')) continue;
|
||
out.push(all[i]);
|
||
}
|
||
return out;
|
||
}
|
||
function syncSeasonCheck(ov, sn) {
|
||
var all = ov.querySelector('[data-vgm-season-all="' + sn + '"]'); if (!all) return;
|
||
var boxes = seasonBoxes(ov, sn);
|
||
var checked = 0;
|
||
for (var i = 0; i < boxes.length; i++) if (boxes[i].checked) checked++;
|
||
all.checked = boxes.length > 0 && checked === boxes.length;
|
||
all.indeterminate = checked > 0 && checked < boxes.length;
|
||
all.disabled = boxes.length === 0;
|
||
}
|
||
|
||
function updateFooter() {
|
||
if (!modalEl) return;
|
||
var n = modalState ? modalState.sel.size : 0;
|
||
var cnt = modalEl.querySelector('[data-vgm-count]');
|
||
var add = modalEl.querySelector('[data-vgm-wishlist]');
|
||
var addLbl = modalEl.querySelector('[data-vgm-add-label]'); // set label only (keep the icon)
|
||
if (modalState && modalState.kind === 'show') {
|
||
if (cnt) cnt.textContent = n + ' episode' + (n === 1 ? '' : 's') + ' selected';
|
||
if (addLbl) addLbl.textContent = '+ Add ' + n + ' to Wishlist';
|
||
if (add) add.disabled = n === 0;
|
||
} else if (modalState && modalState.kind === 'movie' && modalState.owned) {
|
||
// Already owned — the action is a re-grab, not a new want.
|
||
if (cnt) cnt.textContent = '';
|
||
if (addLbl) addLbl.textContent = 'Re-download';
|
||
if (add) add.disabled = false;
|
||
} else {
|
||
if (cnt) cnt.textContent = '';
|
||
if (addLbl) addLbl.textContent = '+ Add to Wishlist';
|
||
if (add) add.disabled = false;
|
||
}
|
||
}
|
||
|
||
function epRow(snum, e, today) {
|
||
var st = epState(e, today);
|
||
var key = snum + '_' + e.episode_number;
|
||
var date = e.air_date ? fmtDate(e.air_date) : '';
|
||
// missing-aired -> pre-checked; owned -> selectable for re-download (not
|
||
// pre-checked); upcoming -> locked (can't grab what hasn't aired).
|
||
var ctrl = (st === 'upcoming')
|
||
? '<span class="vgm-ep-lock">◷</span>'
|
||
: '<input type="checkbox" class="vgm-ep-cb" data-vgm-ep="' + esc(key) + '"' + (st === 'missing' ? ' checked' : '') + '>';
|
||
var badge = st === 'owned' ? '<span class="vgm-ep-badge vgm-ep-badge--owned">In library</span>'
|
||
: st === 'upcoming' ? '<span class="vgm-ep-badge vgm-ep-badge--soon">' + (date || 'Upcoming') + '</span>'
|
||
: '<span class="vgm-ep-badge vgm-ep-badge--missing">Missing</span>';
|
||
return '<label class="vgm-ep vgm-ep--' + st + '">' +
|
||
'<span class="vgm-ep-ctrl">' + ctrl + '</span>' +
|
||
'<span class="vgm-ep-num">E' + (e.episode_number != null ? e.episode_number : '') + '</span>' +
|
||
'<span class="vgm-ep-title">' + esc(e.title || ('Episode ' + e.episode_number)) + '</span>' +
|
||
(date && st !== 'upcoming' ? '<span class="vgm-ep-date">' + esc(date) + '</span>' : '') +
|
||
badge + '</label>';
|
||
}
|
||
|
||
function renderEpisodes(d, o) {
|
||
var wrap = modalEl.querySelector('[data-vgm-eps]'); if (!wrap) return;
|
||
if (!d.seasons || !d.seasons.length) { wrap.hidden = true; return; }
|
||
var today = isoToday();
|
||
// For an un-owned (tmdb) show the episodes aren't shipped with the detail —
|
||
// they load per-season on expand (like the full detail page). Stash the tv
|
||
// id so the lazy loader can fetch them.
|
||
var tvId = (o && o.source === 'tmdb') ? parseInt(o.id, 10) : null;
|
||
modalState = { kind: 'show', sel: new Set(), tvId: tvId, today: today, epMeta: {}, seasonPoster: {} };
|
||
var totalMissing = 0, anyLazy = false;
|
||
var html = '<div class="vgm-eps-head"><span class="vgm-eps-h">Episodes</span>' +
|
||
'<label class="vgm-eps-toggle"><input type="checkbox" data-vgm-missing-only checked>' +
|
||
'<span>Missing only</span></label></div><div class="vgm-eps-list">';
|
||
d.seasons.forEach(function (s) {
|
||
// Capture this season's poster (owned → proxy by season id, tmdb → direct).
|
||
modalState.seasonPoster[String(s.season_number)] = (o && o.source === 'library')
|
||
? (s.has_poster && s.id != null ? '/api/video/poster/season/' + s.id : null)
|
||
: (s.poster_url || null);
|
||
var eps = s.episodes || [];
|
||
// Lazy: a tmdb season with a known count but no episodes shipped yet.
|
||
if (!eps.length && (s.episode_total || 0) > 0 && tvId) {
|
||
anyLazy = true;
|
||
totalMissing += s.episode_total; // all wanted → never "all owned"
|
||
html += '<div class="vgm-season" data-vgm-lazy="1" data-vgm-season="' + s.season_number + '">' +
|
||
'<div class="vgm-season-head" data-vgm-season-toggle>' +
|
||
'<span class="vgm-season-check"><input type="checkbox" data-vgm-season-all="' + s.season_number + '" disabled></span>' +
|
||
'<span class="vgm-season-name">' + esc(s.title || ('Season ' + s.season_number)) + '</span>' +
|
||
'<span class="vgm-season-meta">' + s.episode_total + ' eps</span>' +
|
||
'<span class="vgm-season-chev" aria-hidden="true">⌄</span>' +
|
||
'</div>' +
|
||
'<div class="vgm-season-eps"><div class="vgm-season-loading">Expand to load episodes…</div></div>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
var missing = 0;
|
||
eps.forEach(function (e) {
|
||
modalState.epMeta[s.season_number + '_' + e.episode_number] = { title: e.title, air_date: e.air_date,
|
||
overview: e.overview, still: e.has_still ? ('/api/video/poster/episode/' + e.id) : null };
|
||
if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); }
|
||
});
|
||
totalMissing += missing;
|
||
// Fully-owned seasons are hidden while "Missing only" is on (turn it off
|
||
// to re-download); otherwise an owned season expanded to nothing.
|
||
html += '<div class="vgm-season' + (missing ? '' : ' vgm-season--owned') + '">' +
|
||
'<div class="vgm-season-head" data-vgm-season-toggle>' +
|
||
'<span class="vgm-season-check"><input type="checkbox" data-vgm-season-all="' + s.season_number + '"' + (missing && missing === eps.length ? ' checked' : '') + '></span>' +
|
||
'<span class="vgm-season-name">' + esc(s.title || ('Season ' + s.season_number)) + '</span>' +
|
||
'<span class="vgm-season-meta">' + (missing ? missing + ' missing · ' : 'owned · ') + eps.length + ' eps</span>' +
|
||
'<span class="vgm-season-chev" aria-hidden="true">⌄</span>' +
|
||
'</div>' +
|
||
'<div class="vgm-season-eps">' + eps.map(function (e) { return epRow(s.season_number, e, today); }).join('') + '</div>' +
|
||
'</div>';
|
||
});
|
||
html += '</div>';
|
||
// When you own everything, the missing-only view would be blank — say so.
|
||
// (Skipped for lazy/un-owned shows: nothing is owned, episodes load on expand.)
|
||
if (totalMissing === 0 && !anyLazy) {
|
||
html += '<div class="vgm-eps-allowned">✓ You have every episode. Turn off “Missing only” to re-download.</div>';
|
||
}
|
||
wrap.innerHTML = html;
|
||
wrap.classList.add('vgm-eps--missing-only');
|
||
wrap.hidden = false;
|
||
// initial season-checkbox states reflect the (missing-only) selection
|
||
d.seasons.forEach(function (s) { syncSeasonCheck(modalEl, s.season_number); });
|
||
// Prefetch the first real season of an un-owned show so its first expand
|
||
// is instant (and its missing episodes pre-count in the footer).
|
||
if (anyLazy) prefetchFirstSeason(wrap);
|
||
}
|
||
|
||
function prefetchFirstSeason(wrap) {
|
||
var lazies = wrap.querySelectorAll('.vgm-season[data-vgm-lazy="1"]');
|
||
var pick = null, pickN = Infinity;
|
||
for (var i = 0; i < lazies.length; i++) {
|
||
var n = parseInt(lazies[i].getAttribute('data-vgm-season'), 10);
|
||
if (n >= 1 && n < pickN) { pickN = n; pick = lazies[i]; } // prefer Season 1 over Specials
|
||
}
|
||
if (!pick && lazies.length) pick = lazies[0];
|
||
if (pick) loadSeason(pick);
|
||
}
|
||
|
||
// Fetch a tmdb season's episodes the first time it's expanded, render them,
|
||
// and pre-select the missing-aired ones (everything un-owned that has aired).
|
||
function loadSeason(seasonEl) {
|
||
if (!modalState || !modalState.tvId || !seasonEl) return;
|
||
var sn = parseInt(seasonEl.getAttribute('data-vgm-season'), 10);
|
||
seasonEl.setAttribute('data-vgm-loaded', '1'); // guard double-fetch
|
||
var body = seasonEl.querySelector('.vgm-season-eps');
|
||
if (body) body.innerHTML = '<div class="vgm-season-loading">Loading episodes…</div>';
|
||
fetch('/api/video/tmdb/show/' + modalState.tvId + '/season/' + sn, { headers: { Accept: 'application/json' } })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (data) {
|
||
if (!modalEl || !seasonEl.parentNode) return;
|
||
var eps = (data && data.episodes) || [];
|
||
var b = seasonEl.querySelector('.vgm-season-eps');
|
||
if (!eps.length) { if (b) b.innerHTML = '<div class="vgm-season-loading">No episode info available.</div>'; return; }
|
||
var today = modalState.today;
|
||
if (b) b.innerHTML = eps.map(function (e) { return epRow(sn, e, today); }).join('');
|
||
modalState.epMeta = modalState.epMeta || {};
|
||
eps.forEach(function (e) {
|
||
modalState.epMeta[sn + '_' + e.episode_number] = { title: e.title, air_date: e.air_date,
|
||
overview: e.overview, still: e.still_url || null };
|
||
if (epState(e, today) === 'missing') modalState.sel.add(sn + '_' + e.episode_number);
|
||
});
|
||
var all = seasonEl.querySelector('[data-vgm-season-all]');
|
||
if (all) all.disabled = false;
|
||
syncSeasonCheck(modalEl, sn);
|
||
updateFooter();
|
||
})
|
||
.catch(function () {
|
||
var b = seasonEl.querySelector('.vgm-season-eps');
|
||
if (b) b.innerHTML = '<div class="vgm-season-loading">Couldn’t load episodes — tap to retry.</div>';
|
||
seasonEl.removeAttribute('data-vgm-loaded'); // allow a retry on next expand
|
||
});
|
||
}
|
||
|
||
function fill(d, o) {
|
||
var q = function (s) { return modalEl.querySelector(s); };
|
||
var id = o.id;
|
||
var hero = q('[data-vgm-hero]');
|
||
var bg = (o.source !== 'tmdb' && d.has_backdrop)
|
||
? '/api/video/backdrop/' + o.kind + '/' + id + '?w=1280'
|
||
: (d.backdrop_url || d.backdrop || '');
|
||
if (hero && bg) hero.style.backgroundImage = "url('" + bg + "')";
|
||
|
||
// Poster thumbnail floating over the hero (the premium "card" feel).
|
||
var poster = q('[data-vgm-poster]');
|
||
var pUrl = (o.source !== 'tmdb' && d.has_poster)
|
||
? '/api/video/poster/' + o.kind + '/' + id
|
||
: (d.poster_url || d.poster || '');
|
||
if (poster && pUrl) {
|
||
poster.onload = function () { if (hero) hero.classList.add('vgm-has-poster'); poster.hidden = false; };
|
||
poster.onerror = function () { poster.hidden = true; if (hero) hero.classList.remove('vgm-has-poster'); };
|
||
poster.src = pUrl;
|
||
}
|
||
|
||
var t = q('[data-vgm-title]'); if (t && d.title) t.textContent = d.title;
|
||
if (d.title) modalEl.style.setProperty('--vgm-h', hueOf(d.title)); // refine vibe from the real title
|
||
var eyebrow = [d.network, d.studio, d.year, d.status, d.content_rating].filter(Boolean).map(esc).join(' · ');
|
||
var eb = q('[data-vgm-eyebrow]'); if (eb) eb.textContent = eyebrow;
|
||
|
||
var meta = [];
|
||
if (d.runtime_minutes) meta.push(d.runtime_minutes + ' min');
|
||
if (d.rating) meta.push('★ ' + (Math.round(d.rating * 10) / 10));
|
||
if (d.tagline) meta.push('“' + d.tagline + '”');
|
||
var mt = q('[data-vgm-meta]'); if (mt) mt.innerHTML = meta.map(function (x) {
|
||
return '<span class="vgm-meta-item">' + esc(x) + '</span>';
|
||
}).join('');
|
||
|
||
var g = q('[data-vgm-genres]');
|
||
if (g && d.genres && d.genres.length) g.innerHTML = d.genres.slice(0, 5).map(function (x) {
|
||
return '<span class="vgm-genre">' + esc(x) + '</span>';
|
||
}).join('');
|
||
|
||
var ov = q('[data-vgm-overview]');
|
||
if (ov) {
|
||
if (d.overview) { ov.textContent = d.overview; ov.classList.remove('vgm-overview--none'); }
|
||
else { ov.textContent = 'No synopsis available yet.'; ov.classList.add('vgm-overview--none'); }
|
||
}
|
||
|
||
renderRatings(d); // IMDb / Rotten Tomatoes / Metacritic chips (both kinds)
|
||
var libId = (o.source === 'library') ? parseInt(o.id, 10) : (d.library_id || null);
|
||
if (o.kind === 'show') {
|
||
modalState = modalState || { kind: 'show', sel: new Set(), epMeta: {} };
|
||
renderEpisodes(d, o); renderNext(d); maybeFollow(d); // selector + next-up + follow offer
|
||
if (modalState) { // identity for the wishlist/watchlist writes
|
||
modalState.tmdbId = d.tmdb_id; modalState.title = d.title;
|
||
modalState.poster = pUrl || null; modalState.libraryId = libId;
|
||
modalState._detail = d; // feeds the show download tree (seasons/episodes)
|
||
modalState._tvId = (o.source === 'tmdb') ? parseInt(o.id, 10) : (d.tmdb_id || null);
|
||
}
|
||
} else {
|
||
modalState = { kind: 'movie', owned: !!d.owned, tmdbId: d.tmdb_id, title: d.title,
|
||
year: d.year || null, poster: pUrl || null, libraryId: libId,
|
||
file: d.file || null }; // owned → re-download; file feeds the download verdict
|
||
renderOwned(d);
|
||
}
|
||
updateFooter();
|
||
}
|
||
|
||
// Ratings strip: branded chips for whichever scores the payload carries.
|
||
function renderRatings(d) {
|
||
var box = modalEl && modalEl.querySelector('[data-vgm-ratings]'); if (!box) return;
|
||
function chip(cls, src, val) {
|
||
return '<span class="vgm-rating vgm-rating--' + cls + '">' +
|
||
'<span class="vgm-rating-src">' + src + '</span>' +
|
||
'<span class="vgm-rating-val">' + esc(val) + '</span></span>';
|
||
}
|
||
var chips = [];
|
||
if (d.imdb_rating) chips.push(chip('imdb', 'IMDb', (Math.round(d.imdb_rating * 10) / 10)));
|
||
if (d.rt_rating) chips.push(chip('rt', 'Rotten Tomatoes', d.rt_rating + '%'));
|
||
if (d.metacritic) chips.push(chip('mc', 'Metacritic', d.metacritic));
|
||
if (!chips.length) { box.hidden = true; return; }
|
||
box.innerHTML = chips.join('');
|
||
box.hidden = false;
|
||
}
|
||
|
||
// Owned movie → an "in your library" note (with the best version's quality)
|
||
// instead of pretending it's a fresh wishlist want.
|
||
function renderOwned(d) {
|
||
var box = modalEl && modalEl.querySelector('[data-vgm-owned]'); if (!box) return;
|
||
if (!d.owned) { box.hidden = true; return; }
|
||
var f = d.file || {};
|
||
var bits = [resLabel(f.resolution), f.quality, (f.audio_codec || '').toUpperCase()].filter(Boolean);
|
||
box.innerHTML = '<span class="vgm-owned-ic">✓</span>' +
|
||
'<span class="vgm-owned-txt"><strong>In your library</strong>' +
|
||
(bits.length ? ' · ' + esc(bits.join(' · ')) : '') + '</span>';
|
||
box.hidden = false;
|
||
}
|
||
|
||
// Airing show → the soonest not-yet-aired episode, so "what's next" is right
|
||
// up front (ties the modal to the watchlist's whole reason for existing).
|
||
function renderNext(d) {
|
||
var box = modalEl && modalEl.querySelector('[data-vgm-next]'); if (!box) return;
|
||
if (!isAiring(d.status)) { box.hidden = true; return; }
|
||
var today = isoToday(), best = null;
|
||
(d.seasons || []).forEach(function (s) {
|
||
(s.episodes || []).forEach(function (e) {
|
||
if (e.air_date && e.air_date > today && (!best || e.air_date < best.air_date))
|
||
best = { s: s.season_number, e: e.episode_number, air_date: e.air_date, title: e.title };
|
||
});
|
||
});
|
||
// Un-owned (tmdb) shows ship no episodes — fall back to the payload's
|
||
// next_episode stub from the TMDB extras.
|
||
if (!best && d.next_episode && d.next_episode.episode_number != null) {
|
||
var ne = d.next_episode;
|
||
best = { s: ne.season_number, e: ne.episode_number, air_date: ne.air_date, title: ne.name };
|
||
}
|
||
if (!best) { box.hidden = true; return; }
|
||
box.innerHTML = '<span class="vgm-next-ic">▶</span>' +
|
||
'<span class="vgm-next-txt"><strong>Next episode</strong> · S' + best.s + ' · E' + best.e +
|
||
(best.title ? ' — ' + esc(best.title) : '') + '</span>' +
|
||
(best.air_date ? '<span class="vgm-next-date">' + esc(fmtDate(best.air_date)) + '</span>' : '');
|
||
box.hidden = false;
|
||
}
|
||
|
||
// Airing show you don't follow yet → offer to start watching it (default on),
|
||
// so grabbing episodes also keeps you current. Hidden for ended shows (can't
|
||
// "watch" for new) and shows you already follow.
|
||
function maybeFollow(d) {
|
||
var fw = modalEl.querySelector('[data-vgm-follow]'); if (!fw) return;
|
||
if (!d.tmdb_id || !isAiring(d.status)) { fw.hidden = true; return; }
|
||
fetch('/api/video/watchlist/check', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kind: 'show', tmdb_ids: [d.tmdb_id] }) })
|
||
.then(function (r) { return r.ok ? r.json() : null; })
|
||
.then(function (res) {
|
||
if (!modalEl) return;
|
||
if (res && res.results && res.results[String(d.tmdb_id)]) { fw.hidden = true; return; }
|
||
fw.innerHTML = '<label class="vgm-follow-row"><input type="checkbox" data-vgm-add-watch checked>' +
|
||
'<span class="vgm-follow-txt"><strong>Add to watchlist</strong> — automatically get new episodes as they air</span></label>';
|
||
fw.hidden = false;
|
||
}).catch(function () { /* skip the row */ });
|
||
}
|
||
|
||
// One capture-phase handler — the get button sits inside a card <a>.
|
||
document.addEventListener('click', function (e) {
|
||
var b = e.target.closest && e.target.closest('.vget-btn');
|
||
if (!b) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
openModal({
|
||
kind: b.getAttribute('data-vget-kind'),
|
||
source: b.getAttribute('data-vget-source') || 'library',
|
||
id: b.getAttribute('data-vget-id'),
|
||
title: b.getAttribute('data-vget-title') || '',
|
||
});
|
||
}, true);
|
||
|
||
// The control group for any card, so every surface stays consistent:
|
||
// person → eye (watchlist)
|
||
// movie → get (download/wishlist)
|
||
// airing show → eye + get (follow new episodes AND grab the back catalog)
|
||
// ended show → get
|
||
// Returns a positioned wrapper holding 0–2 buttons.
|
||
function cardButton(o) {
|
||
if (!o || !o.kind) return '';
|
||
var parts = [];
|
||
if (o.kind === 'person') {
|
||
if (window.VideoWatchlist) parts.push(VideoWatchlist.btn({ kind: 'person', tmdbId: o.tmdbId, title: o.title, poster: o.poster }));
|
||
} else if (o.kind === 'show') {
|
||
var airing = !o.status ? true : isAiring(o.status);
|
||
if (airing && o.tmdbId && window.VideoWatchlist) {
|
||
parts.push(VideoWatchlist.btn({ kind: 'show', tmdbId: o.tmdbId, title: o.title,
|
||
poster: o.poster, libraryId: o.libraryId }));
|
||
}
|
||
// every show can be acquired (missing/back-catalog episodes)
|
||
parts.push(btn({ kind: 'show', source: o.source || 'tmdb', openId: o.libraryId || o.tmdbId, title: o.title }));
|
||
} else if (o.kind === 'movie') {
|
||
parts.push(btn({ kind: 'movie', source: o.source || 'tmdb', openId: o.libraryId || o.tmdbId, title: o.title }));
|
||
}
|
||
parts = parts.filter(Boolean);
|
||
return parts.length ? '<span class="vcard-ctrls">' + parts.join('') + '</span>' : '';
|
||
}
|
||
|
||
window.VideoGet = { btn: btn, isAiring: isAiring, open: openModal, close: closeModal, cardButton: cardButton };
|
||
})();
|