diff --git a/tests/test_video_side_shell.py b/tests/test_video_side_shell.py index 3aed7808..842489ae 100644 --- a/tests/test_video_side_shell.py +++ b/tests/test_video_side_shell.py @@ -19,7 +19,6 @@ _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") -_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 = { @@ -157,9 +156,12 @@ def test_video_library_module_referenced_and_isolated(): def test_video_tools_page_has_three_scan_modes(): block = _block( _INDEX, r'
") + # Mode dropdown + single Scan button, same shape as the music tool card. 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 f'value="{mode}"' in block, f"tools page missing scan mode {mode}" + assert "data-video-scan-run" in block and "data-video-scan-select" in block + assert "data-video-scan-phase" in block # reuses music progress markup + assert "tool-card" in block and "tool-card-controls" in block # reuses music classes assert "onclick" not in block @@ -171,15 +173,13 @@ def test_dashboard_library_card_has_refresh_and_deep_buttons(): 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. +def test_scan_module_referenced_and_isolated(): + assert "video/video-scan.js" in _INDEX + stripped = _SCAN_JS.strip() + assert stripped.startswith("/*") or stripped.startswith("(function") + assert "(function" in _SCAN_JS and "})();" in _SCAN_JS + assert "window." not in _SCAN_JS + assert "addEventListener" in _SCAN_JS assert "soulsync:video-scan-done" in _SCAN_JS assert "/api/video/scan/request" in _SCAN_JS diff --git a/webui/index.html b/webui/index.html index 2930e804..8b5f673d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -552,39 +552,56 @@
+
+ +

Video Library

-

Not scanned yet

+

Movies, shows & episodes

+
+ +
0 Movies
+
+ +
0 Shows
+
+ +
0 Episodes
+
+ +
-- Disk Size @@ -721,14 +738,21 @@

Library Scan

-

Pull movies & shows from your media server into the video library. Incremental: recently-added only (fast). Full Refresh: re-read everything. Deep Scan: full re-read and remove items no longer on the server.

-
- - - +

Pull movies & shows from your media server. Incremental = recently-added only; Full Refresh = re-read everything; Deep Scan = also remove items no longer on the server.

+
+ +
-

Idle

+

Idle

+
+
+
+

0 movies, 0 shows

@@ -8865,8 +8889,6 @@ - - diff --git a/webui/static/video/video-scan.js b/webui/static/video/video-scan.js index 16fef0a7..ef0a7a99 100644 --- a/webui/static/video/video-scan.js +++ b/webui/static/video/video-scan.js @@ -1,19 +1,19 @@ /* * 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. + * One place triggers + polls library scans for every surface (Library page, + * Tools page, Dashboard card), so nothing duplicates the fetch/poll logic. It + * reuses the music CSS/markup — it just targets /api/video/* and updates the + * video DOM. * - * 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}}. + * Triggers: + * - click on [data-video-scan-mode="full|incremental|deep"] (or [data-video-scan] = full) + * - click on [data-video-scan-run], reading the mode from [data-video-scan-select] + * - 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/. + * Updates the Tools progress widgets ([data-video-scan-phase|bar|detail]) and + * emits 'soulsync:video-scan-progress' / 'soulsync:video-scan-done' so the + * Library page and Dashboard can react too. Self-contained IIFE, no globals. */ (function () { 'use strict'; @@ -26,20 +26,59 @@ document.dispatchEvent(new CustomEvent(name, { detail: detail })); } + function setText(sel, text) { + var n = document.querySelector(sel); + if (n) n.textContent = text; + } + + function setBar(pct) { + var bar = document.querySelector('[data-video-scan-bar]'); + if (bar) bar.style.width = pct + '%'; + } + + function counts(s) { + return (s.movies || 0) + ' movies, ' + (s.shows || 0) + ' shows'; + } + + function reflectProgress(s) { + var phase = (s.phase || 'scanning'); + setText('[data-video-scan-phase]', phase.charAt(0).toUpperCase() + phase.slice(1)); + setText('[data-video-scan-detail]', counts(s)); + setBar(100); + } + + function reflectDone(s) { + var run = document.querySelector('[data-video-scan-run]'); + if (run) run.disabled = false; + if (s.state === 'error') { + setText('[data-video-scan-phase]', 'Failed'); + setText('[data-video-scan-detail]', s.error || 'Scan failed'); + setBar(0); + } else { + setText('[data-video-scan-phase]', 'Complete'); + setText('[data-video-scan-detail]', + counts(s) + (s.removed ? ', ' + s.removed + ' removed' : '')); + setBar(100); + } + } + function poll() { fetch(STATUS_URL, { headers: { 'Accept': 'application/json' } }) .then(function (r) { return r.json(); }) .then(function (s) { if (s && s.state === 'scanning') { + reflectProgress(s); emit('soulsync:video-scan-progress', s); setTimeout(poll, 1500); } else { scanning = false; + reflectDone(s || { state: 'idle' }); emit('soulsync:video-scan-done', s || { state: 'idle' }); } }) .catch(function () { scanning = false; + reflectDone({ state: 'error', error: 'Could not reach server' }); emit('soulsync:video-scan-done', { state: 'error' }); }); } @@ -47,8 +86,11 @@ function start(mode) { if (scanning) return; scanning = true; - emit('soulsync:video-scan-progress', - { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0 }); + var run = document.querySelector('[data-video-scan-run]'); + if (run) run.disabled = true; + var pending = { state: 'scanning', phase: 'starting', mode: mode, movies: 0, shows: 0 }; + reflectProgress(pending); + emit('soulsync:video-scan-progress', pending); fetch(REQUEST_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, @@ -57,24 +99,29 @@ .then(function () { setTimeout(poll, 600); }) .catch(function () { scanning = false; + reflectDone({ state: 'error' }); 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 init() { + var triggers = document.querySelectorAll('[data-video-scan-mode],[data-video-scan]'); + for (var i = 0; i < triggers.length; i++) { (function (el) { el.addEventListener('click', function (e) { e.preventDefault(); start(el.getAttribute('data-video-scan-mode') || 'full'); }); - })(els[i]); + })(triggers[i]); + } + var run = document.querySelector('[data-video-scan-run]'); + if (run) { + run.addEventListener('click', function (e) { + e.preventDefault(); + var sel = document.querySelector('[data-video-scan-select]'); + start(sel ? sel.value : 'full'); + }); } - } - - function init() { - wire(); document.addEventListener('soulsync:video-scan-start', function (e) { start((e.detail && e.detail.mode) || 'full'); }); diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 4d831b22..e04b1ad3 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -226,27 +226,3 @@ 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-tools.js b/webui/static/video/video-tools.js deleted file mode 100644 index aa182fe1..00000000 --- a/webui/static/video/video-tools.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * 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(); - } -})();