video: Video Source settings panel + library gating; move Detail prefs to own group

Settings → Video Source shows which server video uses (✓ Plex/Jellyfin), a
Plex/Jellyfin picker when both are connected, or a clear 'connect Plex or
Jellyfin' message when neither (Navidrome/Standalone are music-only and not
offered). The Library shows a non-breaking 'no video server' banner + disables
Scan until one is connected. Detail Pages prefs moved into their own 'Video
Preferences' group. /api/video/server GET+POST drives it.
This commit is contained in:
BoulderBadgeDad 2026-06-15 13:55:42 -07:00
parent f7d1b725d7
commit b2dbd6dec5
6 changed files with 139 additions and 0 deletions

View file

@ -30,6 +30,32 @@ def register_routes(bp):
logger.exception("Failed to list video libraries")
return jsonify({"error": "Failed to list video libraries"}), 500
@bp.route("/server", methods=["GET"])
def video_server_status():
"""Which server the video side uses + which of Plex/Jellyfin are configured
(so the UI can show a picker, or a 'connect a server' message)."""
try:
from core.video.sources import resolve_video_server
from config.settings import config_manager
plex = bool((config_manager.get_plex_config() or {}).get("base_url"))
jelly = bool((config_manager.get_jellyfin_config() or {}).get("base_url"))
return jsonify({"server": resolve_video_server(), "plex": plex, "jellyfin": jelly})
except Exception:
logger.exception("video server status failed")
return jsonify({"server": None, "plex": False, "jellyfin": False})
@bp.route("/server", methods=["POST"])
def video_server_set():
"""Set the explicit video-side server pick (only meaningful when both Plex
and Jellyfin are configured)."""
from . import get_video_db
body = request.get_json(silent=True) or {}
choice = body.get("server")
if choice not in ("plex", "jellyfin"):
return jsonify({"error": "bad server"}), 400
get_video_db().set_setting("video_server", choice)
return jsonify({"status": "saved", "server": choice})
@bp.route("/libraries", methods=["POST"])
def save_video_libraries():
from . import get_video_db

View file

@ -34,6 +34,7 @@ def test_blueprint_exposes_dashboard_route():
assert "/api/video/scan/stop" in rules
assert "/api/video/library" in rules
assert "/api/video/libraries" in rules
assert "/api/video/server" in rules
assert any(r.startswith("/api/video/poster/") for r in rules)
assert "/api/video/enrichment/services" in rules
assert "/api/video/enrichment/<service>/status" in rules

View file

@ -5133,6 +5133,13 @@
<!-- Video API Configuration (placeholders; shown only on the video
side in place of the music API services). Same structure as the
music service frames above. -->
<!-- Video Source (which Plex/Jellyfin the video side uses) -->
<div class="settings-group" data-video-only>
<h3>Video Source</h3>
<div class="vid-source-panel" data-video-source-panel>
<div class="callback-help">Checking…</div>
</div>
</div>
<div class="settings-group" data-stg="connections" data-video-only>
<h3>API Configuration <button class="stg-accordion-toggle" onclick="toggleAllServiceAccordions(this)">Expand All</button></h3>
<div class="api-service-frame stg-service" data-video-service="tmdb">
@ -5191,6 +5198,10 @@
<button class="test-button" type="button" data-video-test-service="omdb">Test OMDb</button>
</div>
</div>
</div>
<!-- Video Preferences (video-side only) -->
<div class="settings-group" data-video-only>
<h3>Video Preferences</h3>
<div class="api-service-frame stg-service" data-video-service="prefs">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #38bdf8;"></span>

View file

@ -192,8 +192,41 @@
function onScanDone() { if (state.loaded) load(); }
// Gate the library/scan when no video server (Plex/Jellyfin) is connected —
// nothing breaks, the user is just told what to do.
function ensureServerBanner() {
var content = document.querySelector('#video-library-page .library-content');
if (!content) return null;
var b = content.querySelector('[data-video-noserver]');
if (!b) {
b = document.createElement('div');
b.setAttribute('data-video-noserver', '');
b.className = 'video-noserver hidden';
b.innerHTML = 'No video server connected. Go to <strong>Settings &rarr; Video Source</strong> ' +
'and connect Plex or Jellyfin to scan and browse your video library.';
content.insertBefore(b, content.firstChild);
}
return b;
}
function checkServer() {
fetch('/api/video/server', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
var ok = !!(d && d.server), banner = ensureServerBanner();
if (banner) banner.classList.toggle('hidden', ok);
var scan = document.querySelector('[data-video-scan-mode]');
if (scan) {
scan.disabled = !ok;
scan.style.opacity = ok ? '' : '0.45';
scan.title = ok ? 'Scan the media server' : 'Connect Plex or Jellyfin in Settings first';
}
})
.catch(function () { /* ignore */ });
}
function onPageShown(e) {
if (!e || e.detail !== PAGE_ID) return;
checkServer();
if (!state.loaded) load();
}

