Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
88 changes: 8 additions & 80 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
35 changes: 20 additions & 15 deletions src/api/file-explorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
});
});
}
33 changes: 33 additions & 0 deletions src/api/scan.js
Original file line number Diff line number Diff line change
@@ -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,
})));
});
}
13 changes: 7 additions & 6 deletions src/api/scrobbler.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({});
});
}
Expand Down
14 changes: 2 additions & 12 deletions src/api/velvet-stubs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading