Bri-Tunes/src/routes/public.js

143 lines
4.7 KiB
JavaScript

const path = require('path');
const fs = require('fs');
const express = require('express');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const router = express.Router();
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
// Fallback mime lookup without a new dep — basic extension map.
const MIME = {
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
'.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.opus': 'audio/ogg',
'.flac': 'audio/flac', '.wav': 'audio/wav', '.webm': 'audio/webm',
};
// Can the current viewer access an item with this visibility/owner?
function canView(visibility, ownerId, userId) {
if (visibility === 'public') return true;
if (visibility === 'logged_in') return !!userId;
if (visibility === 'private') return userId != null && ownerId === userId;
return false;
}
// For members-only items, redirect guests to login rather than 404.
// Private items always 404 (don't reveal they exist).
function denyAccess(req, res, next, visibility) {
if (visibility === 'logged_in' && !req.session.user) {
req.session.returnTo = req.originalUrl;
req.flash('error', 'You must be logged in to access that.');
return res.redirect('/login');
}
return next();
}
router.get('/', (req, res) => {
const loggedIn = !!req.session.user;
res.render('public/home', {
title: 'Tunes',
featured: playlists.featured(5, loggedIn),
recent: songs.recent(8, loggedIn),
});
});
router.get('/songs', (req, res) => {
const loggedIn = !!req.session.user;
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25;
const { rows, total } = songs.list({
q: req.query.q || '',
limit: pageSize,
offset: (page - 1) * pageSize,
loggedIn,
});
res.render('public/songs', {
title: 'Songs',
songs: rows,
q: req.query.q || '',
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
});
});
router.get('/songs/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id));
if (!song) return next();
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
return denyAccess(req, res, next, song.visibility);
}
res.render('public/song', { title: song.title, song });
});
router.get('/playlists', (req, res) => {
const loggedIn = !!req.session.user;
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) });
});
router.get('/playlists/:id', (req, res, next) => {
const id = Number(req.params.id);
const playlist = playlists.findById(id);
if (!playlist) return next();
const userId = req.session.user?.id;
// Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(id);
if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
return denyAccess(req, res, next, effectiveVisibility);
}
const tracks = playlists.getTracks(id).filter(t => canView(t.visibility, t.uploadedBy, userId));
res.render('public/playlist', { title: playlist.title, playlist, tracks });
});
// Audio streaming with HTTP Range support.
router.get('/stream/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id));
if (!song) return next();
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
return denyAccess(req, res, next, song.visibility);
}
const absPath = path.join(mediaDir, song.audioPath);
fs.stat(absPath, (err, stat) => {
if (err) return next();
const size = stat.size;
const ext = path.extname(absPath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
});
return fs.createReadStream(absPath).pipe(res);
}
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
return res.end();
}
let start = match[1] === '' ? 0 : parseInt(match[1], 10);
let end = match[2] === '' ? size - 1 : parseInt(match[2], 10);
if (isNaN(start) || isNaN(end) || start > end || end >= size) {
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
return res.end();
}
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': contentType,
'Cache-Control': 'no-cache',
});
fs.createReadStream(absPath, { start, end }).pipe(res);
});
});
module.exports = router;