From 013269691a60a271937ce2473219c226d242998a Mon Sep 17 00:00:00 2001 From: IrosTheBeggar Date: Sat, 18 Apr 2026 19:06:29 -0400 Subject: [PATCH 1/3] update actions (cherry picked from commit b25b40da11aa6f1289170a1575a08957612a4a9e) --- .github/workflows/build.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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' }} From d13834051b8514851409e4938e9c5c608f0e315c Mon Sep 17 00:00:00 2001 From: IrosTheBeggar Date: Sat, 25 Apr 2026 02:01:23 -0400 Subject: [PATCH 2/3] drop unused deps: axios, winston-daily-rotate-file, make-dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small modernizations from PR #558 (the syncthing/electron PR), pulled out so they can land independent of the Electron Desktop Player work. * src/api/scrobbler.js — POST /api/v1/lastfm/test-login was the last caller of axios. Switched to native fetch (Node ≥22), drop the dep. * src/logger.js — was using winston-daily-rotate-file for log rotation. Replaced with a small in-process rotator that opens a fresh file every hour and prunes anything older than 14 days. * src/api/file-explorer.js — `makeDirectorySync` from `make-dir` was the only caller; native `fs.mkdirSync(p, { recursive: true })` covers the same use. Also tightened the playlist-resolver loop and added a `skipped` count to the response so clients can see when entries were filtered for traversal. --- package-lock.json | 88 ++++------------------------------------ package.json | 3 -- src/api/file-explorer.js | 35 +++++++++------- src/api/scrobbler.js | 13 +++--- src/logger.js | 78 +++++++++++++++++++++++++++-------- 5 files changed, 97 insertions(+), 120 deletions(-) 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/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/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; } From c5eb2f218de49f1104e87494887bef59836a7176 Mon Sep 17 00:00:00 2001 From: IrosTheBeggar Date: Sat, 25 Apr 2026 02:01:50 -0400 Subject: [PATCH 3/3] scan progress: core API + UI poller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the scan-progress refactor out of PR #558 (syncthing/electron) so the player UI can show live scan progress on a default-UI install today, without waiting for the Desktop Player feature to ship. Three problems with the old flow: 1. The scan_progress table was readable only via /api/v1/admin/db/scan/ progress, mounted in velvet-stubs.js and gated under /admin/. On a default-UI install the endpoint didn't exist (velvet-stubs only loaded when ui='velvet'), and even on velvet, non-admin users couldn't reach it. 2. The default UI's player polled /api/v1/db/status (coarse boolean lock + total file count) and only set up its 2s interval AFTER seeing locked=true once. A scan triggered after page-load went invisible until the next refresh. 3. The renderer only knew how to write 'Scan In Progress' + a count to a pair of divs. No per-vpath progress, no current-file display. Changes: * src/api/scan.js (NEW) — GET /api/v1/scan/progress. Authenticated but not admin-only. Filters rows to vpaths the caller can see; admins also see pre-vpath 'counting' rows where the scanner hasn't assigned a library yet. currentFile is stripped to basename so absolute server paths don't leak. * src/api/velvet-stubs.js — drop the old /api/v1/admin/db/scan/progress; one comment in its place pointing at the new endpoint. * src/server.js — mount scanApi alongside the other core APIs (always loaded, regardless of ui setting). * webapp/alpha/scan-progress.js (NEW) — polls every 3s unconditionally. Empty response → empty wrap → no visible UI when idle. Renders a per-vpath card with vpath name, progress bar, percent, scanned/expected counts, and current-file as the title attribute. * webapp/alpha/spa.css — .spc-* rules for the cards (palette adapted to the main UI's colors; the velvet variables aren't defined here). * webapp/index.html — replaces the legacy
+
with
; loads the new scan-progress.js script. * webapp/alpha/m.js — removes the old async dbStatus() + its caller + the unused startInterval. The function wrote to the now-removed scan-status DOM ids and would have thrown on every page load. MSTREAMAPI.dbStatus and the GET /api/v1/db/status endpoint are left in place — both are public surface and may have external callers. --- src/api/scan.js | 33 ++++++++ src/api/velvet-stubs.js | 14 +--- src/server.js | 2 + webapp/alpha/m.js | 38 ++-------- webapp/alpha/scan-progress.js | 69 +++++++++++++++++ webapp/alpha/spa.css | 137 ++++++++++++++++++++-------------- webapp/index.html | 4 +- 7 files changed, 193 insertions(+), 104 deletions(-) create mode 100644 src/api/scan.js create mode 100644 webapp/alpha/scan-progress.js 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/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/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 `
+ + ${escapeHtml(sp.vpath)} +
${bar}
+ ${escapeHtml(pctTxt)} + ${escapeHtml(countTxt)} +
`; + }).join(''); + } + + async function tick() { + try { + const server = (typeof MSTREAMAPI !== 'undefined') ? MSTREAMAPI.currentServer : null; + if (!server || !server.host || !server.token) { return; } + const res = await fetch(server.host + 'api/v1/scan/progress', { + headers: { 'x-access-token': server.token }, + }); + if (!res.ok) { return; } + render(await res.json()); + } catch { /* ignore transient network errors */ } + } + + function start() { + if (timer) { return; } + tick(); + timer = setInterval(tick, POLL_INTERVAL_MS); + } + + // Kick off after page ready so MSTREAMAPI has been hydrated by m.js + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } + + // Expose for tests / manual control + window.mstreamScanProgress = { tick, render }; +})(); diff --git a/webapp/alpha/spa.css b/webapp/alpha/spa.css index e82abb21..d33ceee2 100644 --- a/webapp/alpha/spa.css +++ b/webapp/alpha/spa.css @@ -148,26 +148,12 @@ body.top-bar-hidden #content { z-index: 2; display: none; } -/* `wf-enabled` applies when the operator has the waveform-bar setting - turned on, regardless of whether the data for this track has loaded - yet. It reserves the full 28px-tall strip up front so the bar - doesn't grow/shift the moment waveform data arrives for the track - (previously the plain progress bar sat short, then jumped taller - when `wf-active` kicked in — a visible layout shake every song). - The plain orange `.determinate` fill continues to render inside the - tall strip while data loads. */ -.progress.wf-enabled { - padding-top: 28px; - margin-top: 12px; -} -/* `wf-active` is the narrower "data is actually loaded, show the - canvas" state. Hides the plain determinate fill and swaps in the - waveform overlay. Layers on top of `wf-enabled` so the tall - strip stays put. */ .progress.wf-active .waveform-canvas { display: block; } .progress.wf-active .determinate { visibility: hidden; } .progress.wf-active { background-color: transparent !important; + padding-top: 28px; + margin-top: 12px; } /* Keep time indicators above the waveform */ .progress-wrapper .left, @@ -896,7 +882,7 @@ body.top-bar-hidden #content { padding-right: 6px; } -.removeSong, .deletePlaylist, .removePlaylistSong, .renamePlaylist{ +.removeSong, .deletePlaylist, .removePlaylistSong{ cursor: pointer; min-width: 28px !important; height: 14px; @@ -920,46 +906,6 @@ body.top-bar-hidden #content { border-bottom-left-radius: 3px; } -.renamePlaylist { - background-color: rgba(70, 130, 200, .75); - line-height: 100% !important; - padding-left: 7px; - padding-right: 7px; - margin-right: 2px; -} - -.renamePlaylist:hover { - opacity: 1; - background-color: rgba(70, 130, 200, .9); -} - -.rename-playlist-input { - width: 260px; - padding: 6px 8px; - font-size: 14px; - border: 1px solid #ccc; - border-radius: 3px; - box-sizing: border-box; -} - -/* iziToast sets `user-select: none` on the entire toast so the toast body - itself isn't selectable. That cascades into child inputs too and blocks - double-click-to-select inside our text fields. Re-enable selection for - any text-like input living inside an iziToast. */ -.iziToast input[type="text"], -.iziToast input[type="search"], -.iziToast input[type="email"], -.iziToast input[type="url"], -.iziToast input[type="password"], -.iziToast input[type="number"], -.iziToast textarea { - -webkit-user-select: text; - -moz-user-select: text; - -ms-user-select: text; - user-select: text; - -webkit-touch-callout: default; -} - .downloadPlaylistSong, .recursiveAddDir, .addFileplaylist { min-width: 28px; border-right: 1px solid #9E9E9E; @@ -1437,4 +1383,81 @@ select { .make-white { color: #FFF; +} + +/* ── Compact progress cards (nav bar — scan + sync) ─────────────────────── */ +/* Adapted from webapp/velvet/style.css .spc-* rules with concrete colors + matching the main UI palette (velvet's CSS variables aren't defined here). */ +.spc-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} +.spc-card { + display: flex; + align-items: center; + gap: 7px; + background: #262a33; + border: 1px solid #444c56; + border-left: 2px solid #2ecc71; + border-radius: 4px; + padding: 4px 10px; + cursor: default; + min-width: 180px; + max-width: 320px; +} +.spc-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #2ecc71; + flex-shrink: 0; + animation: spc-pulse 1.4s ease-in-out infinite; +} +@keyframes spc-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } } +.spc-vpath { + font-size: 12px; + font-weight: 700; + color: #F5F7FA; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + max-width: 80px; +} +.spc-track { + flex: 1; + position: relative; + height: 4px; + background: #444c56; + border-radius: 99px; + overflow: hidden; + min-width: 40px; +} +.spc-fill { + height: 100%; + background: #2ecc71; + border-radius: 99px; + transition: width .8s ease-out; +} +.spc-fill-ind { + position: absolute; + inset: 0; + width: 40%; + background: linear-gradient(90deg, transparent, rgb(101, 126, 228), transparent); + animation: spc-shimmer 1.8s ease-in-out infinite; +} +@keyframes spc-shimmer { 0% { transform: translateX(-250%); } 100% { transform: translateX(350%); } } +.spc-pct { + font-size: 11px; + font-weight: 700; + color: #2ecc71; + white-space: nowrap; + flex-shrink: 0; +} +.spc-count { + font-size: 10px; + color: #F5F7FA; + white-space: nowrap; + flex-shrink: 0; } \ No newline at end of file diff --git a/webapp/index.html b/webapp/index.html index 695f1254..c34ddf5b 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -415,8 +415,7 @@
Change Album Art
-
-
+