From f734905ba1f6f8bce863e17070329b98913b7cff Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 25 Jun 2026 23:01:35 -0700 Subject: [PATCH] youtube channels: per-channel settings modal UI (cog on the Channels tab) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hover a channel on the watchlist Channels tab -> a settings cog appears (mirror of the unfollow x) -> opens a self-contained modal: - Show name (folder): overrides the $channel folder/show-name token; blank uses the real channel name. - 'Force a specific quality' toggle -> resolution/codec/container/60fps/HDR override; off = use the global youtube quality from Settings. VideoYoutube.openChannelSettings(id, title) builds the modal, loads via GET, saves via POST /youtube/channel//settings. .vyt-cset-* CSS. string-contract test. (note: 10 pre-existing soundcloud music-orchestrator test failures are unrelated to this work — they fail on a clean HEAD too.) --- tests/test_youtube_channel_settings_ui.py | 36 +++++++++ webui/static/video/video-side.css | 38 ++++++++++ webui/static/video/video-watchlist.js | 8 ++ webui/static/video/video-youtube.js | 89 +++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 tests/test_youtube_channel_settings_ui.py diff --git a/tests/test_youtube_channel_settings_ui.py b/tests/test_youtube_channel_settings_ui.py new file mode 100644 index 00000000..08690501 --- /dev/null +++ b/tests/test_youtube_channel_settings_ui.py @@ -0,0 +1,36 @@ +"""Per-channel settings modal — UI wiring (string-contract level, like the other video-JS +tests). The functional core (storage/API/download wiring) is in test_youtube_channel_settings. +""" + +from __future__ import annotations + +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_YT = (_ROOT / "webui" / "static" / "video" / "video-youtube.js").read_text(encoding="utf-8") +_WL = (_ROOT / "webui" / "static" / "video" / "video-watchlist.js").read_text(encoding="utf-8") +_CSS = (_ROOT / "webui" / "static" / "video" / "video-side.css").read_text(encoding="utf-8") + + +def test_channel_card_has_a_settings_cog(): + assert 'data-vyt-wsettings' in _WL # cog button on the watchlist channel card + assert 'vyt-wcard-cog' in _WL and '.vyt-wcard-cog' in _CSS + + +def test_cog_click_opens_the_settings_modal(): + assert 'VideoYoutube.openChannelSettings(' in _WL + assert "cog.getAttribute('data-vyt-wsettings')" in _WL + + +def test_modal_is_exposed_and_built(): + assert 'openChannelSettings: openChannelSettings' in _YT + assert 'function openChannelSettings(' in _YT + assert '.vyt-cset-overlay' in _CSS # modal styles exist + + +def test_modal_loads_and_saves_via_the_settings_api(): + assert "/channel/' + encodeURIComponent(channelId) + '/settings'" in _YT + assert "method: 'POST'" in _YT + # the form carries the custom name + the optional quality override + assert 'data-cset-name' in _YT and 'data-cset-qon' in _YT + assert 'custom_name' in _YT and 'max_resolution' in _YT diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 6635f338..83c41b84 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -3006,6 +3006,44 @@ body[data-side="video"] #soulsync-toggle { display: none; } 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; } +/* settings cog: mirror of the unfollow ✕, top-left of the avatar, hover-revealed */ +.vyt-wcard-cog { position: absolute; top: 4px; left: 50%; margin-left: -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: 13px; line-height: 1; cursor: pointer; opacity: 0; transition: all 0.15s ease; } +.vyt-wcard:hover .vyt-wcard-cog { opacity: 1; } +.vyt-wcard-cog:hover { background: rgba(255, 59, 59, 0.85); color: #fff; } + +/* per-channel settings modal */ +.vyt-cset-overlay { position: fixed; inset: 0; z-index: 10000; display: flex; align-items: center; + justify-content: center; background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(3px); } +.vyt-cset { width: min(440px, 92vw); max-height: 88vh; overflow: auto; background: #16181d; + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 16px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); } +.vyt-cset-head { display: flex; align-items: center; justify-content: space-between; + padding: 16px 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); } +.vyt-cset-h { font-size: 15px; font-weight: 800; color: #fff; } +.vyt-cset-x { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 15px; cursor: pointer; } +.vyt-cset-x:hover { color: #fff; } +.vyt-cset-body { padding: 18px; display: flex; flex-direction: column; gap: 8px; } +.vyt-cset-lbl { font-size: 12px; font-weight: 700; color: rgba(255, 255, 255, 0.85); } +.vyt-cset-in { width: 100%; padding: 9px 11px; border-radius: 9px; border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); color: #fff; font-size: 13.5px; box-sizing: border-box; } +.vyt-cset-hint { font-size: 11.5px; color: rgba(255, 255, 255, 0.5); line-height: 1.45; } +.vyt-cset-hint code { background: rgba(255, 255, 255, 0.08); padding: 1px 4px; border-radius: 4px; } +.vyt-cset-toggle { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 13px; + font-weight: 700; color: #fff; cursor: pointer; } +.vyt-cset-q { display: flex; flex-direction: column; gap: 9px; padding: 12px; margin-top: 2px; + border-radius: 11px; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.07); } +.vyt-cset-q[hidden] { display: none; } +.vyt-cset-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; + font-size: 13px; color: rgba(255, 255, 255, 0.85); } +.vyt-cset-row select { padding: 6px 9px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.14); + background: #1f2229; color: #fff; font-size: 13px; } +.vyt-cset-ck { display: flex; align-items: center; gap: 8px; font-size: 13px; color: rgba(255, 255, 255, 0.85); cursor: pointer; } +.vyt-cset-foot { padding: 14px 18px; border-top: 1px solid rgba(255, 255, 255, 0.08); display: flex; justify-content: flex-end; } +.vyt-cset-save { padding: 9px 22px; border-radius: 10px; border: none; background: #ff3b3b; color: #fff; + font-size: 13.5px; font-weight: 800; cursor: pointer; } +.vyt-cset-save:disabled { opacity: 0.5; cursor: default; } +.vyt-cset-save:hover:not(:disabled) { background: #ff5252; } .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; } diff --git a/webui/static/video/video-watchlist.js b/webui/static/video/video-watchlist.js index 3e7ac55d..25fc9f12 100644 --- a/webui/static/video/video-watchlist.js +++ b/webui/static/video/video-watchlist.js @@ -24,6 +24,8 @@ var meta = n > 0 ? (n + ' video' + (n === 1 ? '' : 's')) : 'Channel'; return '
' + '
' + av + '
' + + '' + '' + '
' + @@ -257,6 +259,12 @@ // 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 cog = e.target.closest('[data-vyt-wsettings]'); + if (cog && window.VideoYoutube && VideoYoutube.openChannelSettings) { + e.preventDefault(); e.stopPropagation(); + VideoYoutube.openChannelSettings(cog.getAttribute('data-vyt-wsettings'), cog.getAttribute('data-title')); + return; + } var unf = e.target.closest('[data-vyt-wunfollow]'); if (unf && window.VideoYoutube) { e.preventDefault(); e.stopPropagation(); diff --git a/webui/static/video/video-youtube.js b/webui/static/video/video-youtube.js index c604b479..3cb76ff1 100644 --- a/webui/static/video/video-youtube.js +++ b/webui/static/video/video-youtube.js @@ -202,6 +202,94 @@ { 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) { + 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 || {}; + return '' + + '' + + '' + + '
Overrides the $channel folder/show name when this channel’s videos download. Blank = use the channel’s real name.
' + + '' + + '
' + + '
Max resolution
' + + '
Codec
' + + '
Container
' + + '' + + '' + + '
' + + '
Off = use the global YouTube quality from Settings.
'; + } + + function openChannelSettings(channelId, title) { + if (!channelId) return; + 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); + 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, @@ -209,5 +297,6 @@ follow: follow, unfollow: unfollow, followPlaylist: followPlaylist, unfollowPlaylist: unfollowPlaylist, removeWish: removeWish, addVideos: addVideos, resolve: resolve, searchChannels: searchChannels, channelResultCard: channelResultCard, + openChannelSettings: openChannelSettings, }; })();