From d89647cd5e689e64029b1cb86564e48c0c431d8f Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 22:52:52 +0200 Subject: [PATCH] feat: refactor audio token management and implement cover art retrieval --- .vscode/tasks.json | 40 +++-- src/server/routes/audio.js | 201 +++++------------------- src/server/routes/audio/coverService.js | 27 ++++ src/server/routes/audio/pathUtils.js | 33 ++++ src/server/routes/audio/streaming.js | 46 ++++++ src/server/routes/audio/tokenStore.js | 31 ++++ 6 files changed, 194 insertions(+), 184 deletions(-) create mode 100644 src/server/routes/audio/coverService.js create mode 100644 src/server/routes/audio/pathUtils.js create mode 100644 src/server/routes/audio/streaming.js create mode 100644 src/server/routes/audio/tokenStore.js diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 4d967e0..34e4133 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,22 +1,20 @@ { - "version": "2.0.0", - "tasks": [ - { - "label": "start-dev", - "type": "shell", - "command": "npm run start", - "isBackground": false, - "problemMatcher": [ - "$eslint-stylish" - ], - "group": "build" - }, - { - "label": "start-dev", - "type": "shell", - "command": "npm run start", - "isBackground": false, - "group": "build" - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "label": "start-dev", + "type": "shell", + "command": "npm run start", + "isBackground": false, + "problemMatcher": ["$eslint-stylish"], + "group": "build" + }, + { + "label": "start-dev", + "type": "shell", + "command": "npm run start", + "isBackground": false, + "group": "build" + } + ] +} diff --git a/src/server/routes/audio.js b/src/server/routes/audio.js index 199d4c2..a8df76e 100644 --- a/src/server/routes/audio.js +++ b/src/server/routes/audio.js @@ -1,168 +1,61 @@ -import fs from 'fs'; -import path from 'path'; -import mime from 'mime'; -import crypto from 'crypto'; -import LRUCache from 'lru-cache'; -import { parseFile as mmParseFile } from 'music-metadata'; -import { DATA_DIR } from '../config.js'; - -// Token cache: bounded LRU with TTL per entry -const TOKEN_TTL_MS_DEFAULT = 10 * 60 * 1000; // 10 minutes -const tokenCache = new LRUCache({ - max: 2048, - ttl: TOKEN_TTL_MS_DEFAULT, - ttlAutopurge: true, - allowStale: false, - updateAgeOnGet: false, - updateAgeOnHas: false, -}); - -function genToken() { - return crypto.randomBytes(16).toString('hex'); -} - -function resolveSafePath(name) { - const filePath = path.join(DATA_DIR, name); - const resolved = path.resolve(filePath); - if (!resolved.startsWith(DATA_DIR)) return null; - return resolved; -} +import { TOKEN_TTL_MS_DEFAULT, putToken, getToken } from './audio/tokenStore.js'; +import { resolveSafePath, fileExists, statFile, getMimeType } from './audio/pathUtils.js'; +import { headFile, streamFile } from './audio/streaming.js'; +import { getCoverForFile } from './audio/coverService.js'; export function createAudioToken(name, ttlMs = TOKEN_TTL_MS_DEFAULT) { const resolved = resolveSafePath(name); if (!resolved) throw new Error('Invalid path'); - if (!fs.existsSync(resolved)) throw new Error('Not found'); - const stat = fs.statSync(resolved); - const type = mime.getType(resolved) || 'audio/mpeg'; - const token = genToken(); - tokenCache.set( - token, - { path: resolved, mime: type, size: stat.size }, - { ttl: Math.max(1000, ttlMs) } - ); - return token; + if (!fileExists(resolved)) throw new Error('Not found'); + const stat = statFile(resolved); + const type = getMimeType(resolved, 'audio/mpeg'); + return putToken({ path: resolved, mime: type, size: stat.size }, ttlMs); } export function registerAudioRoutes(app) { - // Simple in-memory cover cache: name -> { mime, buf } - const COVER_CACHE_MAX_ITEMS = 256; - const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB - const coverCache = new LRUCache({ - max: COVER_CACHE_MAX_ITEMS, - maxSize: COVER_CACHE_MAX_BYTES, - sizeCalculation: (v) => (v?.buf?.length ? v.buf.length : 0), - ttl: 5 * 60 * 1000, - ttlAutopurge: true, - }); const ENABLE_NAME_ENDPOINT = process.env.AUDIO_DEBUG_NAMES === '1'; - // Cleanup helper - function getTokenInfo(token) { - const info = tokenCache.get(token); - if (!info) return null; - return info; - } - - // New HEAD endpoint using opaque token + // HEAD by token app.head('/audio/t/:token', (req, res) => { const token = String(req.params.token || ''); - const info = getTokenInfo(token); + const info = getToken(token); if (!info) return res.status(404).end(); - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', info.mime || 'audio/mpeg'); - res.setHeader('Content-Length', info.size); - res.setHeader('Cache-Control', 'no-store'); - return res.status(200).end(); + return headFile(res, { size: info.size, mime: info.mime || 'audio/mpeg' }); }); - // New GET endpoint using opaque token (supports Range requests) + // GET by token (Range support) app.get('/audio/t/:token', (req, res) => { const token = String(req.params.token || ''); - const info = getTokenInfo(token); + const info = getToken(token); if (!info) return res.status(404).send('Not found'); - const { path: filePath, mime: type } = info; - const stat = fs.statSync(filePath); - const range = req.headers.range; - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Cache-Control', 'no-store'); - if (range) { - const match = /bytes=(\d+)-(\d+)?/.exec(range); - let start = match?.[1] ? parseInt(match[1], 10) : 0; - let end = match?.[2] ? parseInt(match[2], 10) : stat.size - 1; - if (Number.isNaN(start)) start = 0; - if (Number.isNaN(end)) end = stat.size - 1; - start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); - end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1)); - if (start > end || start >= stat.size) { - res.setHeader('Content-Range', `bytes */${stat.size}`); - return res.status(416).end(); - } - const chunkSize = end - start + 1; - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${stat.size}`, - 'Content-Length': chunkSize, - 'Content-Type': type, - }); - fs.createReadStream(filePath, { start, end }).pipe(res); - } else { - res.writeHead(200, { - 'Content-Length': stat.size, - 'Content-Type': type, - }); - fs.createReadStream(filePath).pipe(res); + try { + return streamFile(req, res, { filePath: info.path, mime: info.mime || 'audio/mpeg' }); + } catch (error) { + console.error('Stream error (token):', error); + return res.status(500).send('Stream error'); } }); if (ENABLE_NAME_ENDPOINT) { app.head('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = resolveSafePath(name); + const filePath = resolveSafePath(req.params.name); if (!filePath) return res.status(400).end(); - if (!fs.existsSync(filePath)) return res.status(404).end(); - const stat = fs.statSync(filePath); - const type = mime.getType(filePath) || 'audio/mpeg'; - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Content-Type', type); - res.setHeader('Content-Length', stat.size); - res.setHeader('Cache-Control', 'no-store'); - return res.status(200).end(); + if (!fileExists(filePath)) return res.status(404).end(); + const { size } = statFile(filePath); + const type = getMimeType(filePath, 'audio/mpeg'); + return headFile(res, { size, mime: type }); }); app.get('/audio/:name', (req, res) => { - const name = req.params.name; - const filePath = resolveSafePath(name); + const filePath = resolveSafePath(req.params.name); if (!filePath) return res.status(400).send('Invalid path'); - if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); - const stat = fs.statSync(filePath); - const range = req.headers.range; - const type = mime.getType(filePath) || 'audio/mpeg'; - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('Cache-Control', 'no-store'); - if (range) { - const match = /bytes=(\d+)-(\d+)?/.exec(range); - let start = match?.[1] ? parseInt(match[1], 10) : 0; - let end = match?.[2] ? parseInt(match[2], 10) : stat.size - 1; - if (Number.isNaN(start)) start = 0; - if (Number.isNaN(end)) end = stat.size - 1; - start = Math.min(Math.max(0, start), Math.max(0, stat.size - 1)); - end = Math.min(Math.max(start, end), Math.max(0, stat.size - 1)); - if (start > end || start >= stat.size) { - res.setHeader('Content-Range', `bytes */${stat.size}`); - return res.status(416).end(); - } - const chunkSize = end - start + 1; - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${end}/${stat.size}`, - 'Content-Length': chunkSize, - 'Content-Type': type, - }); - fs.createReadStream(filePath, { start, end }).pipe(res); - } else { - res.writeHead(200, { - 'Content-Length': stat.size, - 'Content-Type': type, - }); - fs.createReadStream(filePath).pipe(res); + if (!fileExists(filePath)) return res.status(404).send('Not found'); + const type = getMimeType(filePath, 'audio/mpeg'); + try { + return streamFile(req, res, { filePath, mime: type }); + } catch (error) { + console.error('Stream error (name):', error); + return res.status(500).send('Stream error'); } }); } else { @@ -173,32 +66,14 @@ export function registerAudioRoutes(app) { // Serve embedded cover art from audio files, if present app.get('/cover/:name', async (req, res) => { try { - const name = req.params.name; - const resolved = resolveSafePath(name); - if (!resolved) return res.status(400).send('Invalid path'); - if (!fs.existsSync(resolved)) { - res.status(404).send('Not found'); - return; - } - - const c = coverCache.get(resolved); - if (c) { - res.setHeader('Content-Type', c.mime || 'image/jpeg'); - res.setHeader('Cache-Control', 'no-store'); - return res.status(200).end(c.buf); - } - - const meta = await mmParseFile(resolved, { duration: false }); - const pic = meta.common?.picture?.[0]; - if (!pic?.data?.length) { - return res.status(404).send('No cover'); - } - const mimeType = pic.format || 'image/jpeg'; - const buf = Buffer.from(pic.data); - coverCache.set(resolved, { mime: mimeType, buf }); - res.setHeader('Content-Type', mimeType); + const resolved = resolveSafePath(req.params.name); + if (!resolved) return res.status(400).send('Invalid path'); + if (!fileExists(resolved)) return res.status(404).send('Not found'); + const cover = await getCoverForFile(resolved); + if (!cover) return res.status(404).send('No cover'); + res.setHeader('Content-Type', cover.mime || 'image/jpeg'); res.setHeader('Cache-Control', 'no-store'); - return res.status(200).end(buf); + return res.status(200).end(cover.buf); } catch (error) { console.error('Error reading cover:', error); return res.status(500).send('Error reading cover'); diff --git a/src/server/routes/audio/coverService.js b/src/server/routes/audio/coverService.js new file mode 100644 index 0000000..3e0ae74 --- /dev/null +++ b/src/server/routes/audio/coverService.js @@ -0,0 +1,27 @@ +import { LRUCache } from 'lru-cache'; +import { parseFile as mmParseFile } from 'music-metadata'; + +const COVER_CACHE_MAX_ITEMS = 256; +const COVER_CACHE_MAX_BYTES = 50 * 1024 * 1024; // 50 MB + +const coverCache = new LRUCache({ + max: COVER_CACHE_MAX_ITEMS, + maxSize: COVER_CACHE_MAX_BYTES, + sizeCalculation: (v) => (v?.buf?.length ? v.buf.length : 0), + ttl: 5 * 60 * 1000, + ttlAutopurge: true, +}); + +export async function getCoverForFile(resolvedPath) { + const cached = coverCache.get(resolvedPath); + if (cached) return cached; + + const meta = await mmParseFile(resolvedPath, { duration: false }); + const pic = meta.common?.picture?.[0]; + if (!pic?.data?.length) return null; + const mime = pic.format || 'image/jpeg'; + const buf = Buffer.from(pic.data); + const value = { mime, buf }; + coverCache.set(resolvedPath, value); + return value; +} diff --git a/src/server/routes/audio/pathUtils.js b/src/server/routes/audio/pathUtils.js new file mode 100644 index 0000000..ce59db4 --- /dev/null +++ b/src/server/routes/audio/pathUtils.js @@ -0,0 +1,33 @@ +import fs from 'fs'; +import path from 'path'; +import mime from 'mime'; +import { DATA_DIR as RAW_DATA_DIR } from '../../config.js'; + +// Resolve DATA_DIR once, ensure absolute path and normalized with trailing separator for prefix checks +const DATA_DIR = path.resolve(RAW_DATA_DIR); +const DATA_DIR_WITH_SEP = DATA_DIR.endsWith(path.sep) ? DATA_DIR : DATA_DIR + path.sep; + +export function resolveSafePath(name) { + if (!name || typeof name !== 'string') return null; + // Prevent path traversal and normalize input + const joined = path.join(DATA_DIR, name); + const resolved = path.resolve(joined); + if (resolved === DATA_DIR || resolved.startsWith(DATA_DIR_WITH_SEP)) return resolved; + return null; +} + +export function fileExists(p) { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +export function statFile(p) { + return fs.statSync(p); +} + +export function getMimeType(p, fallback = 'audio/mpeg') { + return mime.getType(p) || fallback; +} diff --git a/src/server/routes/audio/streaming.js b/src/server/routes/audio/streaming.js new file mode 100644 index 0000000..b818681 --- /dev/null +++ b/src/server/routes/audio/streaming.js @@ -0,0 +1,46 @@ +import fs from 'fs'; + +export function setCommonNoCacheHeaders(res) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Accept-Ranges', 'bytes'); +} + +export function headFile(res, { size, mime }) { + setCommonNoCacheHeaders(res); + res.setHeader('Content-Type', mime); + res.setHeader('Content-Length', size); + res.status(200).end(); +} + +export function streamFile(req, res, { filePath, mime }) { + const stat = fs.statSync(filePath); + const size = stat.size; + const range = req.headers.range; + setCommonNoCacheHeaders(res); + if (range) { + const match = /bytes=(\d+)-(\d+)?/.exec(range); + let start = match?.[1] ? parseInt(match[1], 10) : 0; + let end = match?.[2] ? parseInt(match[2], 10) : size - 1; + if (Number.isNaN(start)) start = 0; + if (Number.isNaN(end)) end = size - 1; + start = Math.min(Math.max(0, start), Math.max(0, size - 1)); + end = Math.min(Math.max(start, end), Math.max(0, size - 1)); + if (start > end || start >= size) { + res.setHeader('Content-Range', `bytes */${size}`); + return res.status(416).end(); + } + const chunkSize = end - start + 1; + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Content-Length': chunkSize, + 'Content-Type': mime, + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + } else { + res.writeHead(200, { + 'Content-Length': size, + 'Content-Type': mime, + }); + fs.createReadStream(filePath).pipe(res); + } +} diff --git a/src/server/routes/audio/tokenStore.js b/src/server/routes/audio/tokenStore.js new file mode 100644 index 0000000..4636c54 --- /dev/null +++ b/src/server/routes/audio/tokenStore.js @@ -0,0 +1,31 @@ +import crypto from 'crypto'; +import { LRUCache } from 'lru-cache'; + +const TOKEN_TTL_MS_DEFAULT = 10 * 60 * 1000; // 10 minutes + +// A bounded LRU with TTL per entry. Values: { path, mime, size } +const tokenCache = new LRUCache({ + max: 2048, + ttl: TOKEN_TTL_MS_DEFAULT, + ttlAutopurge: true, + allowStale: false, + updateAgeOnGet: false, + updateAgeOnHas: false, +}); + +export function genToken() { + return crypto.randomBytes(16).toString('hex'); +} + +export function putToken(value, ttlMs = TOKEN_TTL_MS_DEFAULT) { + const token = genToken(); + tokenCache.set(token, value, { ttl: Math.max(1000, ttlMs) }); + return token; +} + +export function getToken(token) { + if (!token) return null; + return tokenCache.get(String(token)); +} + +export { TOKEN_TTL_MS_DEFAULT };