/* 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); } // 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); 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 ''; } return '' + ini + ''; } // A single wished video tile (thumbnail, title, date, remove). function videoCard(v) { var thumb = v.still_url ? '' : ''; var date = fmtDate(v.published_at); return '
' + '
' + thumb + '
' + '
' + '' + esc(v.title || 'Untitled') + '' + (date ? '' + esc(date) + '' : '') + '
'; } // 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 ? '' : ''; }).join(''); var sub = []; if (ch.handle) sub.push(esc(ch.handle)); if (ch.video_count != null) sub.push(esc(ch.video_count) + ' videos'); return '
' + '
' + avatar(ch, 'vyt-chip-avatar') + '
' + 'YouTube channel' + '' + esc(ch.title) + '' + (sub.length ? '' + sub.join(' · ') + '' : '') + '
' + followBtn(following) + '
' + (strip ? '
' + strip + '
' : '') + '
'; } function followBtn(following) { return ''; } // 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; }); } // 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 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, videos: p.videos } }); } 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 }); } 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 '' + '' + avatar(ch, 'vyt-result-avatar') + '' + '' + 'YouTube' + '' + esc(ch.title) + '' + (sub ? '' + sub + '' : '') + ''; } 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); }); } // ── Per-channel settings modal (cog on the watchlist Channels tab) ──────── // Custom show-name (the $channel folder token used when its videos download) + an // optional quality override that beats the global YouTube quality for this channel. var _RES = ['best', '4320p', '2160p', '1440p', '1080p', '720p', '480p', '360p']; var _COD = ['any', 'av1', 'vp9', 'h264']; var _CON = ['mp4', 'mkv', 'webm']; function _opt(list, sel) { return list.map(function (v) { return ''; }).join(''); } function _csetForm(title, s, q, dq, kind) { var on = !!s.quality; // a stored override → start enabled var b = (q && q.max_resolution) ? q : dq; // seed fields from override, else default b = b || {}; var noun = kind === 'playlist' ? 'playlist' : 'channel'; return '' + '' + '' + '
Overrides the $channel folder/show name when this ' + noun + '’s videos download. Blank = use the ' + noun + '’s real name.
' + '' + '
' + '
Max resolution
' + '
Codec
' + '
Container
' + '' + '' + '
' + '
Off = use the global YouTube quality from Settings.
'; } function openChannelSettings(channelId, title, kind) { if (!channelId) return; var heading = (kind === 'playlist' ? 'Playlist' : 'Channel') + ' settings'; var prev = document.getElementById('vyt-cset-overlay'); if (prev) prev.remove(); var ov = document.createElement('div'); ov.id = 'vyt-cset-overlay'; ov.className = 'vyt-cset-overlay'; ov.innerHTML = ''; document.body.appendChild(ov); function close() { ov.remove(); } ov.addEventListener('click', function (e) { if (e.target === ov) close(); }); ov.querySelector('.vyt-cset-x').addEventListener('click', close); var body = ov.querySelector('.vyt-cset-body'), saveBtn = ov.querySelector('.vyt-cset-save'); fetch('/api/video/youtube/channel/' + encodeURIComponent(channelId) + '/settings', { headers: { Accept: 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (d) { var s = (d && d.settings) || {}, dq = (d && d.default_quality) || {}; body.innerHTML = _csetForm(title, s, s.quality || {}, dq, kind); saveBtn.disabled = false; var qon = body.querySelector('[data-cset-qon]'), qbox = body.querySelector('[data-cset-q]'); if (qon && qbox) qon.addEventListener('change', function () { qbox.hidden = !qon.checked; }); }) .catch(function () { body.innerHTML = 'Could not load settings.'; }); saveBtn.addEventListener('click', function () { var nameEl = body.querySelector('[data-cset-name]'); var payload = { custom_name: (nameEl ? nameEl.value : '').trim() }; var qon = body.querySelector('[data-cset-qon]'); if (qon && qon.checked) { payload.quality = { max_resolution: body.querySelector('[data-cset-res]').value, video_codec: body.querySelector('[data-cset-cod]').value, container: body.querySelector('[data-cset-con]').value, prefer_60fps: body.querySelector('[data-cset-fps]').checked, allow_hdr: body.querySelector('[data-cset-hdr]').checked, }; } saveBtn.disabled = true; fetch('/api/video/youtube/channel/' + encodeURIComponent(channelId) + '/settings', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(payload) }) .then(function (r) { return r.json(); }) .then(function () { if (typeof showToast === 'function') showToast('Channel settings saved', 'success'); close(); }) .catch(function () { saveBtn.disabled = false; if (typeof showToast === 'function') showToast('Save failed', 'error'); }); }); } window.VideoYoutube = { esc: esc, isChannelRef: isChannelRef, isPlaylistRef: isPlaylistRef, fmtDate: fmtDate, avatar: avatar, img: img, fmtDuration: fmtDuration, compactCount: compactCount, 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, openChannelSettings: openChannelSettings, }; })();