initial state: muzick music player + recommendation engine
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { DbService } from '../services/db.service.js';
|
||||
|
||||
// Music library root on disk. The worker scanner writes absolute file paths into
|
||||
// tracks.path rooted here; the backend container must mount the same path so they
|
||||
// resolve. Path-traversal guard below verifies the resolved file stays inside.
|
||||
const MUSIC_DIR = path.resolve(process.env.MUSIC_DIR || '/mnt/hdd1/media/Music');
|
||||
|
||||
const CONTENT_TYPES: Record<string, string> = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.flac': 'audio/flac',
|
||||
'.m4a': 'audio/mp4',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
};
|
||||
|
||||
function contentTypeFor(filePath: string): string {
|
||||
return CONTENT_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// True when resolvedPath is the music root itself or a descendant of it.
|
||||
function isInsideRoot(resolvedPath: string, root: string): boolean {
|
||||
return resolvedPath === root || resolvedPath.startsWith(root + path.sep);
|
||||
}
|
||||
|
||||
export default async function streamRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: { dbService: DbService }
|
||||
) {
|
||||
const { dbService } = options;
|
||||
|
||||
fastify.get('/tracks/:id/stream', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const track = await dbService.getTrackById(id);
|
||||
if (!track) {
|
||||
return reply.code(404).send({ error: 'Track not found' });
|
||||
}
|
||||
|
||||
// SECURITY: resolve the path and confirm it stays within MUSIC_DIR. This
|
||||
// rejects relative paths, symlink-style escapes and any path outside root.
|
||||
const resolvedPath = path.resolve(track.path);
|
||||
if (!isInsideRoot(resolvedPath, MUSIC_DIR)) {
|
||||
return reply.code(403).send({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
let fileSize: number;
|
||||
try {
|
||||
const stats = await stat(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return reply.code(404).send({ error: 'File not found' });
|
||||
}
|
||||
fileSize = stats.size;
|
||||
} catch (err: any) {
|
||||
if (err && err.code === 'ENOENT') {
|
||||
// File missing on disk; the integrity worker would flag this track MISSING.
|
||||
return reply.code(404).send({ error: 'File not found on disk' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const contentType = contentTypeFor(resolvedPath);
|
||||
const rangeHeader = request.headers.range;
|
||||
|
||||
// No Range header: stream the whole file with a 200.
|
||||
if (!rangeHeader) {
|
||||
reply
|
||||
.code(200)
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Length', fileSize)
|
||||
.header('Accept-Ranges', 'bytes');
|
||||
const stream = createReadStream(resolvedPath);
|
||||
stream.on('error', (err) => {
|
||||
request.log.error(err);
|
||||
reply.raw.destroy(err);
|
||||
});
|
||||
return reply.send(stream);
|
||||
}
|
||||
|
||||
// Parse "bytes=start-end". Either bound may be omitted.
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
||||
if (!match || (match[1] === '' && match[2] === '')) {
|
||||
return reply
|
||||
.code(416)
|
||||
.header('Content-Range', `bytes */${fileSize}`)
|
||||
.send({ error: 'Invalid range' });
|
||||
}
|
||||
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (match[1] === '') {
|
||||
// suffix range: last N bytes
|
||||
const suffixLength = parseInt(match[2], 10);
|
||||
if (suffixLength <= 0) {
|
||||
return reply
|
||||
.code(416)
|
||||
.header('Content-Range', `bytes */${fileSize}`)
|
||||
.send({ error: 'Unsatisfiable range' });
|
||||
}
|
||||
start = Math.max(fileSize - suffixLength, 0);
|
||||
end = fileSize - 1;
|
||||
} else {
|
||||
start = parseInt(match[1], 10);
|
||||
end = match[2] === '' ? fileSize - 1 : parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
if (end > fileSize - 1) end = fileSize - 1;
|
||||
|
||||
if (
|
||||
Number.isNaN(start) ||
|
||||
Number.isNaN(end) ||
|
||||
start > end ||
|
||||
start < 0 ||
|
||||
start >= fileSize
|
||||
) {
|
||||
return reply
|
||||
.code(416)
|
||||
.header('Content-Range', `bytes */${fileSize}`)
|
||||
.send({ error: 'Unsatisfiable range' });
|
||||
}
|
||||
|
||||
const chunkSize = end - start + 1;
|
||||
reply
|
||||
.code(206)
|
||||
.header('Content-Type', contentType)
|
||||
.header('Content-Range', `bytes ${start}-${end}/${fileSize}`)
|
||||
.header('Accept-Ranges', 'bytes')
|
||||
.header('Content-Length', chunkSize);
|
||||
|
||||
const stream = createReadStream(resolvedPath, { start, end });
|
||||
stream.on('error', (err) => {
|
||||
request.log.error(err);
|
||||
reply.raw.destroy(err);
|
||||
});
|
||||
return reply.send(stream);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user