video side: Tools page + scan controls on dashboard (3 modes)

- 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.
This commit is contained in:
BoulderBadgeDad 2026-06-13 23:34:28 -07:00
parent 04fb19c80c
commit 71f126f9d2
8 changed files with 265 additions and 40 deletions

View file

@ -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'<section class="video-subpage" data-video-subpage="video-tools"', "</section>")
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'<section class="video-subpage" data-video-subpage="video-dashboard"', "</section>")
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

View file

@ -357,6 +357,10 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
<span class="nav-text">Calendar</span>
</a>
<a class="nav-button" data-video-page="video-tools" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.1-.5-.5-2.1 2.7-2.4z"/></svg></span>
<span class="nav-text">Tools</span>
</a>
<a class="nav-button" data-video-page="video-import" href="#">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/><polyline points="7 9 12 4 17 9"/><line x1="12" y1="4" x2="12" y2="16"/></svg></span>
<span class="nav-text">Import</span>
@ -552,6 +556,14 @@
<h4 class="library-status-title">Video Library</h4>
<p class="library-status-subtitle">Not scanned yet</p>
</div>
<div class="library-status-actions">
<button class="library-status-btn" type="button" data-video-scan-mode="full">
<span>Refresh</span>
</button>
<button class="library-status-btn library-status-btn-secondary" type="button" data-video-scan-mode="deep">
<span>Deep Scan</span>
</button>
</div>
</div>
<div class="library-status-stats">
<div class="library-status-stat">
@ -694,6 +706,35 @@
<p class="video-empty-note" data-video-lib-empty hidden>Nothing here yet — run a scan to pull your library from the media server.</p>
</div>
</section>
<section class="video-subpage" data-video-subpage="video-tools" hidden>
<div class="page-shell tools-page-container">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/dashboard.png" class="page-header-icon" alt=""><span>Tools</span></h2>
<p class="header-subtitle">Maintenance jobs and library tools</p>
</div>
</div>
<div class="tools-section">
<h3 class="tools-section-title">Database &amp; Scanning</h3>
<div class="tools-grid">
<div class="tool-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Library Scan</h4>
</div>
<p class="tool-card-info">Pull movies &amp; shows from your media server into the video library. <strong>Incremental</strong>: recently-added only (fast). <strong>Full Refresh</strong>: re-read everything. <strong>Deep Scan</strong>: full re-read and remove items no longer on the server.</p>
<div class="tool-card-controls video-scan-controls">
<button class="video-tool-btn" type="button" data-video-scan-mode="incremental">Incremental</button>
<button class="video-tool-btn" type="button" data-video-scan-mode="full">Full Refresh</button>
<button class="video-tool-btn video-tool-btn--warn" type="button" data-video-scan-mode="deep">Deep Scan</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" data-video-tools-scan-status>Idle</p>
</div>
</div>
</div>
</div>
</div>
</section>
<div class="video-placeholder-slot" id="video-placeholder-slot" hidden></div>
</div>
@ -8822,6 +8863,10 @@
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
<!-- Video side shell controller (isolated; music never references it) -->
<script src="{{ url_for('static', filename='video/video-side.js', v=static_v) }}"></script>
<!-- Shared video scan controller (triggers + polls scans for all video pages) -->
<script src="{{ url_for('static', filename='video/video-scan.js', v=static_v) }}"></script>
<!-- Video tools page (isolated; reflects scan progress) -->
<script src="{{ url_for('static', filename='video/video-tools.js', v=static_v) }}"></script>
<!-- Video dashboard data layer (isolated; populates the dashboard from the video DB) -->
<script src="{{ url_for('static', filename='video/video-dashboard.js', v=static_v) }}"></script>
<!-- Video library page (isolated; lists movies/shows + scan trigger) -->

View file

@ -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(); });
})();

View file

@ -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') {

View file

@ -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: <status>} (repeatedly while scanning)
* soulsync:video-scan-done {detail: <status>} (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();
}
})();

View file

@ -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);
}

View file

@ -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 },

View file

@ -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();
}
})();