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:
parent
04fb19c80c
commit
71f126f9d2
8 changed files with 265 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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 & 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) -->
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
88
webui/static/video/video-scan.js
Normal file
88
webui/static/video/video-scan.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
56
webui/static/video/video-tools.js
Normal file
56
webui/static/video/video-tools.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in a new issue