diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33e7a164..501d3cec 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,10 +29,13 @@ jobs: with: node-version: 24 - - name: Install dependencies - run: npm install + - name: Install production dependencies + run: npm install --omit=dev + + - name: Prune node_modules + run: npx --yes clean-modules@^3 --yes - name: Build Electron app env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: npx electron-builder --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} + run: npx --yes electron-builder@^26.7.0 --publish ${{ startsWith(github.ref, 'refs/tags/v') && 'always' || 'never' }} diff --git a/package-lock.json b/package-lock.json index 446020f2..ef7213f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "GPL-3.0", "dependencies": { "archiver": "^7.0.1", - "axios": "1.14.0", "busboy": "^1.6.0", "command-exists": "^1.2.9", "commander": "^14.0.3", @@ -23,13 +22,11 @@ "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "m3u8-parser": "^7.2.0", - "make-dir": "^5.0.0", "mime-types": "^3.0.2", "music-metadata": "^11.11.1", "nanoid": "^5.0.9", "tree-kill": "^1.2.2", "winston": "^3.19.0", - "winston-daily-rotate-file": "^5.0.0", "ws": "^8.19.0" }, "bin": { @@ -2615,6 +2612,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -2636,17 +2634,6 @@ "node": ">=6.0.0" } }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, "node_modules/b4a": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.4.tgz", @@ -3305,6 +3292,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3662,6 +3650,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -4086,6 +4075,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4534,15 +4524,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-stream-rotator": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", - "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", - "license": "MIT", - "dependencies": { - "moment": "^2.29.1" - } - }, "node_modules/file-type": { "version": "16.5.4", "resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz", @@ -4712,6 +4693,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4728,6 +4710,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4737,6 +4720,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -5109,6 +5093,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5996,18 +5981,6 @@ "global": "^4.4.0" } }, - "node_modules/make-dir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-5.1.0.tgz", - "integrity": "sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/make-fetch-happen": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", @@ -6410,15 +6383,6 @@ } } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -6772,15 +6736,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -7465,15 +7420,6 @@ "node": ">= 0.10" } }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -9425,24 +9371,6 @@ "node": ">= 12.0.0" } }, - "node_modules/winston-daily-rotate-file": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-5.0.0.tgz", - "integrity": "sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==", - "license": "MIT", - "dependencies": { - "file-stream-rotator": "^0.6.1", - "object-hash": "^3.0.0", - "triple-beam": "^1.4.1", - "winston-transport": "^4.7.0" - }, - "engines": { - "node": ">=8" - }, - "peerDependencies": { - "winston": "^3" - } - }, "node_modules/winston-transport": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", diff --git a/package.json b/package.json index 61d9059e..872d632b 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,6 @@ }, "dependencies": { "archiver": "^7.0.1", - "axios": "1.14.0", "busboy": "^1.6.0", "command-exists": "^1.2.9", "commander": "^14.0.3", @@ -121,13 +120,11 @@ "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", "m3u8-parser": "^7.2.0", - "make-dir": "^5.0.0", "mime-types": "^3.0.2", "music-metadata": "^11.11.1", "nanoid": "^5.0.9", "tree-kill": "^1.2.2", "winston": "^3.19.0", - "winston-daily-rotate-file": "^5.0.0", "ws": "^8.19.0" }, "devDependencies": { diff --git a/src/api/file-explorer.js b/src/api/file-explorer.js index 010b92f1..8ad2a652 100644 --- a/src/api/file-explorer.js +++ b/src/api/file-explorer.js @@ -3,7 +3,6 @@ import fs from 'fs/promises'; import fsOld from 'fs'; import busboy from 'busboy'; import Joi from 'joi'; -import { makeDirectorySync } from 'make-dir'; import winston from 'winston'; import * as fileExplorer from '../util/file-explorer.js'; import * as vpath from '../util/vpath.js'; @@ -121,7 +120,7 @@ export function setup(mstream) { if (!req.headers['data-location']) { throw new WebError('No Location Provided', 403); } const pathInfo = vpath.getVPathInfo(decodeURI(req.headers['data-location']), req.user); - makeDirectorySync(pathInfo.fullPath); + fsOld.mkdirSync(pathInfo.fullPath, { recursive: true }); const bb = busboy({ headers: req.headers }); bb.on('file', (fieldname, file, info) => { @@ -154,20 +153,26 @@ export function setup(mstream) { const songs = await m3u.readPlaylistSongs(pathInfo.fullPath); const vpathRoot = path.resolve(pathInfo.basePath); const playlistDir = path.dirname(pathInfo.fullPath); + + // Defense-in-depth: every entry must resolve within the library root. + const safe = []; + let skipped = 0; + for (const song of songs) { + const resolved = path.resolve(playlistDir, song); + if (resolved === vpathRoot || resolved.startsWith(vpathRoot + path.sep)) { + safe.push(song); + } else { + skipped += 1; + } + } + res.json({ - files: songs - .filter(song => { - // Defense-in-depth: verify resolved path stays within library root - const resolved = path.resolve(playlistDir, song); - return resolved.startsWith(vpathRoot + path.sep) || resolved === vpathRoot; - }) - .map((song) => { - return { - type: fileExplorer.getFileType(song), - name: path.basename(song), - path: path.join(playlistParentDir, song).replace(/\\/g, '/') - }; - }) + files: safe.map((song) => ({ + type: fileExplorer.getFileType(song), + name: path.basename(song), + path: path.join(playlistParentDir, song).replace(/\\/g, '/'), + })), + skipped, }); }); } diff --git a/src/api/scan.js b/src/api/scan.js new file mode 100644 index 00000000..42b84dd4 --- /dev/null +++ b/src/api/scan.js @@ -0,0 +1,33 @@ +// Library scan progress endpoint. +// +// Authenticated (not admin-only). Rows are filtered to the vpaths the caller +// can see — admins additionally see pre-vpath "counting" rows where the +// scanner hasn't assigned a library yet. +// +// The backing `scan_progress` table is written live by the scanner +// (src/db/scanner.mjs) and cleared by task-queue.js when a scan finishes. + +import path from 'path'; +import * as db from '../db/manager.js'; + +export function setup(mstream) { + mstream.get('/api/v1/scan/progress', (req, res) => { + const userVpaths = Array.isArray(req.user?.vpaths) ? req.user.vpaths : []; + const isAdmin = req.user?.admin === true; + + const rows = db.getDB().prepare('SELECT * FROM scan_progress').all(); + const visible = rows.filter(r => { + if (!r.vpath) { return isAdmin; } + return userVpaths.includes(r.vpath); + }); + + res.json(visible.map(r => ({ + vpath: r.vpath || 'Scanning…', + pct: r.expected ? Math.min(100, Math.round((r.scanned / r.expected) * 100)) : null, + scanned: r.scanned || 0, + expected: r.expected || null, + // basename only — never expose absolute server paths + currentFile: r.current_file ? path.basename(r.current_file) : null, + }))); + }); +} diff --git a/src/api/scrobbler.js b/src/api/scrobbler.js index 57fab08c..9b198f17 100644 --- a/src/api/scrobbler.js +++ b/src/api/scrobbler.js @@ -1,6 +1,5 @@ import crypto from 'crypto'; import Joi from 'joi'; -import axios from 'axios'; import * as config from '../state/config.js'; import Scribble from '../state/lastfm.js'; import * as db from '../db/manager.js'; @@ -74,7 +73,7 @@ export function setup(mstream) { if (!lib) { return res.json({ scrobble: false }); } const track = d().prepare(` - SELECT t.file_hash, t.audio_hash, t.title, a.name AS artist, al.name AS album + SELECT t.file_hash, t.title, a.name AS artist, al.name AS album FROM tracks t LEFT JOIN artists a ON t.artist_id = a.id LEFT JOIN albums al ON t.album_id = al.id @@ -122,10 +121,12 @@ export function setup(mstream) { const cryptoString = `api_key${config.program.lastFM.apiKey}authToken${token}methodauth.getMobileSessionusername${req.body.username}${config.program.lastFM.apiSecret}`; const hash = crypto.createHash('md5').update(cryptoString, 'utf8').digest('hex'); - await axios({ - method: 'GET', - url: `http://ws.audioscrobbler.com/2.0/?method=auth.getMobileSession&username=${req.body.username}&authToken=${token}&api_key=${config.program.lastFM.apiKey}&api_sig=${hash}` - }); + const lastfmRes = await fetch( + `http://ws.audioscrobbler.com/2.0/?method=auth.getMobileSession&username=${req.body.username}&authToken=${token}&api_key=${config.program.lastFM.apiKey}&api_sig=${hash}` + ); + if (!lastfmRes.ok) { + throw new Error(`last.fm test-login returned ${lastfmRes.status}`); + } res.json({}); }); } diff --git a/src/api/velvet-stubs.js b/src/api/velvet-stubs.js index b5326309..90e2b82b 100644 --- a/src/api/velvet-stubs.js +++ b/src/api/velvet-stubs.js @@ -266,18 +266,8 @@ export function setup(mstream) { res.json(libs.map(l => ({ name: l.name, root: l.root_path, type: l.type }))); }); - // ── Scan progress (reads from scan_progress table written by scanners) ── - mstream.get('/api/v1/admin/db/scan/progress', (req, res) => { - const rows = d().prepare('SELECT * FROM scan_progress').all(); - res.json(rows.map(r => ({ - vpath: r.vpath || 'Scanning…', - pct: r.expected ? Math.min(100, Math.round((r.scanned / r.expected) * 100)) : null, - scanned: r.scanned || 0, - expected: r.expected || null, - currentFile: r.current_file || null, - countingFound: 0 - }))); - }); + // Scan progress moved to /api/v1/scan/progress (core API, not admin-only, + // vpath-filtered per caller, basenames only). See src/api/scan.js. // ══════════════════════════════════════════════════════════════ // STUBS — features not yet implemented, return safe defaults diff --git a/src/logger.js b/src/logger.js index edd60b66..375e6f32 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,18 +1,22 @@ import winston from 'winston'; -import 'winston-daily-rotate-file'; +import fs from 'fs'; +import path from 'path'; import os from 'os'; let fileTransport; +let rotateInterval; +let currentDirname; +let currentDateKey; const myFormat = winston.format.printf(info => { - let msg = `${info.timestamp} ${info.level}: ${info.message}`; + const msg = `${info.timestamp} ${info.level}: ${info.message}`; if (!info.stack) { return msg; } const stackStr = typeof info.stack === 'string' ? { stack: info.stack } : JSON.parse(JSON.stringify(info.stack, Object.getOwnPropertyNames(info.stack))); - return msg += os.EOL + stackStr.stack; + return msg + os.EOL + stackStr.stack; }); winston.configure({ @@ -28,31 +32,73 @@ winston.configure({ exitOnError: false }); -export function addFileLogger(filepath) { - if (fileTransport) { - reset(); - } +function dateKey() { + const d = new Date(); + const pad = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}`; +} + +// Matches mstream-YYYY-MM-DD-HH.log and size-rotated variants mstream-...log.1, .log.2, etc. +const LOG_FILE_PATTERN = /^mstream-\d{4}-\d{2}-\d{2}-\d{2}\.log(\.\d+)?$/; - fileTransport = new (winston.transports.DailyRotateFile)({ - filename: 'mstream-%DATE%', - dirname: filepath, - extension: '.log', - datePattern: 'YYYY-MM-DD-HH', - maxSize: '20m', - maxFiles: '14d', +function pruneOldLogs(dirname, maxAgeDays) { + try { + const cutoff = Date.now() - maxAgeDays * 86400_000; + for (const f of fs.readdirSync(dirname)) { + if (!LOG_FILE_PATTERN.test(f)) { continue; } + const full = path.join(dirname, f); + if (fs.statSync(full).mtimeMs < cutoff) { + fs.unlinkSync(full); + } + } + } catch { /* best-effort cleanup */ } +} + +function buildFileTransport(dirname, key) { + return new winston.transports.File({ + filename: path.join(dirname, `mstream-${key}.log`), + maxsize: 20 * 1024 * 1024, format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), }); +} + +function rotateIfNeeded() { + const key = dateKey(); + if (key === currentDateKey) { return; } + if (fileTransport) { winston.remove(fileTransport); } + currentDateKey = key; + fileTransport = buildFileTransport(currentDirname, key); winston.add(fileTransport); + pruneOldLogs(currentDirname, 14); +} + +export function addFileLogger(filepath) { + if (fileTransport) { reset(); } + + fs.mkdirSync(filepath, { recursive: true }); + currentDirname = filepath; + currentDateKey = dateKey(); + fileTransport = buildFileTransport(filepath, currentDateKey); + winston.add(fileTransport); + pruneOldLogs(filepath, 14); + + rotateInterval = setInterval(rotateIfNeeded, 60_000); + rotateInterval.unref(); } export function reset() { + if (rotateInterval) { + clearInterval(rotateInterval); + rotateInterval = undefined; + } if (fileTransport) { winston.remove(fileTransport); + fileTransport = undefined; } - - fileTransport = undefined; + currentDateKey = undefined; + currentDirname = undefined; } diff --git a/src/server.js b/src/server.js index bb82a0cb..7df1f1b2 100644 --- a/src/server.js +++ b/src/server.js @@ -35,6 +35,7 @@ import * as userApiKeysApi from './api/user-api-keys.js'; import * as serverPlaybackApi from './api/server-playback.js'; import * as albumArtApi from './api/album-art.js'; import * as waveformApi from './api/waveform.js'; +import * as scanApi from './api/scan.js'; import * as lyricsApi from './api/lyrics.js'; import * as lyricsLrclib from './api/lyrics-lrclib.js'; // Velvet UI modules — dynamically imported only when ui='velvet' is active @@ -253,6 +254,7 @@ export async function serveIt(configFile) { ytdlApi.setup(mstream); albumArtApi.setup(mstream); waveformApi.setup(mstream); + scanApi.setup(mstream); lyricsApi.setup(mstream); // V20 housekeeping: clean up 'pending' lyrics_cache rows from any // previous process that crashed mid-fetch, and start the periodic diff --git a/webapp/alpha/m.js b/webapp/alpha/m.js index 79ad7056..eabeb74c 100644 --- a/webapp/alpha/m.js +++ b/webapp/alpha/m.js @@ -471,7 +471,6 @@ function playNow(el) { VUEPLAYERCORE.addSongWizard(el.getAttribute("data-file_location"), {}, true, MSTREAMPLAYER.positionCache.val + 1); } -let startInterval = false; async function init() { try { const response = await MSTREAMAPI.ping(); @@ -560,40 +559,13 @@ async function init() { } }catch(err) {} - - dbStatus(); } -async function dbStatus() { - try { - const response = await MSTREAMAPI.dbStatus(); - // if not scanning - if (!response.locked || response.locked === false) { - clearInterval(startInterval); - startInterval = false; - document.getElementById('scan-status').innerHTML = ''; - document.getElementById('scan-status-files').innerHTML = ''; - - return; - } - - // Set Interval - if (startInterval === false) { - startInterval = setInterval(function () { - dbStatus(); - }, 2000); - } - - // Update status - document.getElementById('scan-status').innerHTML = t('status.scanInProgress'); - document.getElementById('scan-status-files').innerHTML = t('status.filesInDB', { count: response.totalFileCount }); - }catch(err) { - document.getElementById('scan-status').innerHTML = ''; - document.getElementById('scan-status-files').innerHTML = ''; - clearInterval(startInterval); - startInterval = false; - } -} +// Scan progress display moved to webapp/alpha/scan-progress.js — that +// poller hits /api/v1/scan/progress unconditionally on a 3s interval and +// renders rich per-vpath cards. The old dbStatus() polled /api/v1/db/status +// only after seeing locked=true once, so a scan triggered post-page-load +// never appeared. Removed entirely. function createPopper3(el) { if (curFileTracker === el.getAttribute("data-file_location")) { diff --git a/webapp/alpha/scan-progress.js b/webapp/alpha/scan-progress.js new file mode 100644 index 00000000..daf2643a --- /dev/null +++ b/webapp/alpha/scan-progress.js @@ -0,0 +1,69 @@ +// Top-bar scan progress indicator for the main UI. +// Polls GET /api/v1/scan/progress and renders one `.spc-card` per active scan. +// Empty response → empty wrap → no visible UI (zero layout impact when idle). + +(function () { + const POLL_INTERVAL_MS = 3000; + let timer = null; + + function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function render(scans) { + const wrap = document.getElementById('scan-progress-wrap'); + if (!wrap) { return; } + if (!Array.isArray(scans) || scans.length === 0) { + wrap.innerHTML = ''; + return; + } + wrap.innerHTML = scans.map(sp => { + const pctTxt = sp.pct != null ? `${sp.pct}%` : 'Counting…'; + const bar = sp.pct != null + ? `
` + : ``; + const countTxt = sp.expected + ? `${sp.scanned.toLocaleString()} / ${sp.expected.toLocaleString()}` + : `${sp.scanned.toLocaleString()} files`; + const titleAttr = sp.currentFile + ? ` title="${escapeHtml(sp.currentFile)}"` + : ''; + return `