diff --git a/webui/index.html b/webui/index.html
index a683ebf0..0a6534e9 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -1021,6 +1021,9 @@
+
@@ -1073,6 +1076,9 @@
+
@@ -9718,6 +9724,9 @@
+
+
diff --git a/webui/static/video/video-search.js b/webui/static/video/video-search.js
index c5f8fb18..505ed236 100644
--- a/webui/static/video/video-search.js
+++ b/webui/static/video/video-search.js
@@ -25,6 +25,7 @@
var timer = null;
var wired = false;
var trendingCache = null; // null = not fetched; [] = fetched/empty
+ var lastChannel = null; // resolved YouTube channel awaiting a Follow
function $(sel) { return document.querySelector(sel); }
function esc(s) {
@@ -146,6 +147,34 @@
});
}
+ // A pasted YouTube channel link → resolve + render a Follow chip instead of
+ // a normal title search (the obscure-channel entry point).
+ function runChannel(ref) {
+ var seq = ++reqSeq;
+ show('[data-video-search-loading]', true);
+ VideoYoutube.resolve(ref).then(function (d) {
+ if (seq !== reqSeq) return;
+ show('[data-video-search-loading]', false);
+ show('[data-video-search-hint]', false);
+ var host = $('[data-video-search-results]'); if (!host) return;
+ if (!d || !d.success || !d.channel) {
+ show('[data-video-search-empty]', false);
+ host.innerHTML = '
' +
+ 'Couldn’t read that channel. Paste a channel link like ' +
+ 'youtube.com/@handle.
';
+ return;
+ }
+ lastChannel = d.channel;
+ show('[data-video-search-empty]', false);
+ host.innerHTML = '' +
+ '▶YouTube channel
' +
+ '
' + VideoYoutube.searchCard(d.channel, d.following) + '
';
+ }).catch(function () {
+ if (seq !== reqSeq) return;
+ show('[data-video-search-loading]', false);
+ });
+ }
+
function onInput(val) {
var q = (val || '').trim();
lastQuery = q;
@@ -156,9 +185,35 @@
showIdle(); // back to the trending rail
return;
}
+ if (window.VideoYoutube && VideoYoutube.isChannelRef(q)) {
+ timer = setTimeout(function () { runChannel(q); }, 360);
+ return;
+ }
timer = setTimeout(function () { runSearch(q); }, 320);
}
+ // Follow / un-follow the resolved channel chip.
+ function toggleFollow(btn) {
+ if (!lastChannel) return;
+ var on = btn.classList.contains('vyt-follow--on');
+ btn.disabled = true;
+ var done = function () { btn.disabled = false; document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed')); };
+ if (on) {
+ VideoYoutube.unfollow(lastChannel.youtube_id).then(function () {
+ btn.classList.remove('vyt-follow--on'); btn.innerHTML = '+ Follow'; done();
+ }).catch(function () { btn.disabled = false; });
+ } else {
+ VideoYoutube.follow(lastChannel).then(function (d) {
+ if (d && d.success) {
+ btn.classList.add('vyt-follow--on'); btn.innerHTML = '✓ Following';
+ if (typeof showToast === 'function')
+ showToast('Following ' + lastChannel.title + ' · ' + (d.added_videos || 0) + ' videos added', 'success');
+ }
+ done();
+ }).catch(function () { btn.disabled = false; });
+ }
+ }
+
function openCard(card) {
var kind = card.getAttribute('data-vsr-open');
var id = parseInt(card.getAttribute('data-vsr-id'), 10);
@@ -182,6 +237,8 @@
if (results) {
results.addEventListener('click', function (e) {
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ var fb = e.target.closest('[data-vyt-follow]');
+ if (fb && results.contains(fb)) { e.preventDefault(); toggleFollow(fb); return; }
var card = e.target.closest('[data-vsr-open]');
if (!card || !results.contains(card)) return;
e.preventDefault();
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index dc94829f..1643a7f9 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -2707,3 +2707,95 @@ body[data-side="video"] #soulsync-toggle { display: none; }
.vwsh-head, .vwsh-toolbar, .vwsh-body { padding-left: 18px; padding-right: 18px; }
.vwsh-grid--movies { grid-template-columns: repeat(auto-fill, minmax(128px, 1fr)); gap: 18px 14px; }
}
+
+/* ══ YouTube channels (search chip · wishlist channel blocks · watchlist cards) ══
+ Scoped .vyt-* — never touches music wl-* or the TMDB vwsh-* nebula. */
+.vyt-avatar--ph { display: inline-flex; align-items: center; justify-content: center;
+ background: linear-gradient(135deg, #ff3b3b, #b3122a); color: #fff; font-weight: 800; }
+
+/* ── Search page: the pasted-channel Follow chip ─────────────────────────── */
+.vyt-search { margin-top: 4px; }
+.vyt-chip { border-radius: 16px; border: 1px solid rgba(255, 59, 59, 0.28);
+ background: linear-gradient(180deg, rgba(255, 59, 59, 0.07), rgba(255, 255, 255, 0.02));
+ padding: 16px 18px; max-width: 720px; }
+.vyt-chip-head { display: flex; align-items: center; gap: 15px; }
+.vyt-chip-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; object-fit: cover;
+ font-size: 20px; border: 2px solid rgba(255, 59, 59, 0.5); }
+.vyt-chip-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
+.vyt-chip-badge { font-size: 10.5px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase;
+ color: #ff6b6b; }
+.vyt-chip-title { font-size: 19px; font-weight: 800; color: #fff; line-height: 1.15;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.vyt-chip-sub { font-size: 12.5px; font-weight: 600; color: rgba(255, 255, 255, 0.5); }
+.vyt-follow { flex-shrink: 0; border: 0; cursor: pointer; border-radius: 999px; padding: 10px 20px;
+ font-size: 13px; font-weight: 800; background: #ff3b3b; color: #fff; transition: all 0.15s ease;
+ box-shadow: 0 6px 18px rgba(255, 59, 59, 0.35); }
+.vyt-follow:hover { transform: translateY(-1px); background: #ff5252; }
+.vyt-follow:disabled { opacity: 0.6; cursor: default; }
+.vyt-follow--on { background: rgba(255, 255, 255, 0.1); color: #6cd391; box-shadow: none; }
+.vyt-strip { display: flex; gap: 7px; margin-top: 14px; overflow: hidden; }
+.vyt-strip-cell { flex: 1 1 0; min-width: 0; aspect-ratio: 16 / 9; border-radius: 7px; overflow: hidden;
+ background: #16161d; }
+.vyt-strip-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.vyt-miss { padding: 22px; border-radius: 14px; background: rgba(255, 255, 255, 0.03);
+ border: 1px dashed rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.6); font-size: 13.5px; }
+.vyt-miss code { background: rgba(255, 255, 255, 0.08); padding: 2px 7px; border-radius: 6px; color: #fff; }
+
+/* ── Wishlist YouTube tab: channel block = header + flat video feed ───────── */
+.vyt-field { display: flex; flex-direction: column; gap: 16px; }
+.vyt-chan { border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.07);
+ background: rgba(255, 255, 255, 0.02); overflow: hidden; }
+.vyt-chan-hd { display: flex; align-items: center; gap: 14px; padding: 13px 16px; cursor: pointer;
+ transition: background 0.15s ease; }
+.vyt-chan-hd:hover { background: rgba(255, 59, 59, 0.06); }
+.vyt-chan-avatar { flex-shrink: 0; width: 46px; height: 46px; border-radius: 50%; object-fit: cover;
+ font-size: 16px; border: 2px solid rgba(255, 59, 59, 0.45); }
+.vyt-chan-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+.vyt-chan-title { font-size: 15.5px; font-weight: 800; color: #fff; line-height: 1.2;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.vyt-chan-sub { font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.42); }
+.vyt-chan-link { flex-shrink: 0; font-size: 11.5px; font-weight: 800; color: #ff6b6b; text-decoration: none;
+ padding: 6px 11px; border-radius: 999px; border: 1px solid rgba(255, 59, 59, 0.3); }
+.vyt-chan-link:hover { background: rgba(255, 59, 59, 0.12); }
+.vyt-chan-unfollow { flex-shrink: 0; border: 1px solid rgba(255, 255, 255, 0.12); cursor: pointer;
+ background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.6); font-size: 11.5px; font-weight: 700;
+ padding: 6px 13px; border-radius: 999px; transition: all 0.15s ease; }
+.vyt-chan-unfollow:hover { background: rgba(239, 68, 68, 0.85); color: #fff; border-color: transparent; }
+.vyt-chan-chev { flex-shrink: 0; color: rgba(255, 255, 255, 0.35); transition: transform 0.2s ease; }
+.vyt-chan--collapsed .vyt-chan-chev { transform: rotate(-90deg); }
+.vyt-vids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px;
+ padding: 4px 16px 18px; }
+.vyt-chan--collapsed .vyt-vids { display: none; }
+.vyt-vid { border-radius: 11px; overflow: hidden; background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06); }
+.vyt-vid-thumb { position: relative; aspect-ratio: 16 / 9; background: #16161d; }
+.vyt-vid-img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.vyt-vid-thumb--none { background: rgba(255, 255, 255, 0.04); }
+.vyt-vid-thumb--none::after { content: '▶'; position: absolute; inset: 0; display: flex; align-items: center;
+ justify-content: center; font-size: 22px; color: rgba(255, 59, 59, 0.4); }
+.vyt-vid-rm { position: absolute; top: 7px; right: 7px; width: 24px; height: 24px; border-radius: 50%;
+ border: none; background: rgba(0, 0, 0, 0.55); color: rgba(255, 255, 255, 0.75); font-size: 12px;
+ cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: all 0.15s ease; }
+.vyt-vid:hover .vyt-vid-rm { opacity: 1; }
+.vyt-vid-rm:hover { background: rgba(239, 68, 68, 0.9); color: #fff; }
+.vyt-vid-body { padding: 9px 11px 11px; }
+.vyt-vid-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+ font-size: 12.5px; font-weight: 700; color: #fff; line-height: 1.3; }
+.vyt-vid-date { display: block; margin-top: 4px; font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.4); }
+
+/* ── Watchlist Channels tab: avatar cards ────────────────────────────────── */
+.vyt-wgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; }
+.vyt-wcard { position: relative; display: flex; flex-direction: column; align-items: center; text-align: center; }
+.vyt-wcard-art { display: block; width: 118px; height: 118px; border-radius: 50%; overflow: hidden;
+ border: 2px solid rgba(255, 59, 59, 0.4); transition: transform 0.15s ease, box-shadow 0.15s ease; }
+.vyt-wcard-art:hover { transform: translateY(-3px); box-shadow: 0 10px 26px rgba(255, 59, 59, 0.35); }
+.vyt-wcard-avatar { width: 100%; height: 100%; object-fit: cover; font-size: 34px; }
+.vyt-wcard-unfollow { position: absolute; top: 4px; right: 50%; margin-right: -59px; width: 26px; height: 26px;
+ border-radius: 50%; border: none; background: rgba(0, 0, 0, 0.6); color: rgba(255, 255, 255, 0.7);
+ font-size: 12px; cursor: pointer; opacity: 0; transition: all 0.15s ease; }
+.vyt-wcard:hover .vyt-wcard-unfollow { opacity: 1; }
+.vyt-wcard-unfollow:hover { background: rgba(239, 68, 68, 0.9); color: #fff; }
+.vyt-wcard-info { margin-top: 11px; display: flex; flex-direction: column; gap: 2px; max-width: 100%; }
+.vyt-wcard-title { font-size: 13.5px; font-weight: 800; color: #fff; line-height: 1.2;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.vyt-wcard-meta { font-size: 11.5px; font-weight: 600; color: rgba(255, 255, 255, 0.42); }
diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js
index df2ace66..186ae4ce 100644
--- a/webui/static/video/video-watchlist.js
+++ b/webui/static/video/video-watchlist.js
@@ -13,9 +13,23 @@
var PAGE_ID = 'video-watchlist';
var LIMIT = 60;
var state = { loaded: false, tab: 'show', search: '', sort: 'default', page: 1,
- counts: { show: 0, person: 0 } };
+ counts: { show: 0, person: 0 }, channelCount: 0 };
var searchTimer = null;
+ // A followed YouTube channel card (avatar, title, wished-video count, unfollow).
+ function channelCard(ch) {
+ var av = window.VideoYoutube ? VideoYoutube.avatar(ch, 'vyt-wcard-avatar') : '';
+ var n = ch.video_count || 0;
+ return '' +
+ '
' + av + '' +
+ '
' +
+ '
' +
+ esc(ch.title) + '' + n + ' video' + (n === 1 ? '' : 's') +
+ '
';
+ }
+
function $(s, r) { return (r || document).querySelector(s); }
function esc(s) {
return String(s == null ? '' : s)
@@ -97,27 +111,52 @@
if (empty) empty.classList.toggle('hidden', total > 0);
var et = $('[data-vwlp-empty-title]');
if (et && total === 0) {
- et.textContent = state.search
- ? 'No matches'
- : (state.tab === 'show' ? 'No shows on your watchlist yet' : 'No people on your watchlist yet');
+ et.textContent = state.search ? 'No matches'
+ : state.tab === 'show' ? 'No shows on your watchlist yet'
+ : state.tab === 'person' ? 'No people on your watchlist yet'
+ : 'No channels followed yet — paste a channel link on the Search page';
}
}
function render(items) {
+ var grid = $('[data-vwlp-grid]');
+ if (state.tab === 'channel') {
+ grid.classList.add('vyt-wgrid');
+ grid.innerHTML = items.map(channelCard).join('');
+ return;
+ }
+ grid.classList.remove('vyt-wgrid');
// Everything on this page is watched — seed the shared cache so the eyes
// paint "watched" with no flash.
if (window.VideoWatchlist) {
items.forEach(function (it) { VideoWatchlist._watched[state.tab][it.tmdb_id] = true; });
}
- var grid = $('[data-vwlp-grid]');
if (grid) {
grid.innerHTML = items.map(function (it) { return cardHTML(it, state.tab); }).join('');
if (window.VideoWatchlist) VideoWatchlist.hydrate(grid);
}
}
+ // Followed YouTube channels live on their own endpoint, not /watchlist.
+ function loadChannels() {
+ var ld = $('[data-vwlp-loading]'); if (ld) ld.classList.remove('hidden');
+ fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
+ .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);
+ updatePagination(null);
+ updateEmpty(chans.length);
+ })
+ .catch(function () { if (ld) ld.classList.add('hidden'); render([]); updateEmpty(0); });
+ }
+
function load() {
state.loaded = true;
+ if (state.tab === 'channel') { loadChannels(); return; }
var ld = $('[data-vwlp-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 });
@@ -137,7 +176,7 @@
}
function setTab(tab) {
- if (tab !== 'show' && tab !== 'person') return;
+ if (tab !== 'show' && tab !== 'person' && tab !== 'channel') return;
state.tab = tab; state.page = 1;
var tabs = document.querySelectorAll('[data-vwlp-tab]');
for (var i = 0; i < tabs.length; i++)
@@ -181,12 +220,40 @@
if (next) next.addEventListener('click', function () { state.page++; load(); });
document.addEventListener('soulsync:video-watchlist-changed', onChanged);
+ // Following a channel fires the wishlist-changed event — keep the
+ // Channels tab + badge current too.
+ document.addEventListener('soulsync:video-wishlist-changed', function () {
+ if (state.tab === 'channel') { var g = $('[data-vwlp-grid]'); if (g && g.offsetParent !== null) { load(); return; } }
+ refreshChannelCount();
+ });
+ }
+
+ function refreshChannelCount() {
+ fetch('/api/video/youtube/channels', { headers: { Accept: 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ var n = (d && d.channels) ? d.channels.length : 0;
+ state.channelCount = n;
+ var cc = $('[data-vwlp-count-channel]'); if (cc) cc.textContent = n;
+ })
+ .catch(function () { /* ignore */ });
}
// Intercept card clicks → in-app SPA navigation (a bare would do a
// FULL page reload). The eye button's capture-phase handler already stops its
// own clicks from reaching here. Mirrors video-library.js.
function onGridClick(e) {
+ var unf = e.target.closest('[data-vyt-wunfollow]');
+ if (unf && window.VideoYoutube) {
+ e.preventDefault(); e.stopPropagation();
+ unf.disabled = true;
+ VideoYoutube.unfollow(unf.getAttribute('data-vyt-wunfollow')).then(function () {
+ if (typeof showToast === 'function') showToast('Unfollowed', 'info');
+ load();
+ }).catch(function () { unf.disabled = false; });
+ return;
+ }
+ if (e.target.closest('.vyt-wcard-art')) return; // let the YouTube link open
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
var card = e.target.closest('[data-vwlp-open]');
if (!card) return;
@@ -200,12 +267,13 @@
}));
}
- function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
+ function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); refreshChannelCount(); } }
function init() {
wire();
document.addEventListener('soulsync:video-page-shown', onShown);
refreshBadge(); // seed the nav badge on boot
+ refreshChannelCount();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
diff --git a/webui/static/video/video-wishlist.js b/webui/static/video/video-wishlist.js
index 3277402b..8cfa3bd9 100644
--- a/webui/static/video/video-wishlist.js
+++ b/webui/static/video/video-wishlist.js
@@ -13,7 +13,29 @@
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 }, showData: {}, showInfo: {} };
+ counts: { movie: 0, show: 0, episode: 0 }, ytChannel: 0, ytVideo: 0,
+ showData: {}, showInfo: {} };
+
+ // ── YouTube channel block (channel = header, videos = flat feed) ───────────
+ function ytChannelBlock(ch) {
+ var av = window.VideoYoutube ? VideoYoutube.avatar(ch, 'vyt-chan-avatar') : '';
+ var vids = (window.VideoYoutube && (ch.videos || []).map(VideoYoutube.videoCard).join('')) || '';
+ var n = ch.video_count || (ch.videos || []).length;
+ return '' +
+ '
' + av +
+ '
' +
+ '' + esc(ch.title) + '' +
+ '' + n + ' video' + (n === 1 ? '' : 's') + '' +
+ '
' +
+ '
YouTube ↗' +
+ '
' +
+ '
▾' +
+ '
' +
+ '
' + vids + '
' +
+ '
';
+ }
var searchTimer = null;
function $(s, r) { return (r || document).querySelector(s); }
@@ -228,14 +250,17 @@
function render(items) {
var grid = $('[data-vwsh-grid]'); if (!grid) return;
var shows = state.tab === 'show';
+ var yt = state.tab === 'youtube';
grid.classList.toggle('wl-nebula-field', shows);
grid.classList.toggle('vwsh-nebula', shows); // video-only scope so music wl-* is untouched
- grid.classList.toggle('vwsh-grid--movies', !shows);
+ grid.classList.toggle('vwsh-grid--movies', state.tab === 'movie');
+ grid.classList.toggle('vyt-field', yt);
state.showData = {};
if (shows) items.forEach(function (sh) { state.showData[sh.tmdb_id] = sh; }); // for the episode area
- grid.innerHTML = shows
- ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
- : items.map(movieCard).join('');
+ grid.innerHTML = yt
+ ? items.map(ytChannelBlock).join('')
+ : shows ? items.map(function (sh, i) { return nebulaOrb(sh, i); }).join('')
+ : items.map(movieCard).join('');
}
// ── counts / badges / pager ───────────────────────────────────────────────
@@ -247,9 +272,27 @@
updateBadges(counts && counts.total != null ? counts.total : (state.counts.movie + state.counts.episode));
updateSub();
}
+ 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();
+ }
+ // 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');
@@ -283,12 +326,33 @@
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' : 'No TV episodes on your wishlist yet');
+ : 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' } })
@@ -326,7 +390,7 @@
}
function setTab(tab) {
- if (tab !== 'movie' && tab !== 'show') return;
+ 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]');
@@ -353,7 +417,29 @@
.catch(function () { btn.disabled = false; });
}
+ // YouTube remove: a single video, or unfollow a channel (which also clears it).
+ function doYtRemove(btn) {
+ if (!window.VideoYoutube) return;
+ var scope = btn.getAttribute('data-vyt-rm'), id = btn.getAttribute('data-id');
+ btn.disabled = true;
+ var after = function () {
+ if (typeof showToast === 'function') showToast(scope === 'channel' ? 'Unfollowed' : 'Removed', 'info');
+ load(); document.dispatchEvent(new CustomEvent('soulsync:video-wishlist-changed'));
+ };
+ var p = scope === 'channel'
+ ? VideoYoutube.unfollow(id).then(function () { return VideoYoutube.removeWish('channel', id); })
+ : VideoYoutube.removeWish('video', id);
+ p.then(after).catch(function () { btn.disabled = false; });
+ }
+
function onGridClick(e) {
+ var ytRm = e.target.closest('[data-vyt-rm]');
+ if (ytRm) { e.preventDefault(); e.stopPropagation(); doYtRemove(ytRm); return; }
+ var ytTog = e.target.closest('[data-vyt-toggle]');
+ if (ytTog && !e.target.closest('[data-vyt-ext]')) {
+ var blk = ytTog.closest('.vyt-chan'); if (blk) blk.classList.toggle('vyt-chan--collapsed'); return;
+ }
+ if (e.target.closest('[data-vyt-ext]')) return; // let the YouTube link open
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;
@@ -405,16 +491,17 @@
// 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();
+ if (g && g.offsetParent !== null) load(); else { refreshBadge(); refreshYtCount(); }
});
}
- function onShown(e) { if (e && e.detail === PAGE_ID) { state.page = 1; load(); } }
+ 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);
diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js
new file mode 100644
index 00000000..bd8fc9da
--- /dev/null
+++ b/webui/static/video/video-youtube.js
@@ -0,0 +1,112 @@
+/* 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();
+ }
+
+ function avatar(ch, cls) {
+ if (ch && ch.poster_url || (ch && ch.avatar_url)) {
+ return '
';
+ }
+ return '' + esc(initials(ch && ch.title)) + '';
+ }
+
+ // 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 '';
+ }
+
+ 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; });
+ }
+
+ function follow(channel) { return post('/api/video/youtube/follow', { channel: channel }); }
+ 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 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,
+ videoCard: videoCard, searchCard: searchCard, followBtn: followBtn,
+ follow: follow, unfollow: unfollow, removeWish: removeWish, resolve: resolve,
+ };
+})();