From 4b7a915d39803d460c7981a9911f3ecbed849c08 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 16 Jun 2026 12:57:42 -0700 Subject: [PATCH] Video get-modal: lazy-load episodes for un-owned (tmdb) shows A show you don't own ships its seasons with no episodes (loaded per-season on expand, like the full detail page), so the modal mis-read '0 episodes' as 'all owned' and seasons expanded to nothing. - Render tmdb seasons as collapsible with an 'expand to load' placeholder; fetch /api/video/tmdb/show//season/ on first expand, render rows, pre-select the missing-aired episodes, enable the season select-all. - Skip the 'you have every episode' hint when any season is lazy/un-owned; lazy seasons aren't tagged owned, so they stay visible under 'Missing only'. - Next-episode line falls back to the payload's next_episode stub when no episodes are loaded (the only next-up source for un-owned shows). --- webui/static/video/video-get-modal.js | 79 ++++++++++++++++++++++++--- webui/static/video/video-side.css | 1 + 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/webui/static/video/video-get-modal.js b/webui/static/video/video-get-modal.js index 38dbf04b..8b00c307 100644 --- a/webui/static/video/video-get-modal.js +++ b/webui/static/video/video-get-modal.js @@ -136,7 +136,14 @@ // 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')) { - sh.parentNode.classList.toggle('vgm-season--open'); + 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-wishlist]')) { @@ -256,17 +263,36 @@ badge + ''; } - function renderEpisodes(d) { + 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(); - modalState = { kind: 'show', sel: new Set() }; - var totalMissing = 0; + // 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 }; + var totalMissing = 0, anyLazy = false; var html = '
Episodes' + '
'; d.seasons.forEach(function (s) { 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 += '
' + + '
' + + '' + + '' + esc(s.title || ('Season ' + s.season_number)) + '' + + '' + s.episode_total + ' eps' + + '' + + '
' + + '
Expand to load episodes…
' + + '
'; + return; + } var missing = 0; eps.forEach(function (e) { if (epState(e, today) === 'missing') { missing++; modalState.sel.add(s.season_number + '_' + e.episode_number); } }); totalMissing += missing; @@ -284,7 +310,8 @@ }); html += '
'; // When you own everything, the missing-only view would be blank — say so. - if (totalMissing === 0) { + // (Skipped for lazy/un-owned shows: nothing is owned, episodes load on expand.) + if (totalMissing === 0 && !anyLazy) { html += '
✓ You have every episode. Turn off “Missing only” to re-download.
'; } wrap.innerHTML = html; @@ -294,6 +321,38 @@ d.seasons.forEach(function (s) { syncSeasonCheck(modalEl, s.season_number); }); } + // 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 = '
Loading episodes…
'; + 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 = '
No episode info available.
'; return; } + var today = modalState.today; + if (b) b.innerHTML = eps.map(function (e) { return epRow(sn, e, today); }).join(''); + eps.forEach(function (e) { + 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 = '
Couldn’t load episodes — tap to retry.
'; + 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; @@ -341,7 +400,7 @@ renderRatings(d); // IMDb / Rotten Tomatoes / Metacritic chips (both kinds) if (o.kind === 'show') { modalState = modalState || { kind: 'show', sel: new Set() }; - renderEpisodes(d); renderNext(d); maybeFollow(d); // selector + next-up + follow offer + renderEpisodes(d, o); renderNext(d); maybeFollow(d); // selector + next-up + follow offer } else { modalState = { kind: 'movie', owned: !!d.owned }; // owned movies → re-download, not wishlist renderOwned(d); @@ -391,11 +450,17 @@ 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 = '' + 'Next episode · S' + best.s + ' · E' + best.e + (best.title ? ' — ' + esc(best.title) : '') + '' + - '' + esc(fmtDate(best.air_date)) + ''; + (best.air_date ? '' + esc(fmtDate(best.air_date)) + '' : ''); box.hidden = false; } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 2e00571c..6ecdcadc 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -2044,6 +2044,7 @@ body[data-side="video"] #soulsync-toggle { display: none; } .vgm-season--open .vgm-season-chev { transform: rotate(180deg); } .vgm-season-eps { display: none; padding: 2px 6px 8px; } .vgm-season--open .vgm-season-eps { display: block; } +.vgm-season-loading { padding: 14px 12px; font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.4); text-align: center; } .vgm-ep { display: flex; align-items: center; gap: 11px; padding: 8px 10px; border-radius: 9px; cursor: pointer; transition: background 0.12s ease; } .vgm-ep:hover { background: rgba(255, 255, 255, 0.04); }