summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-09 20:16:41 +0300
committerPaul Buetow <paul@buetow.org>2026-05-09 20:16:41 +0300
commit8aa495df3b3da9213bdf29d7a15c1473fefcc63b (patch)
treefff04a324527e08b75a9667dd4e81b5a07a0ea0d /web
parentc68965ac297f6b4f0da4ad541e921646c320c489 (diff)
s1 add media rescan hotkey and progress indicator
Diffstat (limited to 'web')
-rw-r--r--web/index.html1
-rw-r--r--web/js/admin.js4
-rw-r--r--web/js/app.js5
-rw-r--r--web/js/keyboard.js4
-rw-r--r--web/js/tests/admin-rescan.test.js156
-rw-r--r--web/js/views/admin-status.js25
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');
}