video side: reuse music styling for Tools + dashboard scan controls

The visuals were off because I'd invented CSS/markup instead of reusing the
shared design system. Fixed to match music exactly:
- Dashboard Library card now uses music's full markup — header icon, Refresh/
  Deep Scan buttons WITH their icons, and stat rows with icons (movies/shows/
  episodes/disk). Same .library-status-* classes, no custom CSS.
- Tools 'Library Scan' card now mirrors the music Database Updater: a mode
  dropdown (Incremental/Full Refresh/Deep Scan) + one Scan button inside
  .tool-card-controls + the standard progress bar. Styling comes for free from
  the generic music classes.
- Dropped bespoke .video-tool-btn/.video-scan-controls CSS and folded the
  separate video-tools.js into the shared video-scan.js (one fewer file). JS
  stays isolated only because it must hit /api/video + update video DOM.
110 tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-13 23:50:41 -07:00
parent 71f126f9d2
commit 7ab62674ed
5 changed files with 111 additions and 122 deletions

View file

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

View file

@ -552,39 +552,56 @@
<div class="library-status-card">
<div class="library-status-glow"></div>
<div class="library-status-header">
<div class="library-status-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="9" y1="7" x2="16" y2="7"/><line x1="9" y1="11" x2="14" y2="11"/></svg>
</div>
<div class="library-status-info">
<h4 class="library-status-title">Video Library</h4>
<p class="library-status-subtitle">Not scanned yet</p>
<p class="library-status-subtitle">Movies, shows &amp; episodes</p>
</div>
<div class="library-status-actions">
<button class="library-status-btn" type="button" data-video-scan-mode="full">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
<span>Refresh</span>
</button>
<button class="library-status-btn library-status-btn-secondary" type="button" data-video-scan-mode="deep">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
<span>Deep Scan</span>
</button>
</div>
</div>
<div class="library-status-stats">
<div class="library-status-stat">
<div class="library-status-stat-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="20" rx="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="17" x2="22" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/></svg>
</div>
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="movies">0</span>
<span class="library-status-stat-label">Movies</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="15" rx="2"/><polyline points="17 2 12 7 7 2"/></svg>
</div>
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="shows">0</span>
<span class="library-status-stat-label">Shows</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="episodes">0</span>
<span class="library-status-stat-label">Episodes</span>
</div>
</div>
<div class="library-status-stat">
<div class="library-status-stat-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
</div>
<div class="library-status-stat-text">
<span class="library-status-stat-value" data-video-stat="library-size">--</span>
<span class="library-status-stat-label">Disk Size</span>
@ -721,14 +738,21 @@
<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>
<p class="tool-card-info">Pull movies &amp; shows from your media server. Incremental = recently-added only; Full Refresh = re-read everything; Deep Scan = also remove items no longer on the server.</p>
<div class="tool-card-controls">
<select data-video-scan-select>
<option value="incremental">Incremental Update</option>
<option value="full">Full Refresh</option>
<option value="deep">Deep Scan</option>
</select>
<button type="button" data-video-scan-run>Scan Library</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" data-video-tools-scan-status>Idle</p>
<p class="progress-phase-label" data-video-scan-phase>Idle</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" data-video-scan-bar style="width: 0%;"></div>
</div>
<p class="progress-details-label" data-video-scan-detail>0 movies, 0 shows</p>
</div>
</div>
</div>
@ -8865,8 +8889,6 @@
<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

@ -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: <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/.
* 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');
});

View file

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

View file

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