From 71f126f9d2d9c0c2a2d46911cb35262bccac7a48 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sat, 13 Jun 2026 23:34:28 -0700 Subject: [PATCH] video side: Tools page + scan controls on dashboard (3 modes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Tools page (video nav + subpage, mirrors music tools styling): a Library Scan tool card with Incremental / Full Refresh / Deep Scan buttons + a live status line. Room for more maintenance jobs later. - Dashboard Library card now has Refresh (full) + Deep Scan buttons, like the music dashboard. - Shared video-scan.js controller: one place triggers + polls scans for all surfaces (wires any [data-video-scan-mode]/[data-video-scan]); emits soulsync:video-scan-progress/done. Library/Tools/Dashboard just listen — no duplicated fetch/poll. video-library.js refactored onto it; dashboard reloads stats on scan-done. - All isolated IIFEs, data-attr wired (no inline onclick). video-tools added to the nav (13 pages). 110 tests green. --- tests/test_video_side_shell.py | 34 ++++++++++- webui/index.html | 45 ++++++++++++++ webui/static/video/video-dashboard.js | 3 + webui/static/video/video-library.js | 54 +++++----------- webui/static/video/video-scan.js | 88 +++++++++++++++++++++++++++ webui/static/video/video-side.css | 24 ++++++++ webui/static/video/video-side.js | 1 + webui/static/video/video-tools.js | 56 +++++++++++++++++ 8 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 webui/static/video/video-scan.js create mode 100644 webui/static/video/video-tools.js diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 7346a4f8..3aed7808 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -18,12 +18,14 @@ _INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="re _JS = (_ROOT / "webui" / "static" / "video" / "video-side.js").read_text(encoding="utf-8") _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") +_TOOLS_JS = (_ROOT / "webui" / "static" / "video" / "video-tools.js").read_text(encoding="utf-8") _CSS_PATH = _ROOT / "webui" / "static" / "video" / "video-side.css" EXPECTED_VIDEO_PAGES = { "video-dashboard", "video-search", "video-discover", "video-library", "video-watchlist", "video-wishlist", "video-downloads", "video-calendar", - "video-import", "video-settings", "video-issues", "video-help", + "video-tools", "video-import", "video-settings", "video-issues", "video-help", } @@ -152,6 +154,36 @@ def test_video_library_module_referenced_and_isolated(): assert "window." not in _LIB_JS +def test_video_tools_page_has_three_scan_modes(): + block = _block( + _INDEX, r'
") + for mode in ("incremental", "full", "deep"): + assert f'data-video-scan-mode="{mode}"' in block, f"tools page missing scan mode {mode}" + assert "data-video-tools-scan-status" in block + assert "onclick" not in block + + +def test_dashboard_library_card_has_refresh_and_deep_buttons(): + block = _block( + _INDEX, r'
") + assert 'data-video-scan-mode="full"' in block # Refresh + assert 'data-video-scan-mode="deep"' in block # Deep Scan + assert "library-status-actions" in block + + +def test_scan_and_tools_modules_referenced_and_isolated(): + assert "video/video-scan.js" in _INDEX and "video/video-tools.js" in _INDEX + for js in (_SCAN_JS, _TOOLS_JS): + stripped = js.strip() + assert stripped.startswith("/*") or stripped.startswith("(function") + assert "(function" in js and "})();" in js + assert "window." not in js + assert "addEventListener" in js + # The shared controller drives scans via the documented events. + assert "soulsync:video-scan-done" in _SCAN_JS + assert "/api/video/scan/request" in _SCAN_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 3f0a8116..2930e804 100644 --- a/webui/index.html +++ b/webui/index.html @@ -357,6 +357,10 @@ Calendar + + + Tools + Import @@ -552,6 +556,14 @@

Video Library

Not scanned yet

+
+ + +
@@ -694,6 +706,35 @@
+ @@ -8822,6 +8863,10 @@ + + + + diff --git a/webui/static/video/video-dashboard.js b/webui/static/video/video-dashboard.js index ecd8169d..aae122dc 100644 --- a/webui/static/video/video-dashboard.js +++ b/webui/static/video/video-dashboard.js @@ -118,4 +118,7 @@ } document.addEventListener('soulsync:video-page-shown', onPageShown); + // Dashboard scan buttons (data-video-scan-mode) are wired by video-scan.js; + // refresh the live counts whenever any scan finishes. + document.addEventListener('soulsync:video-scan-done', function () { loadStats(); }); })(); diff --git a/webui/static/video/video-library.js b/webui/static/video/video-library.js index 45398e64..30ff016a 100644 --- a/webui/static/video/video-library.js +++ b/webui/static/video/video-library.js @@ -13,8 +13,6 @@ var PAGE_ID = 'video-library'; var LIBRARY_URL = '/api/video/library'; - var SCAN_REQUEST_URL = '/api/video/scan/request'; - var SCAN_STATUS_URL = '/api/video/scan/status'; var state = { tab: 'movies', data: null, loading: false, scanning: false }; @@ -103,43 +101,21 @@ if (label) label.textContent = text; } - function pollScan() { - fetch(SCAN_STATUS_URL, { headers: { 'Accept': 'application/json' } }) - .then(function (r) { return r.json(); }) - .then(function (s) { - if (s && s.state === 'scanning') { - setScanLabel((s.phase || 'Scanning') + '… ' - + (s.movies || 0) + 'm ' + (s.shows || 0) + 's'); - setTimeout(pollScan, 1500); - } else { - state.scanning = false; - var btn = $('[data-video-scan]'); - if (btn) btn.disabled = false; - setScanLabel('Scan Library'); - loadLibrary(true); - } - }) - .catch(function () { - state.scanning = false; - var btn = $('[data-video-scan]'); - if (btn) btn.disabled = false; - setScanLabel('Scan Library'); - }); - } - - function startScan() { - if (state.scanning) return; - state.scanning = true; + // Scan is triggered by the shared controller (video-scan.js wires the + // [data-video-scan] button); we just reflect its progress + reload on done. + function onScanProgress(e) { + var s = e.detail || {}; + if (s.state !== 'scanning') return; var btn = $('[data-video-scan]'); if (btn) btn.disabled = true; - setScanLabel('Starting…'); - fetch(SCAN_REQUEST_URL, { method: 'POST', headers: { 'Accept': 'application/json' } }) - .then(function () { setTimeout(pollScan, 600); }) - .catch(function () { - state.scanning = false; - if (btn) btn.disabled = false; - setScanLabel('Scan Library'); - }); + setScanLabel((s.phase || 'Scanning') + '… ' + (s.movies || 0) + 'm ' + (s.shows || 0) + 's'); + } + + function onScanDone() { + var btn = $('[data-video-scan]'); + if (btn) btn.disabled = false; + setScanLabel('Scan Library'); + loadLibrary(true); } function wire() { @@ -156,8 +132,6 @@ }); })(tabs[i]); } - var scan = document.querySelector('[data-video-scan]'); - if (scan) scan.addEventListener('click', startScan); } function onPageShown(e) { @@ -168,6 +142,8 @@ function init() { wire(); document.addEventListener('soulsync:video-page-shown', onPageShown); + document.addEventListener('soulsync:video-scan-progress', onScanProgress); + document.addEventListener('soulsync:video-scan-done', onScanDone); } if (document.readyState === 'loading') { diff --git a/webui/static/video/video-scan.js b/webui/static/video/video-scan.js new file mode 100644 index 00000000..16fef0a7 --- /dev/null +++ b/webui/static/video/video-scan.js @@ -0,0 +1,88 @@ +/* + * SoulSync — shared VIDEO scan controller (isolated). + * + * One place that triggers + polls library scans, so the Library page, the Tools + * page, and the Dashboard card don't each duplicate the fetch/poll logic. Other + * modules stay decoupled: they just listen for the events below. + * + * Wires any element with data-video-scan-mode="full|incremental|deep" (or + * data-video-scan, = full) to start a scan on click. Also starts on a + * 'soulsync:video-scan-start' event {detail:{mode}}. + * + * Emits: + * soulsync:video-scan-progress {detail: } (repeatedly while scanning) + * soulsync:video-scan-done {detail: } (once, on finish/error) + * + * Self-contained IIFE, no globals, no inline handlers, under static/video/. + */ +(function () { + 'use strict'; + + var REQUEST_URL = '/api/video/scan/request'; + var STATUS_URL = '/api/video/scan/status'; + var scanning = false; + + function emit(name, detail) { + document.dispatchEvent(new CustomEvent(name, { detail: detail })); + } + + function poll() { + fetch(STATUS_URL, { headers: { 'Accept': 'application/json' } }) + .then(function (r) { return r.json(); }) + .then(function (s) { + if (s && s.state === 'scanning') { + emit('soulsync:video-scan-progress', s); + setTimeout(poll, 1500); + } else { + scanning = false; + emit('soulsync:video-scan-done', s || { state: 'idle' }); + } + }) + .catch(function () { + scanning = false; + emit('soulsync:video-scan-done', { state: 'error' }); + }); + } + + function start(mode) { + if (scanning) return; + scanning = true; + emit('soulsync:video-scan-progress', + { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0 }); + fetch(REQUEST_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ mode: mode }) + }) + .then(function () { setTimeout(poll, 600); }) + .catch(function () { + scanning = false; + emit('soulsync:video-scan-done', { state: 'error' }); + }); + } + + function wire() { + var els = document.querySelectorAll('[data-video-scan-mode],[data-video-scan]'); + for (var i = 0; i < els.length; i++) { + (function (el) { + el.addEventListener('click', function (e) { + e.preventDefault(); + start(el.getAttribute('data-video-scan-mode') || 'full'); + }); + })(els[i]); + } + } + + function init() { + wire(); + document.addEventListener('soulsync:video-scan-start', function (e) { + start((e.detail && e.detail.mode) || 'full'); + }); + } + + 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 e04b1ad3..4d831b22 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -226,3 +226,27 @@ body[data-side="video"] .dashboard-header-sweep { font-size: 12px; color: var(--text-secondary, #9aa0aa); } + +/* ── Tools page: scan buttons ──────────────────────────────────────────── */ +.video-scan-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; +} +.video-tool-btn { + padding: 9px 18px; + font-size: 13px; + font-weight: 600; + color: #fff; + background: rgb(var(--accent-rgb, 88 101 242)); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 9px; + cursor: pointer; + transition: filter 0.15s ease, opacity 0.15s ease; +} +.video-tool-btn:hover { filter: brightness(1.1); } +.video-tool-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.video-tool-btn--warn { + background: rgba(255, 255, 255, 0.06); + color: var(--text-primary, #e8e8ea); +} diff --git a/webui/static/video/video-side.js b/webui/static/video/video-side.js index 0f4f6cf0..720973c9 100644 --- a/webui/static/video/video-side.js +++ b/webui/static/video/video-side.js @@ -33,6 +33,7 @@ { id: 'video-wishlist', label: 'Wishlist' }, { id: 'video-downloads', label: 'Downloads' }, { id: 'video-calendar', label: 'Calendar' }, + { id: 'video-tools', label: 'Tools' }, { id: 'video-import', label: 'Import', shared: true }, { id: 'video-settings', label: 'Settings' }, { id: 'video-issues', label: 'Issues', shared: true }, diff --git a/webui/static/video/video-tools.js b/webui/static/video/video-tools.js new file mode 100644 index 00000000..aa182fe1 --- /dev/null +++ b/webui/static/video/video-tools.js @@ -0,0 +1,56 @@ +/* + * SoulSync — Video Tools page (isolated). + * + * The scan buttons (data-video-scan-mode) are wired by video-scan.js; this + * module just reflects scan progress/result into the tool card's status line + * and disables the buttons while a scan runs. Self-contained IIFE, no globals. + */ +(function () { + 'use strict'; + + function statusNode() { + return document.querySelector('[data-video-tools-scan-status]'); + } + + function setStatus(text) { + var n = statusNode(); + if (n) n.textContent = text; + } + + function setButtonsDisabled(disabled) { + var btns = document.querySelectorAll( + '[data-video-subpage="video-tools"] [data-video-scan-mode]'); + for (var i = 0; i < btns.length; i++) btns[i].disabled = disabled; + } + + function onProgress(e) { + var s = e.detail || {}; + if (s.state !== 'scanning') return; + setButtonsDisabled(true); + setStatus((s.phase || 'Scanning') + '… ' + + (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows'); + } + + function onDone(e) { + var s = e.detail || {}; + setButtonsDisabled(false); + if (s.state === 'error') { + setStatus('Failed' + (s.error ? ': ' + s.error : '')); + } else { + var msg = 'Done — ' + (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows'; + if (s.removed) msg += ', ' + s.removed + ' removed'; + setStatus(msg); + } + } + + function init() { + document.addEventListener('soulsync:video-scan-progress', onProgress); + document.addEventListener('soulsync:video-scan-done', onDone); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();