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;