Two reports: - 'Following · 0 videos added' toast: following from the channel page sent a stub with no videos, so 0 were wished. Watchlisting a channel shouldn't bulk- wish its whole catalog anyway (that conflates watchlist/track with wishlist/ acquire, unlike TV shows). follow() now sends only the channel fields and the toast reads 'Added to watchlist'. Videos are wished explicitly via + Wish. - '90 videos' in every channel header: that was just our fetch cap (limit=90), not the real total — YouTube no longer exposes a trustworthy channel video count (videoCountText reports a shelf count: 142 for Veritasium, 71 for MrBeast). Dropped it; the accurate subscriber count already conveys the channel's scale.
170 lines
8.3 KiB
JavaScript
170 lines
8.3 KiB
JavaScript
/* Shared YouTube-channel helpers for the video side: detect a pasted channel
|
|
* link, render channel/video cards, and follow/unfollow. Used by the search
|
|
* page (paste a link → Follow), the wishlist YouTube tab, and the watchlist.
|
|
* Scoped under window.VideoYoutube; all CSS is .vyt-* (never touches music wl-*).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
});
|
|
}
|
|
|
|
// A pasted YouTube *channel* reference: a full channel URL or a bare @handle.
|
|
// Deliberately NOT bare words, so normal title searches don't trigger it.
|
|
function isChannelRef(q) {
|
|
q = (q || '').trim();
|
|
if (/^@[\w.\-]{2,}$/.test(q)) return true;
|
|
if (!/youtube\.com/i.test(q)) return false;
|
|
return /youtube\.com\/(@[\w.\-]+|channel\/UC[\w\-]+|c\/[\w.\-]+|user\/[\w.\-]+)/i.test(q);
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
if (!iso) return '';
|
|
var d = new Date(iso.length <= 10 ? iso + 'T00:00:00' : iso);
|
|
if (isNaN(d)) return '';
|
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
function initials(s) {
|
|
var w = String(s || '').trim().split(/\s+/).filter(Boolean);
|
|
return (w.slice(0, 2).map(function (x) { return x[0]; }).join('') || '▶').toUpperCase();
|
|
}
|
|
|
|
// Route YouTube CDN images through our same-origin proxy so hotlink/CORS
|
|
// policy can't blank them out. Non-YouTube / already-proxied urls pass through.
|
|
function img(url) {
|
|
if (!url) return url;
|
|
if (url.indexOf('//') === 0) url = 'https:' + url; // protocol-relative
|
|
if (/^https:\/\/([\w-]+\.)*(ytimg\.com|ggpht\.com|googleusercontent\.com)\//i.test(url))
|
|
return '/api/video/img?u=' + encodeURIComponent(url);
|
|
return url;
|
|
}
|
|
|
|
function avatar(ch, cls) {
|
|
var url = ch && (ch.poster_url || ch.avatar_url);
|
|
var ini = esc(initials(ch && ch.title));
|
|
if (url) {
|
|
// If the image can't load, swap to the initials chip (never an empty circle).
|
|
return '<img class="' + cls + '" src="' + esc(img(url)) + '" alt="" loading="lazy" ' +
|
|
'onerror="this.outerHTML=\'<span class="' + cls + ' vyt-avatar--ph">' + ini + '</span>\'">';
|
|
}
|
|
return '<span class="' + cls + ' vyt-avatar--ph">' + ini + '</span>';
|
|
}
|
|
|
|
// A single wished video tile (thumbnail, title, date, remove).
|
|
function videoCard(v) {
|
|
var thumb = v.still_url
|
|
? '<img class="vyt-vid-img" src="' + esc(v.still_url) + '" alt="" loading="lazy" ' +
|
|
'onerror="this.parentNode.classList.add(\'vyt-vid-thumb--none\')">'
|
|
: '';
|
|
var date = fmtDate(v.published_at);
|
|
return '<div class="vyt-vid" data-vyt-vid="' + esc(v.youtube_id) + '">' +
|
|
'<div class="vyt-vid-thumb' + (v.still_url ? '' : ' vyt-vid-thumb--none') + '">' + thumb +
|
|
'<button class="vyt-vid-rm" type="button" data-vyt-rm="video" data-id="' + esc(v.youtube_id) +
|
|
'" title="Remove">✕</button></div>' +
|
|
'<div class="vyt-vid-body">' +
|
|
'<span class="vyt-vid-title" title="' + esc(v.title) + '">' + esc(v.title || 'Untitled') + '</span>' +
|
|
(date ? '<span class="vyt-vid-date">' + esc(date) + '</span>' : '') +
|
|
'</div></div>';
|
|
}
|
|
|
|
// The search-page result: avatar, title/handle, a strip of recent stills, Follow.
|
|
function searchCard(ch, following) {
|
|
var strip = (ch.videos || []).slice(0, 6).map(function (v) {
|
|
return v.thumbnail_url
|
|
? '<span class="vyt-strip-cell"><img src="' + esc(img(v.thumbnail_url)) + '" alt="" loading="lazy" ' +
|
|
'onerror="this.parentNode.style.display=\'none\'"></span>'
|
|
: '';
|
|
}).join('');
|
|
var sub = [];
|
|
if (ch.handle) sub.push(esc(ch.handle));
|
|
if (ch.video_count != null) sub.push(esc(ch.video_count) + ' videos');
|
|
return '<div class="vyt-chip" data-vyt-channel="' + esc(ch.youtube_id) + '">' +
|
|
'<div class="vyt-chip-head">' +
|
|
avatar(ch, 'vyt-chip-avatar') +
|
|
'<div class="vyt-chip-meta">' +
|
|
'<span class="vyt-chip-badge">YouTube channel</span>' +
|
|
'<span class="vyt-chip-title">' + esc(ch.title) + '</span>' +
|
|
(sub.length ? '<span class="vyt-chip-sub">' + sub.join(' · ') + '</span>' : '') +
|
|
'</div>' +
|
|
followBtn(following) +
|
|
'</div>' +
|
|
(strip ? '<div class="vyt-strip">' + strip + '</div>' : '') +
|
|
'</div>';
|
|
}
|
|
|
|
function followBtn(following) {
|
|
return '<button class="vyt-follow' + (following ? ' vyt-follow--on' : '') + '" type="button" ' +
|
|
'data-vyt-follow>' + (following ? '✓ Following' : '+ Follow') + '</button>';
|
|
}
|
|
|
|
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; });
|
|
}
|
|
|
|
// Following a channel WATCHLISTS it (like a show) — it doesn't auto-wish all
|
|
// its videos, so we send only the channel fields, not its video list.
|
|
function follow(channel) {
|
|
var ch = channel || {};
|
|
return post('/api/video/youtube/follow',
|
|
{ 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 removeWish(scope, sourceId) {
|
|
return post('/api/video/youtube/wishlist/remove', { scope: scope, source_id: sourceId });
|
|
}
|
|
function addVideos(channel, videos) {
|
|
return post('/api/video/youtube/wishlist/add', { channel: channel, videos: videos });
|
|
}
|
|
|
|
// mm:ss / h:mm:ss from a seconds count
|
|
function fmtDuration(sec) {
|
|
sec = parseInt(sec, 10);
|
|
if (!sec || sec < 0) return '';
|
|
var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
|
|
var mm = (h && m < 10) ? '0' + m : '' + m, ss = s < 10 ? '0' + s : '' + s;
|
|
return (h ? h + ':' + mm : m + '') + ':' + ss;
|
|
}
|
|
function compactCount(n) {
|
|
n = parseInt(n, 10);
|
|
if (!n && n !== 0) return '';
|
|
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + 'B';
|
|
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
|
|
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
|
|
return '' + n;
|
|
}
|
|
|
|
function searchChannels(q) {
|
|
return fetch('/api/video/youtube/search?q=' + encodeURIComponent(q), { headers: { Accept: 'application/json' } })
|
|
.then(function (r) { return r.ok ? r.json() : null; });
|
|
}
|
|
|
|
// A compact channel card for the search results grid (→ opens the channel page).
|
|
function channelResultCard(ch) {
|
|
var sub = ch.subscriber_count ? compactCount(ch.subscriber_count) + ' subscribers' : (ch.handle ? esc(ch.handle) : '');
|
|
return '<a class="vyt-result" href="#" data-vyt-open-channel="' + esc(ch.youtube_id) + '">' +
|
|
'<span class="vyt-result-art">' + avatar(ch, 'vyt-result-avatar') + '</span>' +
|
|
'<span class="vyt-result-info">' +
|
|
'<span class="vyt-result-badge">YouTube</span>' +
|
|
'<span class="vyt-result-title" title="' + esc(ch.title) + '">' + esc(ch.title) + '</span>' +
|
|
(sub ? '<span class="vyt-result-sub">' + sub + '</span>' : '') +
|
|
'</span></a>';
|
|
}
|
|
|
|
function resolve(ref) {
|
|
return fetch('/api/video/youtube/resolve?url=' + encodeURIComponent(ref),
|
|
{ headers: { Accept: 'application/json' } }).then(function (r) { return r.ok ? r.json() : (r.status === 404 ? r.json() : null); });
|
|
}
|
|
|
|
window.VideoYoutube = {
|
|
esc: esc, isChannelRef: isChannelRef, 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,
|
|
searchChannels: searchChannels, channelResultCard: channelResultCard,
|
|
};
|
|
})();
|