feat: refactor audio token management and implement cover art retrieval
All checks were successful
Build and Push Docker Image / docker (push) Successful in 9s

This commit is contained in:
2025-09-04 22:52:52 +02:00
parent 10a992c048
commit d89647cd5e
6 changed files with 194 additions and 184 deletions

4
.vscode/tasks.json vendored
View File

@@ -6,9 +6,7 @@
"type": "shell",
"command": "npm run start",
"isBackground": false,
"problemMatcher": [
"$eslint-stylish"
],
"problemMatcher": ["$eslint-stylish"],
"group": "build"
},
{

View File

@@ -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);
const resolved = resolveSafePath(req.params.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');
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(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);
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');

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 };