diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-09 20:16:41 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-09 20:16:41 +0300 |
| commit | 8aa495df3b3da9213bdf29d7a15c1473fefcc63b (patch) | |
| tree | fff04a324527e08b75a9667dd4e81b5a07a0ea0d /web | |
| parent | c68965ac297f6b4f0da4ad541e921646c320c489 (diff) | |
s1 add media rescan hotkey and progress indicator
Diffstat (limited to 'web')
| -rw-r--r-- | web/index.html | 1 | ||||
| -rw-r--r-- | web/js/admin.js | 4 | ||||
| -rw-r--r-- | web/js/app.js | 5 | ||||
| -rw-r--r-- | web/js/keyboard.js | 4 | ||||
| -rw-r--r-- | web/js/tests/admin-rescan.test.js | 156 | ||||
| -rw-r--r-- | web/js/views/admin-status.js | 25 |
6 files changed, 186 insertions, 9 deletions
diff --git a/web/index.html b/web/index.html index a3df292..474f49e 100644 --- a/web/index.html +++ b/web/index.html @@ -137,6 +137,7 @@ <div class="help-row"><span><kbd>Space</kbd> <kbd>p</kbd></span><span>Play / pause</span></div> <div class="help-row"><span><kbd>N</kbd> <kbd>P</kbd></span><span>Next / previous track</span></div> <div class="help-row"><span><kbd>R</kbd></span><span>Play random track</span></div> + <div class="help-row"><span><kbd>M</kbd></span><span>Rescan media library</span></div> <div class="help-row"><span><kbd>i</kbd></span><span>Show / hide media info</span></div> <div class="help-row"><span><kbd>n</kbd></span><span>Open notes</span></div> <div class="help-row"><span><kbd>f</kbd></span><span>Toggle fullscreen</span></div> diff --git a/web/js/admin.js b/web/js/admin.js index 86ad471..ac43643 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -1,5 +1,6 @@ import { API } from './api.js'; import { escapeHtml, toast } from './utils.js'; +import { triggerRescan } from './views/admin-status.js'; export function initAdmin() { const btn = document.getElementById('admin-toggle'); @@ -18,8 +19,7 @@ export function initAdmin() { rescanBtn?.addEventListener('click', async () => { rescanBtn.disabled = true; - try { await API.rescan(); toast('Rescan triggered'); } - catch (err) { toast(err.message, 'error'); } + try { await triggerRescan(); } finally { rescanBtn.disabled = false; } }); diff --git a/web/js/app.js b/web/js/app.js index 9ecd854..5590293 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -29,7 +29,7 @@ import { state } from './state.js'; import { initPWA } from './pwa.js'; import { closeAllModals } from './dom.js'; import { toast } from './utils.js'; -import { showAdmin } from './views/admin-status.js'; +import { showAdmin, triggerRescan } from './views/admin-status.js'; import { initHelp, showSearch, toggleHelp, toggleSidebar } from './views/help.js'; import { initMediaGrid, @@ -254,6 +254,9 @@ function keyboardHandlers() { nextTrack: () => navigatePlayable(1, { forcePlay: true }), prevTrack: () => navigatePlayable(-1, { forcePlay: true }), playRandom: () => playRandom(), + rescanMedia: () => { + if (state.isAdmin) triggerRescan(); + }, mediaInfo: () => toggleMediaInfo(), fullscreen: () => toggleFullscreen(), toggleMinimize: () => toggleMinimize(), diff --git a/web/js/keyboard.js b/web/js/keyboard.js index 8f07f88..1636754 100644 --- a/web/js/keyboard.js +++ b/web/js/keyboard.js @@ -261,6 +261,10 @@ export function initKeyboard(handlers) { e.preventDefault(); handlers.playRandom?.(e); break; + case 'M': + e.preventDefault(); + handlers.rescanMedia?.(e); + break; // Note: 's' / 'S' are handled above by the e.code === 'KeyS' block case '/': e.preventDefault(); diff --git a/web/js/tests/admin-rescan.test.js b/web/js/tests/admin-rescan.test.js new file mode 100644 index 0000000..f1661db --- /dev/null +++ b/web/js/tests/admin-rescan.test.js @@ -0,0 +1,156 @@ +import { initKeyboard } from '../keyboard.js'; +import { renderScanProgress, triggerRescan } from '../views/admin-status.js'; + +const failures = []; +const requests = []; +let keydownHandler = null; +let rescanStatus = 200; + +function assert(cond, msg) { + if (!cond) failures.push(msg || 'assertion failed'); +} + +const indicator = mockElement(); +const indicatorText = mockElement(); +const toastEl = { + className: '', + textContent: '', + classList: { remove() {} }, +}; + +globalThis.document = { + addEventListener(type, handler) { + if (type === 'keydown') keydownHandler = handler; + }, + getElementById(id) { + if (id === 'scan-indicator') return indicator; + if (id === 'scan-indicator-text') return indicatorText; + if (id === 'toast') return toastEl; + return null; + }, +}; +globalThis.location = { pathname: '/', href: '' }; +globalThis.setTimeout = () => 0; + +globalThis.fetch = async (url, options = {}) => { + requests.push({ url: String(url), method: options.method || 'GET' }); + if (String(url) === '/api/admin/rescan') { + if (rescanStatus !== 200) { + return jsonResponse({ error: 'admin only' }, rescanStatus); + } + return jsonResponse({ status: 'ok' }); + } + if (String(url) === '/api/admin/scan-progress') { + return jsonResponse({ + running: true, + current_set: 'movies', + sets_total: 2, + sets_done: 1, + files_total: 9, + files_done: 4, + }); + } + return jsonResponse({}); +}; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function mockElement() { + return { + textContent: '', + hidden: true, + classList: { + add(name) { + if (name === 'hidden') this.hidden = true; + }, + remove(name) { + if (name === 'hidden') this.hidden = false; + }, + hidden: true, + }, + }; +} + +function pressKey(key) { + let prevented = false; + keydownHandler?.({ + key, + code: `Key${key.toUpperCase()}`, + target: { tagName: 'BODY', isContentEditable: false }, + preventDefault() { prevented = true; }, + }); + return prevented; +} + +function testKeyboardRescanHandler() { + let rescans = 0; + initKeyboard({ rescanMedia: () => { rescans += 1; } }); + + const prevented = pressKey('M'); + + assert(prevented, 'M should prevent default browser handling'); + assert(rescans === 1, 'M should call the rescan handler'); +} + +function testRenderRunningProgress() { + renderScanProgress({ + running: true, + current_set: 'music', + sets_total: 3, + sets_done: 2, + files_total: 20, + files_done: 7, + }); + + assert(indicator.classList.hidden === false, 'running progress should show the indicator'); + assert(indicatorText.textContent === 'Scanning music 2/3 sets, 7/20 files', 'running progress text should include set and file counts'); +} + +function testRenderIdleProgressHidesIndicator() { + renderScanProgress({ running: false }); + + assert(indicator.classList.hidden === true, 'idle progress should hide the indicator'); +} + +async function testTriggerRescanRefreshesProgress() { + requests.length = 0; + rescanStatus = 200; + await triggerRescan(); + + assert(requests[0]?.url === '/api/admin/rescan', 'trigger should call rescan endpoint first'); + assert(requests[0]?.method === 'POST', 'trigger should post to rescan endpoint'); + assert(requests[1]?.url === '/api/admin/scan-progress', 'trigger should refresh progress after starting scan'); + assert(indicator.classList.hidden === false, 'trigger should show refreshed running progress'); +} + +async function testTriggerRescanErrorDoesNotPollProgress() { + requests.length = 0; + toastEl.textContent = ''; + rescanStatus = 403; + + await triggerRescan(); + + assert(requests.length === 1, 'failed trigger should not poll scan progress'); + assert(toastEl.textContent === 'admin only', 'failed trigger should show the API error'); +} + +console.log('Running admin rescan frontend tests...'); +testKeyboardRescanHandler(); +testRenderRunningProgress(); +testRenderIdleProgressHidesIndicator(); +await testTriggerRescanRefreshesProgress(); +await testTriggerRescanErrorDoesNotPollProgress(); + +if (failures.length) { + console.error('FAILURES:'); + failures.forEach((m) => console.error(' - ' + m)); + process.exit(1); +} else { + console.log('All admin rescan frontend tests passed.'); + process.exit(0); +} diff --git a/web/js/views/admin-status.js b/web/js/views/admin-status.js index 3db7394..9a1d2f9 100644 --- a/web/js/views/admin-status.js +++ b/web/js/views/admin-status.js @@ -1,4 +1,5 @@ import { API } from '../api.js'; +import { toast } from '../utils.js'; let scanProgressTimer = null; @@ -8,19 +9,29 @@ export function showAdmin() { startScanProgressPolling(); } -function startScanProgressPolling() { +export async function triggerRescan() { + try { + await API.rescan(); + await refreshScanProgress(); + toast('Rescan triggered'); + } catch (err) { + toast(err.message || 'Rescan failed', 'error'); + } +} + +export function startScanProgressPolling() { if (scanProgressTimer) return; - pollScanProgress(); - scanProgressTimer = setInterval(pollScanProgress, 2000); + refreshScanProgress(); + scanProgressTimer = setInterval(refreshScanProgress, 2000); } -async function pollScanProgress() { +export async function refreshScanProgress() { try { renderScanProgress(await API.scanProgress()); } catch {} } -function renderScanProgress(progress) { +export function renderScanProgress(progress) { const indicator = document.getElementById('scan-indicator'); const text = document.getElementById('scan-indicator-text'); if (!indicator || !text || !progress) return; @@ -33,7 +44,9 @@ function renderScanProgress(progress) { const setPart = progress.current_set ? ` ${progress.current_set}` : ''; const setsTotal = progress.sets_total || 0; const setsPart = setsTotal ? ` ${progress.sets_done || 0}/${setsTotal} sets` : ''; - const filesPart = progress.files_done ? `, ${progress.files_done} files` : ''; + const filesTotal = progress.files_total || 0; + const filesDone = progress.files_done || 0; + const filesPart = filesTotal ? `, ${filesDone}/${filesTotal} files` : (filesDone ? `, ${filesDone} files` : ''); text.textContent = `Scanning${setPart}${setsPart}${filesPart}`; indicator.classList.remove('hidden'); } |