View file

@ -13,6 +13,46 @@
var PAGE_ID = 'video-settings';
var URL = '/api/video/libraries';
var CONFIG_URL = '/api/video/enrichment/config';
var SERVER_URL = '/api/video/server';
function esc(s) {
return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ── Video Source (which Plex/Jellyfin the video side uses) ──────────────
function loadServer() {
var host = document.querySelector('[data-video-source-panel]');
if (!host) return;
fetch(SERVER_URL, { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { renderServer(host, d || {}); })
.catch(function () { renderServer(host, {}); });
}
function srvBtn(id, label, active) {
return '<button class="vid-source-btn' + (id === active ? ' active' : '') +
'" type="button" data-video-server-pick="' + id + '">' + label + '</button>';
}
function renderServer(host, d) {
var plex = !!d.plex, jelly = !!d.jellyfin;
if (!plex && !jelly) {
host.innerHTML = '<div class="vid-source-none">No video server connected. ' +
'Connect <strong>Plex</strong> or <strong>Jellyfin</strong> under <em>Server Connections</em> ' +
'below to scan and browse your video library. <em>(Navidrome and Standalone are music-only.)</em></div>';
} else if (plex && jelly) {
host.innerHTML = '<div class="vid-source-pick">' +
srvBtn('plex', 'Plex', d.server) + srvBtn('jellyfin', 'Jellyfin', d.server) + '</div>' +
'<div class="callback-help">Both are connected — pick which one the video side uses.</div>';
} else {
host.innerHTML = '<div class="vid-source-using"><span class="vid-source-check">✓</span> ' +
'Video uses <strong>' + (plex ? 'Plex' : 'Jellyfin') + '</strong></div>';
}
}
function pickServer(id) {
fetch(SERVER_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ server: id })
}).then(function () { loadServer(); load(); }).catch(function () { /* ignore */ });
}
function status(text) {
var n = document.querySelector('[data-video-lib-status]');
@ -129,6 +169,7 @@
function onPageShown(e) {
if (e && e.detail !== PAGE_ID) return;
loadServer();
load();
loadKeys();
}
@ -149,6 +190,11 @@
if (autoplay) autoplay.addEventListener('change', savePrefs);
var region = document.getElementById('video-watch-region');
if (region) region.addEventListener('change', savePrefs);
// Video source picker (delegated — the panel is rendered async).
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-video-server-pick]');
if (btn) pickServer(btn.getAttribute('data-video-server-pick'));
});
// Per-connection Test buttons (same behaviour as music's testConnection).
var testBtns = document.querySelectorAll('[data-video-test-service]');
for (var k = 0; k < testBtns.length; k++) {

View file

@ -1465,3 +1465,25 @@ a.vd-guest:hover .vd-guest-name { color: rgb(var(--vd-accent-rgb)); }
color: var(--text-primary, #fff); border-radius: 8px; padding: 7px 10px; font-size: 13px; cursor: pointer;
}
.vid-pref-row--select select option { background: #16161c; color: #fff; }
/* ── settings: Video Source panel ────────────────────────────────────────── */
.vid-source-using { font-size: 14px; color: var(--text-primary, #fff); display: flex; align-items: center; gap: 8px; }
.vid-source-check { color: #22c55e; font-weight: 800; }
.vid-source-none { font-size: 13.5px; line-height: 1.6; color: rgba(255, 255, 255, 0.72); }
.vid-source-none strong { color: #fff; }
.vid-source-pick { display: flex; gap: 10px; margin-bottom: 10px; }
.vid-source-btn {
padding: 8px 18px; border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 700;
background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.14); color: rgba(255, 255, 255, 0.8);
transition: all 0.18s ease;
}
.vid-source-btn:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.vid-source-btn.active { background: rgba(56, 189, 248, 0.9); border-color: transparent; color: #fff; }
/* library: no-server gate banner */
.video-noserver {
margin: 0 0 18px; padding: 16px 18px; border-radius: 12px; font-size: 14px; line-height: 1.6;
color: rgba(255, 255, 255, 0.85);
background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.32);
}
.video-noserver.hidden { display: none; }