From ef3d4cfdb421cb04a99c2d0fdc0e0058519bfffd Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 17 Jun 2026 20:33:31 -0700 Subject: [PATCH] =?UTF-8?q?YouTube=20playlists=20on=20the=20watchlist=20?= =?UTF-8?q?=E2=80=94=20frontend=20(search,=20detail,=20watchlist)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search: paste a playlist link → it resolves to a 'YouTube playlist' chip (cover, owner, count) with Add-to-watchlist; clicking the chip opens it. (isPlaylistRef + playlistCard + followPlaylist/unfollowPlaylist helpers.) - routing: /video-detail/youtube/playlist/ deep-links like a channel (string id); the open-detail event handles kind='playlist'. - detail view: a FLAT list in the curator's order (no year-seasons, no season nav / view toggle, no catalog streaming — it's a partial set). Billboard shows 'Playlist · N videos · owner'. Reuses the show-detail shell + the new duration/views cards. - watchlist: followed playlists sit beside channels in the Channels tab (rounded square + a list badge), open on click, ✕ to unfollow. Live-validated (Lex Fridman playlist resolves + renders in order). The 2 shell test 'failures' are the pre-existing window.-assertion (no new globals added). --- webui/static/video/video-detail.js | 66 ++++++++++++++++++++++++--- webui/static/video/video-search.js | 58 +++++++++++++++++++---- webui/static/video/video-side.css | 5 ++ webui/static/video/video-side.js | 3 +- webui/static/video/video-watchlist.js | 41 ++++++++++++++--- webui/static/video/video-youtube.js | 46 +++++++++++++++++-- 6 files changed, 193 insertions(+), 26 deletions(-) diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index b5858a70..30c614c8 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -164,13 +164,19 @@ var meta = []; if (d.source === 'youtube') { - meta.push('YouTube'); + var isPl = d.kind === 'playlist'; + meta.push('' + (isPl ? 'Playlist' : 'YouTube') + ''); var yc = window.VideoYoutube; - var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('' + subs + ' subscribers'); - // NB: no "N videos" — YouTube doesn't expose a reliable total, and what we - // fetch is just the recent page (the cap), so showing it would mislead. - var views = yc && yc.compactCount(d.view_count); if (views) meta.push('' + views + ' views'); - if (d.handle) meta.push('' + esc(d.handle) + ''); + if (isPl) { + // A playlist IS a known, fixed list, so its count is real (unlike a + // channel's total). Owner shows as a genre tag below. + if (d.video_count != null) meta.push('' + esc(d.video_count) + ' videos'); + } else { + var subs = yc && yc.compactCount(d.subscriber_count); if (subs) meta.push('' + subs + ' subscribers'); + // NB: no "N videos" for a CHANNEL — YouTube doesn't expose a reliable total. + var views = yc && yc.compactCount(d.view_count); if (views) meta.push('' + views + ' views'); + if (d.handle) meta.push('' + esc(d.handle) + ''); + } var mm = q('[data-vd-meta]'); if (mm) mm.innerHTML = meta.join(''); renderActions(d); var ll = q('[data-vd-links]'); if (ll) ll.innerHTML = ''; @@ -869,6 +875,7 @@ function renderViewToggle() { var host = q('[data-vd-view-toggle]'); if (!host) return; + if (data && data.kind === 'playlist') { host.innerHTML = ''; return; } // flat list — no view toggle host.innerHTML = VIEWS.map(function (v) { return '' + + '
' + + esc(pl.title) + 'Playlist
'; + } + function $(s, r) { return (r || document).querySelector(s); } function esc(s) { return String(s == null ? '' : s) @@ -122,7 +134,7 @@ var grid = $('[data-vwlp-grid]'); if (state.tab === 'channel') { grid.classList.add('vyt-wgrid'); - grid.innerHTML = items.map(channelCard).join(''); + grid.innerHTML = items.map(function (it) { return it.playlist_id ? playlistCard(it) : channelCard(it); }).join(''); return; } grid.classList.remove('vyt-wgrid'); @@ -144,12 +156,12 @@ .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { if (ld) ld.classList.add('hidden'); - var chans = (d && d.channels) || []; - state.channelCount = chans.length; - var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = chans.length; - render(chans); + var all = ((d && d.channels) || []).concat((d && d.playlists) || []); // channels + playlists + state.channelCount = all.length; + var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = all.length; + render(all); updatePagination(null); - updateEmpty(chans.length); + updateEmpty(all.length); }) .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); }); } @@ -253,6 +265,16 @@ }).catch(function () { unf.disabled = false; }); return; } + var punf = e.target.closest('[data-vyt-wunfollow-playlist]'); + if (punf && window.VideoYoutube) { + e.preventDefault(); e.stopPropagation(); + punf.disabled = true; + VideoYoutube.unfollowPlaylist(punf.getAttribute('data-vyt-wunfollow-playlist')).then(function () { + if (typeof showToast === 'function') showToast('Unfollowed', 'info'); + load(); + }).catch(function () { punf.disabled = false; }); + return; + } if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var ch = e.target.closest('[data-vyt-open-channel]'); if (ch) { @@ -261,6 +283,13 @@ detail: { kind: 'channel', source: 'youtube', id: ch.getAttribute('data-vyt-open-channel') } })); return; } + var pl = e.target.closest('[data-vyt-open-playlist]'); + if (pl) { + e.preventDefault(); + document.dispatchEvent(new CustomEvent('soulsync:video-open-detail', { + detail: { kind: 'playlist', source: 'youtube', id: pl.getAttribute('data-vyt-open-playlist') } })); + return; + } var card = e.target.closest('[data-vwlp-open]'); if (!card) return; e.preventDefault(); diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index 01205c65..0bd9e451 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -21,6 +21,15 @@ return /youtube\.com\/(@[\w.\-]+|channel\/UC[\w\-]+|c\/[\w.\-]+|user\/[\w.\-]+)/i.test(q); } + // A pasted YouTube *playlist* link (or bare PL/OL/FL/UU id). Rejects mixes (RD…) + // and personal lists (WL/LL) — same rule as the backend parse_playlist_id. + function isPlaylistRef(q) { + q = (q || '').trim(); + var m = /[?&]list=([A-Za-z0-9_-]+)/.exec(q); + var id = m ? m[1] : (/^(PL|OL|FL|UU)[A-Za-z0-9_-]{8,}$/.test(q) ? q : null); + return !!(id && !/^(RD|UL|LL|WL)/.test(id)); + } + function fmtDate(iso) { if (!iso) return ''; var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso); @@ -101,6 +110,28 @@ 'data-vyt-follow>' + (following ? '✓ Following' : '+ Follow') + ''; } + // A pasted-playlist result chip (mirrors searchCard): cover, title, owner + + // count, and an Add-to-watchlist toggle (data-vyt-follow-playlist). + function playlistCard(pl, following) { + var cover = pl.thumbnail_url + ? '' + : ''; + var sub = []; + if (pl.channel_title) sub.push(esc(pl.channel_title)); + if (pl.video_count != null) sub.push(esc(pl.video_count) + ' videos'); + return '
' + + '
' + cover + + '
' + + 'YouTube playlist' + + '' + esc(pl.title) + '' + + (sub.length ? '' + sub.join(' · ') + '' : '') + + '
' + + '' + + '
'; + } + function post(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; }); @@ -114,6 +145,14 @@ { channel: { youtube_id: ch.youtube_id, title: ch.title, avatar_url: ch.avatar_url } }); } function unfollow(youtubeId) { return post('/api/video/youtube/unfollow', { youtube_id: youtubeId }); } + function followPlaylist(pl) { + var p = pl || {}; + return post('/api/video/youtube/playlist/follow', { playlist: { + playlist_id: p.playlist_id, title: p.title, thumbnail_url: p.thumbnail_url } }); + } + function unfollowPlaylist(playlistId) { + return post('/api/video/youtube/playlist/unfollow', { playlist_id: playlistId }); + } function removeWish(scope, sourceId) { return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId }); } @@ -161,10 +200,11 @@ } window.VideoYoutube = { - esc: esc, isChannelRef: isChannelRef, fmtDate: fmtDate, avatar: avatar, img: img, + esc: esc, isChannelRef: isChannelRef, isPlaylistRef: isPlaylistRef, fmtDate: fmtDate, avatar: avatar, img: img, fmtDuration: fmtDuration, compactCount: compactCount, - videoCard: videoCard, searchCard: searchCard, followBtn: followBtn, - follow: follow, unfollow: unfollow, removeWish: removeWish, addVideos: addVideos, resolve: resolve, + videoCard: videoCard, searchCard: searchCard, playlistCard: playlistCard, followBtn: followBtn, + follow: follow, unfollow: unfollow, followPlaylist: followPlaylist, unfollowPlaylist: unfollowPlaylist, + removeWish: removeWish, addVideos: addVideos, resolve: resolve, searchChannels: searchChannels, channelResultCard: channelResultCard, }; })();