youtube channels: per-channel settings modal UI (cog on the Channels tab)
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/<id>/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.)
This commit is contained in:
parent
7140b88948
commit
f734905ba1
4 changed files with 171 additions and 0 deletions
36
tests/test_youtube_channel_settings_ui.py
Normal file
36
tests/test_youtube_channel_settings_ui.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@
|
|||
var meta = n > 0 ? (n + ' video' + (n === 1 ? '' : 's')) : 'Channel';
|
||||
return '<div class="vyt-wcard" data-vyt-open-channel="' + esc(ch.youtube_id) + '" title="Open channel">' +
|
||||
'<div class="vyt-wcard-art">' + av + '</div>' +
|
||||
'<button class="vyt-wcard-cog" type="button" data-vyt-wsettings="' + esc(ch.youtube_id) +
|
||||
'" data-title="' + esc(ch.title) + '" title="Channel settings">⚙</button>' +
|
||||
'<button class="vyt-wcard-unfollow" type="button" data-vyt-wunfollow="' + esc(ch.youtube_id) +
|
||||
'" title="Unfollow">✕</button>' +
|
||||
'<div class="vyt-wcard-info"><span class="vyt-wcard-title" title="' + esc(ch.title) + '">' +
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 '<option value="' + v + '"' + (v === sel ? ' selected' : '') + '>' + v + '</option>';
|
||||
}).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 '' +
|
||||
'<label class="vyt-cset-lbl">Show name (folder)</label>' +
|
||||
'<input class="vyt-cset-in" data-cset-name type="text" value="' + esc(s.custom_name || '') +
|
||||
'" placeholder="' + esc(title || '') + '">' +
|
||||
'<div class="vyt-cset-hint">Overrides the <code>$channel</code> folder/show name when this channel’s videos download. Blank = use the channel’s real name.</div>' +
|
||||
'<label class="vyt-cset-toggle"><input type="checkbox" data-cset-qon' + (on ? ' checked' : '') +
|
||||
'> Force a specific quality for this channel</label>' +
|
||||
'<div class="vyt-cset-q" data-cset-q' + (on ? '' : ' hidden') + '>' +
|
||||
'<div class="vyt-cset-row"><span>Max resolution</span><select data-cset-res>' + _opt(_RES, b.max_resolution || '1080p') + '</select></div>' +
|
||||
'<div class="vyt-cset-row"><span>Codec</span><select data-cset-cod>' + _opt(_COD, b.video_codec || 'any') + '</select></div>' +
|
||||
'<div class="vyt-cset-row"><span>Container</span><select data-cset-con>' + _opt(_CON, b.container || 'mp4') + '</select></div>' +
|
||||
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-fps' + (b.prefer_60fps !== false ? ' checked' : '') + '> Prefer 60fps</label>' +
|
||||
'<label class="vyt-cset-ck"><input type="checkbox" data-cset-hdr' + (b.allow_hdr ? ' checked' : '') + '> Allow HDR</label>' +
|
||||
'</div>' +
|
||||
'<div class="vyt-cset-hint">Off = use the global YouTube quality from Settings.</div>';
|
||||
}
|
||||
|
||||
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 = '<div class="vyt-cset" role="dialog" aria-label="Channel settings">' +
|
||||
'<div class="vyt-cset-head"><span class="vyt-cset-h">Channel settings</span>' +
|
||||
'<button class="vyt-cset-x" type="button" title="Close">✕</button></div>' +
|
||||
'<div class="vyt-cset-body">Loading…</div>' +
|
||||
'<div class="vyt-cset-foot"><button class="vyt-cset-save" type="button" disabled>Save</button></div></div>';
|
||||
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,
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue