soulsync/webui/static/video/video-wishlist.js
BoulderBadgeDad 9bb6c4ebd0 video wishlist: add a 'Clear all' button (movies / TV / YouTube)
The wishlist page had no way to empty a tab — only per-item remove. Added a red-tinted
'Clear all' button in the toolbar that empties the ACTIVE tab in one click (after a
confirm), shown only when that tab has items.

- db.clear_wishlist(kind) maps the tab to its rows (movie→'movie', show→'episode',
  youtube→'video') and deletes them; returns the count.
- POST /api/video/wishlist/clear {kind: movie|show|youtube}.
- Toolbar button + clearAll() (confirm → clear → reload) + updateClearBtn() visibility.

Tests: per-tab clear leaves the others intact, unknown-kind/empty no-ops, the endpoint,
and the frontend wiring. node --check clean.
2026-06-20 16:42:57 -07:00

605 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* SoulSync — Video Wishlist page (isolated).
*
* The curated 'get this' list, split by a Movies / TV tab. Movies render as a
* poster grid; TV groups into collapsible show → season → episode rows with
* wanted/done roll-ups and a remove (✕) at every level. Server-paged + searchable
* like the other pages. Reads /api/video/wishlist; removes via /wishlist/remove.
* Self-contained IIFE, no globals.
*/
(function () {
'use strict';
var PAGE_ID = 'video-wishlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'movie', search: '', sort: 'added', page: 1,
counts: { movie: 0, show: 0, episode: 0 }, ytChannel: 0, ytVideo: 0,
showData: {}, showInfo: {} };
var searchTimer = null;
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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; }
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);
}
var STATUS = {
wanted: ['Wanted', 'vwsh-st--wanted'], searching: ['Searching', 'vwsh-st--searching'],
downloading: ['Downloading', 'vwsh-st--downloading'], downloaded: ['Done', 'vwsh-st--done'],
failed: ['Failed', 'vwsh-st--failed'],
};
function statusPill(status) {
var s = STATUS[status] || STATUS.wanted;
return '<span class="vwsh-st ' + s[1] + '">' + s[0] + '</span>';
}
function rmBtn(scope, attrs) {
return '<button class="vwsh-rm" type="button" title="Remove" aria-label="Remove" ' +
'data-vwsh-rm="' + scope + '"' + attrs + '>&times;</button>';
}
// ── movie card ────────────────────────────────────────────────────────────
function movieCard(it) {
var owned = it.library_id != null;
var art = it.poster_url
? '<img class="vwsh-movie-img" src="' + esc(it.poster_url) + '" alt="" loading="lazy" ' +
'onerror="this.style.display=\'none\'">'
: '<div class="vwsh-movie-ph">🎬</div>';
var meta = [it.year, owned ? 'In library' : null].filter(Boolean).join(' · ');
return '<div class="vwsh-movie" data-vwsh-open-movie="' + esc(it.tmdb_id) +
'" data-vwsh-src="' + (owned ? 'library' : 'tmdb') + '" data-vwsh-id="' + esc(owned ? it.library_id : it.tmdb_id) + '">' +
'<div class="vwsh-movie-art">' + art + '<div class="vwsh-movie-scrim"></div>' +
statusPill(it.status) + rmBtn('movie', ' data-tmdb="' + esc(it.tmdb_id) + '"') + '</div>' +
'<div class="vwsh-movie-info"><span class="vwsh-movie-title" title="' + esc(it.title) + '">' +
esc(it.title) + '</span>' + (meta ? '<span class="vwsh-movie-meta">' + esc(meta) + '</span>' : '') +
'</div></div>';
}
// ── show orb (the "Nebula": show→artist, season→album, episode→track) ─────
function initials(s) {
var w = String(s || '').replace(/[^A-Za-z0-9 ]/g, '').split(' ').filter(Boolean);
var i = w.slice(0, 2).map(function (x) { return x[0]; }).join('');
return (i || String(s || '?').slice(0, 2)).toUpperCase();
}
function orbSize(n) { return n >= 10 ? 'orb-lg' : n >= 4 ? 'orb-md' : 'orb-sm'; }
function nebulaOrb(sh, idx) {
// Source-aware: a TMDB show opens its show page; a YouTube channel (source
// 'youtube') opens the in-app channel page. YEAR is the "season", video the
// "episode" — the data is already shaped that way, so the same render runs.
var yt = sh.source === 'youtube';
// YouTube art goes through our image proxy (reliable load); tmdb passes through.
var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; };
var src = sh.library_id != null ? 'library' : 'tmdb';
var openId = sh.library_id != null ? sh.library_id : sh.tmdb_id;
var openAttrs = yt
? 'data-vwsh-open-channel data-yt="' + esc(sh.youtube_id) + '"'
: 'data-vwsh-open-show data-vwsh-src="' + src + '" data-vwsh-id="' + esc(openId) + '"';
var hue = hueOf(sh.title);
var total = sh.wanted || 0;
// Channel avatar can be missing (flat listing doesn't always surface it) —
// fall back to the newest video's thumbnail so the orb is never blank.
var poster = sh.poster_url || (yt && (sh.seasons || [])[0] ? sh.seasons[0].poster_url : null);
var img = poster
? '<img class="wl-orb-img" src="' + esc(pimg(poster)) + '" alt="" ' +
'onerror="this.outerHTML=\'<div class=&quot;wl-orb-initials&quot;>' + esc(initials(sh.title)) + '</div>\'">'
: '<div class="wl-orb-initials">' + esc(initials(sh.title)) + '</div>';
// Episodes are shown grouped under a clickable season header (header →
// show/channel page); each episode card SELECTS it (drives the info bar).
// Season = a poster panel on the LEFT with the episode grid to its RIGHT.
var seasons = (sh.seasons || []).map(function (se) {
var n = se.episodes.length;
var posterUrl = se.poster_url || sh.poster_url || null;
var thumb = posterUrl ? '<img src="' + esc(pimg(posterUrl)) + '" alt="">' : '<span class="vwsh-szn-ph">📺</span>';
var cards = (se.episodes || []).map(function (e) { return epCard(sh, se, e); }).join('');
var sName = yt ? (se.season_number ? se.season_number : 'Undated') : ('Season ' + se.season_number);
var sRm = yt
? 'data-vwsh-rm="yt-season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"'
: 'data-vwsh-rm="season" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '"';
return '<div class="vwsh-szn">' +
'<div class="vwsh-szn-side" ' + openAttrs + ' title="' + (yt ? 'Open channel page' : 'Open show page') + '">' +
'<div class="vwsh-szn-poster">' + thumb + '</div>' +
'<div class="vwsh-szn-name">' + esc(sName) + '</div>' +
'<div class="vwsh-szn-count">' + n + (yt ? ' video' : ' episode') + (n === 1 ? '' : 's') + '</div>' +
'<div class="vwsh-szn-go">' + (yt ? 'View channel' : 'View show') + ' &rarr;</div>' +
'<button class="vwsh-szn-rm" type="button" ' + sRm + ' title="Remove">&#10005;</button>' +
'</div>' +
'<div class="vwsh-ep-grid">' + cards + '</div>' +
'</div>';
}).join('');
var eps = total + (yt ? ' video' : ' episode') + (total === 1 ? '' : 's');
// --orb-hue on the GROUP so the music orb styles + my cinematic-expand
// backdrop (--vwsh-poster) both resolve; poster bleeds in only when expanded.
var gstyle = 'animation-delay:' + Math.min(idx * 45, 700) + 'ms;--orb-hue:' + hue +
(poster ? ";--vwsh-poster:url('" + esc(poster) + "')" : '');
var prog = total ? Math.max(0, Math.min(1, (sh.done || 0) / total)) : 0; // #4 acquisition progress
// Header is a 3-column row that FLANKS the poster: synopsis (left) · poster
// (middle) · cast (right). When collapsed (or no data) the side columns are
// empty → hidden → just the centered bubble, so the nebula grid is unchanged.
var showRm = yt
? 'data-vwsh-rm="yt-channel" data-yt="' + esc(sh.youtube_id) + '"'
: 'data-vwsh-rm="show" data-tmdb="' + esc(sh.tmdb_id) + '"';
return '<div class="wl-orb-group" data-vwsh-group data-vwsh-tmdb="' + esc(sh.tmdb_id) + '" ' +
'data-vwsh-source="' + (yt ? 'youtube' : 'tmdb') + '" style="' + gstyle + '">' +
'<button class="wl-orb-remove" type="button" ' + showRm + ' title="Remove">&#10005;</button>' +
'<div class="vwsh-xhead">' +
'<div class="vwsh-info-syn" data-vwsh-syn></div>' +
'<div class="vwsh-xhead-mid">' +
'<div class="wl-orb-tooltip">' + esc(sh.title) + '<br><span>' + eps + '</span></div>' +
'<div class="wl-orb ' + orbSize(total) + '" data-vwsh-orb style="--vwsh-prog:' + prog + '">' +
'<div class="wl-orb-glow"></div>' + img + '<div class="wl-orb-ring"></div>' +
'<div class="vwsh-prog"></div>' +
'</div>' +
'<div class="wl-orb-label" ' + openAttrs + ' title="' + esc(sh.title) + '">' + esc(sh.title) + '</div>' +
'<div class="wl-orb-meta">' + eps + (sh.done ? ' · ' + sh.done + ' done' : '') + '</div>' +
'</div>' +
'<div class="vwsh-info-cast" data-vwsh-cast></div>' +
'</div>' +
'<div class="wl-orb-expanded"><div class="vwsh-seasons">' + seasons + '</div></div>' +
'</div>';
}
// ── info bar: synopsis + clickable cast, contextual to the selected episode ─
function castBubbles(arr) {
return (arr || []).slice(0, 8).map(function (c) {
var photo = c.photo
? '<img src="' + esc(c.photo) + '" alt="" loading="lazy" onerror="this.parentNode.classList.add(\'vwsh-cast--ph\')">'
: '';
return '<button class="vwsh-cast' + (c.photo ? '' : ' vwsh-cast--ph') + '" type="button" ' +
'data-vwsh-open-person data-id="' + esc(c.tmdb_id) + '" ' +
'title="' + esc(c.name) + (c.character ? ' — ' + esc(c.character) : '') + '">' +
'<span class="vwsh-cast-img"><span class="vwsh-cast-ini">' + esc(initials(c.name)) + '</span>' + photo + '</span>' +
'<span class="vwsh-cast-name">' + esc(c.name) + '</span></button>';
}).join('');
}
function findEpisode(tmdb, sNum, eNum) {
var sh = state.showData[tmdb], ep = null;
if (sh) (sh.seasons || []).forEach(function (se) {
if (se.season_number === sNum) (se.episodes || []).forEach(function (x) {
if (x.episode_number === eNum) { ep = x; ep.season_number = sNum; } // episodes don't carry their season #
});
});
return ep;
}
// sel = a selected episode object (episode synopsis + guest cast), or null (show synopsis + show cast).
function renderInfoBar(group, tmdb, sel) {
var synEl = group && group.querySelector('[data-vwsh-syn]');
var castEl = group && group.querySelector('[data-vwsh-cast]');
if (!synEl || !castEl) return;
// YouTube: no cast. A selected video shows its FULL metadata — description,
// duration, views — fetched lazily from yt-dlp on first select (like the
// TV nebula lazy-loads guest stars), then cached on the episode object.
if (group.getAttribute('data-vwsh-source') === 'youtube') {
castEl.innerHTML = '';
if (!sel) { synEl.innerHTML = ''; return; }
var vd = sel._ytd; // undefined = not fetched, null = fetched/empty
var bits = [];
var yd = fmtDate((vd && vd.published_at) || sel.air_date); if (yd) bits.push(esc(yd));
if (vd && window.VideoYoutube) {
var du = VideoYoutube.fmtDuration(vd.duration_seconds); if (du) bits.push(esc(du));
var vc = VideoYoutube.compactCount(vd.view_count); if (vc) bits.push(esc(vc) + ' views');
}
var desc = (vd && vd.description) || sel.overview || '';
var watch = 'https://www.youtube.com/watch?v=' + encodeURIComponent(sel.source_id);
synEl.innerHTML =
(bits.length ? '<span class="vwsh-info-eyebrow">' + bits.join(' · ') + '</span>' : '') +
(desc ? esc(desc) : (sel._ytd === undefined ? 'Loading details…' : 'No description for this video.')) +
'<a class="vwsh-yt-watch" href="' + watch + '" target="_blank" rel="noopener">Watch on YouTube &#8599;</a>';
if (sel._ytd === undefined) {
sel._ytd = null; // mark in-flight so we fetch once
fetch('/api/video/youtube/video/' + encodeURIComponent(sel.source_id), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
sel._ytd = (d && d.video) || null;
var cur = group.querySelector('.vwsh-epc--sel');
if (cur && cur.getAttribute('data-src-id') === sel.source_id) renderInfoBar(group, tmdb, sel);
})
.catch(function () { sel._ytd = null; });
}
return;
}
var si = state.showInfo[tmdb] || {};
var eyebrow, overview, castArr;
if (sel) {
eyebrow = 'S' + sel.season_number + ' · E' + sel.episode_number;
overview = sel.overview || 'No synopsis for this episode.';
// Episode cast = its guest stars (episode-specific) THEN the show regulars,
// deduped — most episodes have no guest stars, so show the regulars too.
var seen = {};
castArr = (sel._guests || []).concat(si.cast || []).filter(function (c) {
var k = c.tmdb_id || c.name; if (seen[k]) return false; seen[k] = 1; return true;
});
} else {
eyebrow = ''; overview = si.overview || ''; castArr = si.cast || [];
}
// Side columns are independent so each hides (:empty) when it has nothing.
synEl.innerHTML = (eyebrow || overview)
? ((eyebrow ? '<span class="vwsh-info-eyebrow">' + esc(eyebrow) + '</span>' : '') + esc(overview)) : '';
castEl.innerHTML = castArr.length ? castBubbles(castArr) : '';
// lazily fetch the episode's guest stars, then re-render if still selected
if (sel && sel._guests === undefined) {
sel._guests = null;
fetch('/api/video/episode/' + tmdb + '/' + sel.season_number + '/' + sel.episode_number, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
sel._guests = (d && d.guest_stars) || [];
var cur = group.querySelector('.vwsh-epc--sel');
if (cur && +cur.getAttribute('data-s') === sel.season_number && +cur.getAttribute('data-e') === sel.episode_number)
renderInfoBar(group, tmdb, sel);
})
.catch(function () { sel._guests = []; });
}
}
// Lazily load the show's synopsis + cast when its orb first expands.
function loadShowInfo(group) {
if (!group) return;
var tmdb = parseInt(group.getAttribute('data-vwsh-tmdb'), 10);
renderInfoBar(group, tmdb, null); // paint the View-show button immediately
if (group.getAttribute('data-vwsh-source') === 'youtube') return; // no tmdb detail for channels
if (group.getAttribute('data-vwsh-info-loaded')) return;
group.setAttribute('data-vwsh-info-loaded', '1');
var sh = state.showData[tmdb]; if (!sh) return;
var url = sh.library_id != null ? '/api/video/detail/show/' + sh.library_id : '/api/video/tmdb/show/' + tmdb;
fetch(url, { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
state.showInfo[tmdb] = { overview: d.overview || '', cast: d.cast || [] };
if (!group.querySelector('.vwsh-epc--sel')) renderInfoBar(group, tmdb, null); // unless an episode is selected
})
.catch(function () { /* best-effort */ });
}
// A single episode card. Clicking it SELECTS the episode (drives the info bar);
// the "View show" button in the info bar is what navigates.
function epCard(sh, se, e) {
var yt = sh.source === 'youtube';
var pimg = function (u) { return (yt && window.VideoYoutube) ? VideoYoutube.img(u) : u; };
var t = e.title || (yt ? 'Untitled' : ('Episode ' + e.episode_number));
var st = STATUS[e.status] ? e.status : 'wanted';
var date = fmtDate(e.air_date);
// TMDB shows the SxEx label; a YouTube video shows just its upload date.
var metaTxt = yt ? (date || 'Video') : ('S' + se.season_number + '·E' + e.episode_number + (date ? ' · ' + esc(date) : ''));
var thumb = e.still_url
? '<span class="vwsh-epc-thumb"><img src="' + esc(pimg(e.still_url)) + '" alt="" loading="lazy" ' +
'onerror="this.parentNode.classList.add(\'vwsh-epc-thumb--none\')"></span>'
: '<span class="vwsh-epc-thumb vwsh-epc-thumb--none"></span>';
var rm = yt
? 'data-vwsh-rm="yt-video" data-id="' + esc(e.source_id) + '"'
: 'data-vwsh-rm="episode" data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"';
return '<div class="vwsh-epc" data-vwsh-ep data-tmdb="' + esc(sh.tmdb_id) + '" data-s="' + se.season_number + '" data-e="' + e.episode_number + '"' +
(yt ? ' data-src-id="' + esc(e.source_id) + '"' : '') + '>' + thumb +
'<div class="vwsh-epc-body">' +
'<div class="vwsh-epc-title" title="' + esc(t) + '">' + esc(t) + '</div>' +
'<div class="vwsh-epc-meta"><span class="vwsh-ep-dot vwsh-ep-dot--' + st + '"></span>' + (yt ? esc(metaTxt) : metaTxt) + '</div>' +
'</div>' +
'<button class="vwsh-epc-rm" type="button" ' + rm + ' title="Remove">&#10005;</button>' +
'</div>';
}
function render(items) {
var grid = $('[data-vwsh-grid]'); if (!grid) return;
// YouTube uses the SAME nebula as TV (channel=show, year=season, video=episode).
var nebula = state.tab === 'show' || state.tab === 'youtube';
grid.classList.toggle('wl-nebula-field', nebula);
grid.classList.toggle('vwsh-nebula', nebula); // video-only scope so music wl-* is untouched
grid.classList.toggle('vwsh-grid--movies', state.tab === 'movie');
state.showData = {};
if (nebula) items.forEach(function (sh) { state.showData[sh.tmdb_id] = sh; }); // for the episode area
grid.innerHTML = nebula
? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
: items.map(movieCard).join('');
}
// ── counts / badges / pager ───────────────────────────────────────────────
function setCounts(counts) {
state.counts = { movie: (counts && counts.movie) || 0, show: (counts && counts.show) || 0,
episode: (counts && counts.episode) || 0 };
var cm = $('[data-vwsh-count-movie]'); if (cm) cm.textContent = state.counts.movie;
var cs = $('[data-vwsh-count-show]'); if (cs) cs.textContent = state.counts.show;
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
updateSub();
updateClearBtn();
}
function setYtCounts(counts) {
state.ytChannel = (counts && counts.channel) || 0;
state.ytVideo = (counts && counts.video) || 0;
var cy = $('[data-vwsh-count-youtube]'); if (cy) cy.textContent = state.ytVideo;
updateSub();
updateClearBtn();
}
// Show "Clear all" only when the active tab actually has items.
function updateClearBtn() {
var btn = $('[data-vwsh-clear]'); if (!btn) return;
var has = state.tab === 'movie' ? state.counts.movie > 0
: state.tab === 'show' ? state.counts.show > 0
: (state.ytVideo > 0 || state.ytChannel > 0);
btn.hidden = !has;
}
// Keep the YouTube tab badge fresh without switching to the tab.
function refreshYtCount() {
fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.counts) setYtCounts(d.counts); })
.catch(function () { /* ignore */ });
}
function updateSub() {
var el = $('[data-vwsh-sub]'); if (!el) return;
var c = state.counts;
if (state.tab === 'youtube') {
el.textContent = state.ytChannel + ' channel' + (state.ytChannel === 1 ? '' : 's') +
' · ' + state.ytVideo + ' video' + (state.ytVideo === 1 ? '' : 's');
return;
}
el.textContent = state.tab === 'show'
? c.show + ' show' + (c.show === 1 ? '' : 's') + ' · ' + c.episode + ' episode' + (c.episode === 1 ? '' : 's')
: c.movie + ' movie' + (c.movie === 1 ? '' : 's');
}
function updateBadges(total) {
var n = total || 0;
['[data-video-wishlist-badge]', '[data-video-badge="wishlist"]'].forEach(function (sel) {
document.querySelectorAll(sel).forEach(function (b) {
b.textContent = n; b.classList.toggle('hidden', !n);
});
});
}
function refreshBadge() {
fetch('/api/video/wishlist/counts', { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.success) updateBadges(d.total); })
.catch(function () { /* ignore */ });
}
function updatePagination(p) {
var box = $('[data-vwsh-pagination]'), prev = $('[data-vwsh-prev]'),
next = $('[data-vwsh-next]'), info = $('[data-vwsh-pageinfo]');
if (!box) return;
if (!p || p.total_pages <= 1) { box.classList.add('hidden'); return; }
if (prev) prev.disabled = !p.has_prev;
if (next) next.disabled = !p.has_next;
if (info) info.textContent = 'Page ' + p.page + ' of ' + p.total_pages;
box.classList.remove('hidden');
}
function updateEmpty(total) {
var empty = $('[data-vwsh-empty]'); if (empty) empty.classList.toggle('hidden', total > 0);
var et = $('[data-vwsh-empty-title]');
if (et && total === 0) {
et.textContent = state.search ? 'No matches'
: state.tab === 'movie' ? 'No movies on your wishlist yet'
: state.tab === 'show' ? 'No TV episodes on your wishlist yet'
: 'No channels followed yet — paste a channel link on the Search page';
}
}
function loadYoutube() {
var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden');
var params = new URLSearchParams({ search: state.search, sort: state.sort, page: state.page, limit: LIMIT });
fetch('/api/video/youtube/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (ld) ld.classList.add('hidden');
if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; }
setYtCounts(d.counts);
var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length };
state.page = p.page;
render(d.items || []);
updatePagination(p);
updateEmpty(p.total_count);
})
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); });
}
function load() {
state.loaded = true;
if (state.tab === 'youtube') { loadYoutube(); return; }
var ld = $('[data-vwsh-loading]'); if (ld) ld.classList.remove('hidden');
var params = new URLSearchParams({ kind: state.tab, search: state.search, sort: state.sort, page: state.page, limit: LIMIT });
fetch('/api/video/wishlist?' + params.toString(), { headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (ld) ld.classList.add('hidden');
if (!d || !d.success) { render([]); updatePagination(null); updateEmpty(0); return; }
setCounts(d.counts);
var p = d.pagination || { page: 1, total_pages: 1, total_count: (d.items || []).length };
state.page = p.page;
render(d.items || []);
updatePagination(p);
updateEmpty(p.total_count);
maybeBackfillArt(d.items || []);
})
.catch(function () { if (ld) ld.classList.add('hidden'); render([]); updatePagination(null); updateEmpty(0); });
}
// Rows added before art-capture have no episode still / season poster — fetch
// them once (cheap: one tmdb_season call per show/season server-side), reload.
var artBackfilled = false;
function maybeBackfillArt(items) {
if (state.tab !== 'show' || artBackfilled) return;
var missing = (items || []).some(function (sh) {
return (sh.seasons || []).some(function (se) {
return !se.poster_url || (se.episodes || []).some(function (e) { return !e.still_url || !e.overview; });
});
});
if (!missing) return;
artBackfilled = true;
fetch('/api/video/wishlist/backfill-art', { method: 'POST', headers: { Accept: 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) { if (res && res.updated > 0) load(); })
.catch(function () { /* best-effort */ });
}
function setTab(tab) {
if (tab !== 'movie' && tab !== 'show' && tab !== 'youtube') return;
state.tab = tab; state.page = 1; state.search = '';
var si = $('[data-vwsh-search]'); if (si) si.value = '';
var tabs = document.querySelectorAll('[data-vwsh-tab]');
for (var i = 0; i < tabs.length; i++)
tabs[i].classList.toggle('vwsh-tab--on', tabs[i].getAttribute('data-vwsh-tab') === tab);
updateClearBtn();
load();
}
// Empty the whole current tab (movies / TV / YouTube), after a confirm.
function clearAll() {
var kind = state.tab;
var label = kind === 'movie' ? 'movies' : kind === 'show' ? 'TV episodes' : 'YouTube videos';
var go = function () {
fetch('/api/video/wishlist/clear', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: kind }),
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (res) {
if (res && res.success) {
if (typeof showToast === 'function')
showToast('Cleared ' + (res.removed || 0) + ' ' + label + ' from your wishlist', 'success');
load();
} else if (typeof showToast === 'function') {
showToast('Could not clear wishlist', 'error');
}
}).catch(function () { if (typeof showToast === 'function') showToast('Could not clear wishlist', 'error'); });
};
if (typeof showConfirmDialog === 'function') {
showConfirmDialog({
title: 'Clear wishlist',
message: 'Remove ALL ' + label + ' from your wishlist? This cant be undone.',
confirmText: 'Clear all', cancelText: 'Cancel', destructive: true,
}).then(function (ok) { if (ok) go(); });
} else { go(); }
}
// ── remove (TMDB scopes via /wishlist/remove; YouTube scopes via youtube) ──
function doRemove(btn) {
var scope = btn.getAttribute('data-vwsh-rm');
btn.disabled = true;
var after = function () {
if (typeof showToast === 'function') showToast('Removed from wishlist', 'info');
load();
};
var afterYt = function () { after(); document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
var fail = function () { btn.disabled = false; };
if (scope === 'yt-video') {
VideoYoutube.removeWish('video', btn.getAttribute('data-id')).then(afterYt).catch(fail); return;
}
if (scope === 'yt-channel') { // remove the channel's videos AND unfollow it
var cid = btn.getAttribute('data-yt');
VideoYoutube.unfollow(cid).then(function () { return VideoYoutube.removeWish('channel', cid); })
.then(afterYt).catch(fail); return;
}
if (scope === 'yt-season') { // a "year" = remove every wished video in it
var sh = state.showData[parseInt(btn.getAttribute('data-tmdb'), 10)];
var yr = parseInt(btn.getAttribute('data-s'), 10), ids = [];
if (sh) (sh.seasons || []).forEach(function (se) {
if (se.season_number === yr) (se.episodes || []).forEach(function (ep) { if (ep.source_id) ids.push(ep.source_id); });
});
if (!ids.length) { fail(); return; }
Promise.all(ids.map(function (id) { return VideoYoutube.removeWish('video', id); })).then(afterYt).catch(fail);
return;
}
var body = { scope: scope, tmdb_id: parseInt(btn.getAttribute('data-tmdb'), 10) };
if (btn.hasAttribute('data-s')) body.season_number = parseInt(btn.getAttribute('data-s'), 10);
if (btn.hasAttribute('data-e')) body.episode_number = parseInt(btn.getAttribute('data-e'), 10);
fetch('/api/video/wishlist/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (!d || !d.success) { fail(); return; } after(); })
.catch(fail);
}
function onGridClick(e) {
var rm = e.target.closest('[data-vwsh-rm]');
if (rm) { e.preventDefault(); e.stopPropagation(); doRemove(rm); return; }
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var openCh = e.target.closest('[data-vwsh-open-channel]');
if (openCh) { // YouTube channel → in-app channel page
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: 'channel', source: 'youtube', id: openCh.getAttribute('data-yt') } }));
return;
}
var open = e.target.closest('[data-vwsh-open-show], [data-vwsh-open-movie], [data-vwsh-open-person]');
if (open) {
var person = open.hasAttribute('data-vwsh-open-person');
document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', {
detail: { kind: person ? 'person' : (open.hasAttribute('data-vwsh-open-show') ? 'show' : 'movie'),
id: parseInt(person ? open.getAttribute('data-id') : open.getAttribute('data-vwsh-id'), 10),
source: person ? 'tmdb' : (open.getAttribute('data-vwsh-src') || 'tmdb') },
}));
return;
}
var epc = e.target.closest('[data-vwsh-ep]');
if (epc) { // episode → SELECT it (drives the info bar); click again to deselect
var eg = epc.closest('.wl-orb-group');
var etmdb = parseInt(epc.getAttribute('data-tmdb'), 10);
var eWasSel = epc.classList.contains('vwsh-epc--sel');
if (eg) { var es = eg.querySelectorAll('.vwsh-epc'); for (var j = 0; j < es.length; j++) es[j].classList.remove('vwsh-epc--sel'); }
if (eWasSel) { renderInfoBar(eg, etmdb, null); return; } // back to show synopsis + cast
epc.classList.add('vwsh-epc--sel');
renderInfoBar(eg, etmdb, findEpisode(etmdb, parseInt(epc.getAttribute('data-s'), 10), parseInt(epc.getAttribute('data-e'), 10)));
return;
}
var orb = e.target.closest('[data-vwsh-orb]');
if (orb) { // show → reveal seasons + lazily load the synopsis/cast info bar
var g = orb.closest('.wl-orb-group');
if (!g) return;
// expand → load info bar; collapse → clear it (the synopsis/cast live in
// the always-present header, so they must be wiped or they linger).
if (g.classList.toggle('expanded')) loadShowInfo(g);
else clearInfoBar(g);
}
}
// Empty the info bar + drop any episode selection (so the :empty side columns
// collapse). Used when an orb closes.
function clearInfoBar(group) {
if (!group) return;
var syn = group.querySelector('[data-vwsh-syn]'); if (syn) syn.innerHTML = '';
var cast = group.querySelector('[data-vwsh-cast]'); if (cast) cast.innerHTML = '';
var sel = group.querySelectorAll('.vwsh-epc--sel');
for (var i = 0; i < sel.length; i++) sel[i].classList.remove('vwsh-epc--sel');
}
function wire() {
var tabs = document.querySelectorAll('[data-vwsh-tab]');
for (var i = 0; i < tabs.length; i++) (function (b) {
b.addEventListener('click', function () { setTab(b.getAttribute('data-vwsh-tab')); });
})(tabs[i]);
var grid = $('[data-vwsh-grid]'); if (grid) grid.addEventListener('click', onGridClick);
var search = $('[data-vwsh-search]');
if (search) search.addEventListener('input', function () {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(function () { state.search = search.value.trim(); state.page = 1; load(); }, 250);
});
var sortSel = $('[data-vwsh-sort]');
if (sortSel) sortSel.addEventListener('change', function () { state.sort = sortSel.value; state.page = 1; load(); });
var clearBtn = $('[data-vwsh-clear]');
if (clearBtn) clearBtn.addEventListener('click', clearAll);
var prev = $('[data-vwsh-prev]');
if (prev) prev.addEventListener('click', function () { if (state.page > 1) { state.page--; load(); } });
var next = $('[data-vwsh-next]');
if (next) next.addEventListener('click', function () { state.page++; load(); });
// Adds elsewhere (the get-modal) refresh the badge + page if visible.
document.addEventListener('soulsync:video-wishlist-changed', function () {
var g = $('[data-vwsh-grid]');
if (g && g.offsetParent !== null) load(); else { refreshBadge(); refreshYtCount(); }
});
}
function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); refreshYtCount(); } }
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onShown);
refreshBadge();
refreshYtCount();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();