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") logger.exception("Failed to list video libraries")
return jsonify({"error": "Failed to list video libraries"}), 500 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"]) @bp.route("/libraries", methods=["POST"])
def save_video_libraries(): def save_video_libraries():
from . import get_video_db 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/scan/stop" in rules
assert "/api/video/library" in rules assert "/api/video/library" in rules
assert "/api/video/libraries" 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 any(r.startswith("/api/video/poster/") for r in rules)
assert "/api/video/enrichment/services" in rules assert "/api/video/enrichment/services" in rules
assert "/api/video/enrichment/<service>/status" 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 <!-- Video API Configuration (placeholders; shown only on the video
side in place of the music API services). Same structure as the side in place of the music API services). Same structure as the
music service frames above. --> 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> <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> <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"> <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> <button class="test-button" type="button" data-video-test-service="omdb">Test OMDb</button>
</div> </div>
</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="api-service-frame stg-service" data-video-service="prefs">
<div class="stg-service-header" onclick="toggleStgService(this)"> <div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #38bdf8;"></span> <span class="stg-service-dot" style="color: #38bdf8;"></span>

View file

@ -192,8 +192,41 @@
function onScanDone() { if (state.loaded) load(); } 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) { function onPageShown(e) {
if (!e || e.detail !== PAGE_ID) return; if (!e || e.detail !== PAGE_ID) return;
checkServer();
if (!state.loaded) load(); if (!state.loaded) load();
} }

View file

@ -13,6 +13,46 @@
var PAGE_ID = 'video-settings'; var PAGE_ID = 'video-settings';
var URL = '/api/video/libraries'; var URL = '/api/video/libraries';
var CONFIG_URL = '/api/video/enrichment/config'; 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) { function status(text) {
var n = document.querySelector('[data-video-lib-status]'); var n = document.querySelector('[data-video-lib-status]');
@ -129,6 +169,7 @@
function onPageShown(e) { function onPageShown(e) {
if (e && e.detail !== PAGE_ID) return; if (e && e.detail !== PAGE_ID) return;
loadServer();
load(); load();
loadKeys(); loadKeys();
} }
@ -149,6 +190,11 @@
if (autoplay) autoplay.addEventListener('change', savePrefs); if (autoplay) autoplay.addEventListener('change', savePrefs);
var region = document.getElementById('video-watch-region'); var region = document.getElementById('video-watch-region');
if (region) region.addEventListener('change', savePrefs); 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). // Per-connection Test buttons (same behaviour as music's testConnection).
var testBtns = document.querySelectorAll('[data-video-test-service]'); var testBtns = document.querySelectorAll('[data-video-test-service]');
for (var k = 0; k < testBtns.length; k++) { 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; 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; } .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; }