Files
muzick/backend/src/routes/stream.routes.ts
T

141 lines
4.4 KiB
TypeScript

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