diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py
index 77c9f05b..14415a80 100644
--- a/tests/test_video_side_shell.py
+++ b/tests/test_video_side_shell.py
@@ -19,6 +19,7 @@ _JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encodin
_DASH_JS = (_ROOT / "webui" / "static" / "video" / "video-dashboard.js").read_text(encoding="utf-8")
_LIB_JS = (_ROOT / "webui" / "static" / "video" / "video-library.js").read_text(encoding="utf-8")
_SCAN_JS = (_ROOT / "webui" / "static" / "video" / "video-scan.js").read_text(encoding="utf-8")
+_VSETTINGS_JS = (_ROOT / "webui" / "static" / "video" / "video-settings.js").read_text(encoding="utf-8")
_CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css"
EXPECTED_VIDEO_PAGES = {
@@ -194,6 +195,28 @@ def test_video_settings_reuses_real_music_settings_page():
assert 'data-video-page="video-settings"] #video-page-host' in css
+def test_video_library_mapping_ui_present_and_video_only():
+ # Movies/TV selectors live next to the music library selector, marked
+ # data-video-only so they show only on the video side.
+ assert 'data-video-lib-select="movies"' in _INDEX
+ assert 'data-video-lib-select="tv"' in _INDEX
+ assert "data-video-lib-save" in _INDEX
+ assert "data-video-only" in _INDEX
+ css = _CSS_PATH.read_text(encoding="utf-8")
+ assert 'body[data-side="music"] [data-video-only]' in css # hidden on music side
+
+
+def test_video_settings_module_referenced_and_isolated():
+ assert "video/video-settings.js" in _INDEX
+ stripped = _VSETTINGS_JS.strip()
+ assert stripped.startswith("/*") or stripped.startswith("(function")
+ assert "(function" in _VSETTINGS_JS and "})();" in _VSETTINGS_JS
+ assert "window." not in _VSETTINGS_JS
+ assert "addEventListener" in _VSETTINGS_JS
+ assert "/api/video/libraries" in _VSETTINGS_JS
+ assert "soulsync:video-page-shown" in _VSETTINGS_JS
+
+
def test_controller_is_isolated_iife_with_no_globals():
stripped = _JS.strip()
# Wrapped in an IIFE → declares no module-level globals that could collide
diff --git a/webui/index.html b/webui/index.html
index 8b5f673d..cd9845a8 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -4795,6 +4795,25 @@
Select which music library to use (doesn't affect config file)
+
+
@@ -8889,6 +8908,8 @@
+
+
diff --git a/webui/static/video/video-settings.js b/webui/static/video/video-settings.js
new file mode 100644
index 00000000..24b041cc
--- /dev/null
+++ b/webui/static/video/video-settings.js
@@ -0,0 +1,80 @@
+/*
+ * SoulSync — Video settings additions (isolated).
+ *
+ * The video side shows the real music settings page; this module only drives the
+ * VIDEO-specific bits added to it — for now, the Movies/TV library mapping
+ * (which server library the scan reads). It populates the dropdowns from
+ * /api/video/libraries when the Settings page is shown on the video side and
+ * saves the choice back. Self-contained IIFE, no globals, no inline handlers.
+ */
+(function () {
+ 'use strict';
+
+ var PAGE_ID = 'video-settings';
+ var URL = '/api/video/libraries';
+
+ function status(text) {
+ var n = document.querySelector('[data-video-lib-status]');
+ if (n) n.textContent = text || '';
+ }
+
+ function fill(select, items, selected) {
+ if (!select) return;
+ select.textContent = '';
+ var none = document.createElement('option');
+ none.value = '';
+ none.textContent = '— None —';
+ select.appendChild(none);
+ for (var i = 0; i < items.length; i++) {
+ var opt = document.createElement('option');
+ opt.value = items[i].title;
+ opt.textContent = items[i].title;
+ if (items[i].title === selected) opt.selected = true;
+ select.appendChild(opt);
+ }
+ }
+
+ function load() {
+ status('Loading…');
+ fetch(URL, { headers: { 'Accept': 'application/json' } })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) {
+ if (!d || d.error) { status('Could not load libraries'); return; }
+ var sel = d.selected || {};
+ fill(document.querySelector('[data-video-lib-select="movies"]'), d.movies || [], sel.movies);
+ fill(document.querySelector('[data-video-lib-select="tv"]'), d.tv || [], sel.tv);
+ status('');
+ })
+ .catch(function () { status('Could not load libraries'); });
+ }
+
+ function save() {
+ var m = document.querySelector('[data-video-lib-select="movies"]');
+ var t = document.querySelector('[data-video-lib-select="tv"]');
+ status('Saving…');
+ fetch(URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+ body: JSON.stringify({ movies: m ? m.value : '', tv: t ? t.value : '' })
+ })
+ .then(function (r) { return r.json(); })
+ .then(function () { status('Saved'); })
+ .catch(function () { status('Save failed'); });
+ }
+
+ function onPageShown(e) {
+ if (e && e.detail === PAGE_ID) load();
+ }
+
+ function init() {
+ var btn = document.querySelector('[data-video-lib-save]');
+ if (btn) btn.addEventListener('click', save);
+ document.addEventListener('soulsync:video-page-shown', onPageShown);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+})();
diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css
index b1d5fb50..43260e68 100644
--- a/webui/static/video/video-side.css
+++ b/webui/static/video/video-side.css
@@ -117,6 +117,11 @@ body[data-side="video"][data-video-page="video-settings"] #settings-page {
body[data-side="video"][data-video-page="video-settings"] #video-page-host {
display: none !important;
}
+/* Video-only additions inside the shared settings page (e.g. the Movies/TV
+ library mapping) — hidden on the music side. */
+body[data-side="music"] [data-video-only] {
+ display: none !important;
+}
/* ── Dashboard header: match music, minus the sweep animation ──────────── */
/* The header reuses music's .dashboard-header markup/CSS for an identical look;