231 lines
9.1 KiB
JavaScript
231 lines
9.1 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 social = require('../services/social');
|
|
const users = require('../services/users');
|
|
|
|
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, isVip = false) {
|
|
if (visibility === 'public') return true;
|
|
if (visibility === 'logged_in') return !!userId;
|
|
if (visibility === 'vip') return !!userId && isVip;
|
|
if (visibility === 'private') return userId != null && ownerId === userId;
|
|
return false;
|
|
}
|
|
|
|
// For members-only items, redirect guests to login rather than 404.
|
|
// VIP items redirect non-VIP logged-in users with a flash message.
|
|
// Private items always 404 (don't reveal they exist).
|
|
function denyAccess(req, res, next, visibility) {
|
|
if ((visibility === 'logged_in' || visibility === 'vip') && !req.session.user) {
|
|
req.session.returnTo = req.originalUrl;
|
|
req.flash('error', 'You must be logged in to access that.');
|
|
return res.redirect('/login');
|
|
}
|
|
if (visibility === 'vip' && req.session.user) {
|
|
req.flash('error', 'VIP access required.');
|
|
return res.redirect('/songs');
|
|
}
|
|
return next();
|
|
}
|
|
|
|
router.get('/', (req, res) => {
|
|
const loggedIn = !!req.session.user;
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
res.render('public/home', {
|
|
title: res.locals.siteName,
|
|
featured: social.enrichPlaylists(playlists.featured(5, loggedIn, isVip), userId),
|
|
recent: social.enrichSongs(songs.recent(8, loggedIn, isVip), userId),
|
|
});
|
|
});
|
|
|
|
router.get('/songs', (req, res) => {
|
|
const loggedIn = !!req.session.user;
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
const page = Math.max(1, Number(req.query.page) || 1);
|
|
const pageSize = 25;
|
|
const q = req.query.q || '';
|
|
const genre = req.query.genre || '';
|
|
const uploadedBy = req.query.uploader || '';
|
|
|
|
const { rows, total } = songs.list({ q, genre, uploadedBy, limit: pageSize, offset: (page - 1) * pageSize, loggedIn, isVip });
|
|
social.enrichSongs(rows, userId);
|
|
|
|
const genres = songs.listGenres();
|
|
const uploaders = songs.listUploaders(loggedIn, isVip);
|
|
|
|
const recentlyLiked = social.getRecentlyLikedSongs(20, loggedIn, isVip).map(songs.toView);
|
|
social.enrichSongs(recentlyLiked, userId);
|
|
|
|
const mostPopularRaw = social.getMostPopular(20, loggedIn, isVip).map(songs.toView);
|
|
social.enrichSongs(mostPopularRaw, userId);
|
|
|
|
const userPlaylists = isVip && userId ? playlists.listByUser(userId) : [];
|
|
|
|
res.render('public/songs', {
|
|
title: 'Songs', songs: rows, q, genre, uploadedBy,
|
|
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
genres, uploaders, recentlyLiked, mostPopular: mostPopularRaw, userPlaylists,
|
|
});
|
|
});
|
|
|
|
router.get('/songs/:slug', (req, res, next) => {
|
|
const song = songs.findBySlug(req.params.slug);
|
|
if (!song) return next();
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
if (!canView(song.visibility, song.uploadedBy, userId, isVip)) {
|
|
return denyAccess(req, res, next, song.visibility);
|
|
}
|
|
if (song.isNsfw && !userId) {
|
|
return denyAccess(req, res, next, 'logged_in');
|
|
}
|
|
social.enrichSongs([song], userId);
|
|
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
|
|
const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user, isVip);
|
|
const og = {
|
|
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
|
|
description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`,
|
|
image: song.coverPath ? `${res.locals.appBaseUrl}/media/${song.coverPath}` : null,
|
|
url: `${res.locals.appBaseUrl}/songs/${song.slug}`,
|
|
};
|
|
res.render('public/song', { title: song.title, song, creator, songPlaylists, og });
|
|
});
|
|
|
|
router.get('/playlists', (req, res) => {
|
|
const loggedIn = !!req.session.user;
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
const list = social.enrichPlaylists(playlists.listVisible(loggedIn, isVip), userId);
|
|
res.render('public/playlists', { title: 'Playlists', playlists: list });
|
|
});
|
|
|
|
router.get('/playlists/:slug', (req, res, next) => {
|
|
const playlist = playlists.findBySlug(req.params.slug);
|
|
if (!playlist) return next();
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
if (!canView(playlist.visibility, playlist.createdBy, userId, isVip)) {
|
|
return denyAccess(req, res, next, playlist.visibility);
|
|
}
|
|
social.enrichPlaylists([playlist], userId);
|
|
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip) && (!t.isNsfw || !!userId));
|
|
social.enrichSongs(tracks, userId);
|
|
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
|
|
const similar = playlists.getSimilar(playlist.id, !!userId, isVip);
|
|
social.enrichPlaylists(similar, userId);
|
|
const og = {
|
|
title: `${res.locals.siteName} Playlist: ${playlist.title}`,
|
|
description: playlist.description || null,
|
|
image: playlist.coverPath ? `${res.locals.appBaseUrl}/media/${playlist.coverPath}` : null,
|
|
url: `${res.locals.appBaseUrl}/playlists/${playlist.slug}`,
|
|
};
|
|
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator, similar, og });
|
|
});
|
|
|
|
router.get('/profiles', (req, res) => {
|
|
const profiles = users.listVerified();
|
|
res.render('public/profiles', { title: 'Profiles', profiles });
|
|
});
|
|
|
|
router.get('/profiles/:slug', (req, res, next) => {
|
|
const profile = users.findBySlug(req.params.slug);
|
|
if (!profile) return next();
|
|
const loggedIn = !!req.session.user;
|
|
const userId = req.session.user?.id || null;
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
|
|
const userPlaylists = social.enrichPlaylists(playlists.listPublicByUser(profile.id, loggedIn, isVip), userId);
|
|
const userSongs = social.enrichSongs(songs.listPublicByUser(profile.id, loggedIn, isVip), userId);
|
|
|
|
const likedSongRows = social.getLikedSongs(profile.id, loggedIn, isVip);
|
|
// Map raw DB rows to song view objects
|
|
const likedSongs = social.enrichSongs(
|
|
likedSongRows.map(row => songs.findById(row.id)).filter(Boolean),
|
|
userId
|
|
);
|
|
|
|
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn, isVip);
|
|
const likedPlaylists = social.enrichPlaylists(
|
|
likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean),
|
|
userId
|
|
);
|
|
|
|
res.render('public/profile', {
|
|
title: profile.displayName + "'s Profile",
|
|
profile,
|
|
userPlaylists,
|
|
userSongs,
|
|
likedSongs,
|
|
likedPlaylists,
|
|
});
|
|
});
|
|
|
|
// 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();
|
|
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
|
|
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id, isVip)) {
|
|
return denyAccess(req, res, next, song.visibility);
|
|
}
|
|
if (song.isNsfw && !req.session.user) {
|
|
return res.status(403).end();
|
|
}
|
|
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;
|