Add VIP visibility tier for songs and playlists

- Introduce `vip` visibility level with CSS badge styling
- Update admin routes to restrict VIP setting to admins and VIP users
- Extend visibility logic in public routes (`canView`, `denyAccess`)
- Add VIP-specific SQL queries in playlists, social, and songs services
- Update EJS templates to show VIP badges and radio options for authorized users
This commit is contained in:
Brian Fertig 2026-04-14 19:12:02 -06:00
parent 5fd5ee6351
commit 771165f042
15 changed files with 176 additions and 57 deletions

View File

@ -392,6 +392,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
} }
.badge-public { color: var(--neon-cyan); border: 1px solid var(--neon-cyan); box-shadow: 0 0 5px rgba(0,229,255,0.35); } .badge-public { color: var(--neon-cyan); border: 1px solid var(--neon-cyan); box-shadow: 0 0 5px rgba(0,229,255,0.35); }
.badge-logged-in { color: var(--neon-amber); border: 1px solid var(--neon-amber); box-shadow: 0 0 5px rgba(255,179,71,0.4); } .badge-logged-in { color: var(--neon-amber); border: 1px solid var(--neon-amber); box-shadow: 0 0 5px rgba(255,179,71,0.4); }
.badge-vip { color: #39ff14; border: 1px solid #39ff14; box-shadow: 0 0 5px rgba(57,255,20,0.5); background: rgba(57,255,20,0.08); }
.badge-private { color: var(--neon-magenta); border: 1px solid var(--neon-magenta); box-shadow: 0 0 5px rgba(255,43,214,0.4); } .badge-private { color: var(--neon-magenta); border: 1px solid var(--neon-magenta); box-shadow: 0 0 5px rgba(255,43,214,0.4); }
/* ---------- Visibility radio fieldset ---------- */ /* ---------- Visibility radio fieldset ---------- */

View File

@ -39,8 +39,8 @@ const upload = multer({
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]); const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]); const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
const VISIBILITY_VALUES = ['public', 'logged_in', 'private']; const VISIBILITY_VALUES = ['public', 'logged_in', 'vip', 'private'];
const VISIBILITY_RANK = { public: 0, logged_in: 1, private: 2 }; const VISIBILITY_RANK = { public: 0, logged_in: 1, vip: 2, private: 3 };
// For uploads, all text fields are optional — we'll fill blanks from ID3 tags. // For uploads, all text fields are optional — we'll fill blanks from ID3 tags.
// The edit form enforces title/artist via a stricter schema below. // The edit form enforces title/artist via a stricter schema below.
@ -258,6 +258,8 @@ module.exports = function adminRoutes(csrfProtection) {
} }
} }
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
songs.create({ songs.create({
title: merged.title, title: merged.title,
artist: merged.artist, artist: merged.artist,
@ -267,7 +269,7 @@ module.exports = function adminRoutes(csrfProtection) {
durationSeconds: tags.duration, durationSeconds: tags.duration,
audioPath: relMedia(audioFile, 'audio'), audioPath: relMedia(audioFile, 'audio'),
coverPath, coverPath,
visibility: parsed.data.visibility, visibility,
uploadedBy: req.session.user.id, uploadedBy: req.session.user.id,
}); });
req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.'); req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.');
@ -299,13 +301,15 @@ module.exports = function adminRoutes(csrfProtection) {
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() }); return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() });
} }
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
songs.update(id, { songs.update(id, {
title: parsed.data.title, title: parsed.data.title,
artist: parsed.data.artist, artist: parsed.data.artist,
album: parsed.data.album || null, album: parsed.data.album || null,
genre: parsed.data.genre || null, genre: parsed.data.genre || null,
year: parsed.data.year ?? null, year: parsed.data.year ?? null,
visibility: parsed.data.visibility, visibility,
}); });
const coverFile = req.files?.cover?.[0]; const coverFile = req.files?.cover?.[0];
if (coverFile) { if (coverFile) {
@ -369,11 +373,13 @@ module.exports = function adminRoutes(csrfProtection) {
console.warn('[admin] failed to process playlist cover:', err.message); console.warn('[admin] failed to process playlist cover:', err.message);
} }
} }
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
const created = playlists.create({ const created = playlists.create({
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description || null, description: parsed.data.description || null,
coverPath, coverPath,
visibility: parsed.data.visibility, visibility,
createdBy: req.session.user.id, createdBy: req.session.user.id,
}); });
const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean); const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean);
@ -426,10 +432,12 @@ module.exports = function adminRoutes(csrfProtection) {
errors, values: req.body, errors, values: req.body,
}); });
} }
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility;
playlists.update(id, { playlists.update(id, {
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description || null, description: parsed.data.description || null,
visibility: parsed.data.visibility, visibility,
}); });
const coverFile = req.files?.cover?.[0]; const coverFile = req.files?.cover?.[0];
if (coverFile) { if (coverFile) {

View File

@ -18,50 +18,58 @@ const MIME = {
}; };
// Can the current viewer access an item with this visibility/owner? // Can the current viewer access an item with this visibility/owner?
function canView(visibility, ownerId, userId) { function canView(visibility, ownerId, userId, isVip = false) {
if (visibility === 'public') return true; if (visibility === 'public') return true;
if (visibility === 'logged_in') return !!userId; if (visibility === 'logged_in') return !!userId;
if (visibility === 'vip') return !!userId && isVip;
if (visibility === 'private') return userId != null && ownerId === userId; if (visibility === 'private') return userId != null && ownerId === userId;
return false; return false;
} }
// For members-only items, redirect guests to login rather than 404. // 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). // Private items always 404 (don't reveal they exist).
function denyAccess(req, res, next, visibility) { function denyAccess(req, res, next, visibility) {
if (visibility === 'logged_in' && !req.session.user) { if ((visibility === 'logged_in' || visibility === 'vip') && !req.session.user) {
req.session.returnTo = req.originalUrl; req.session.returnTo = req.originalUrl;
req.flash('error', 'You must be logged in to access that.'); req.flash('error', 'You must be logged in to access that.');
return res.redirect('/login'); return res.redirect('/login');
} }
if (visibility === 'vip' && req.session.user) {
req.flash('error', 'VIP access required.');
return res.redirect('/songs');
}
return next(); return next();
} }
router.get('/', (req, res) => { router.get('/', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null; const userId = req.session.user?.id || null;
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
res.render('public/home', { res.render('public/home', {
title: res.locals.siteName, title: res.locals.siteName,
featured: social.enrichPlaylists(playlists.featured(5, loggedIn), userId), featured: social.enrichPlaylists(playlists.featured(5, loggedIn, isVip), userId),
recent: social.enrichSongs(songs.recent(8, loggedIn), userId), recent: social.enrichSongs(songs.recent(8, loggedIn, isVip), userId),
}); });
}); });
router.get('/songs', (req, res) => { router.get('/songs', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null; 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 page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25; const pageSize = 25;
const q = req.query.q || ''; const q = req.query.q || '';
const genre = req.query.genre || ''; const genre = req.query.genre || '';
const uploadedBy = req.query.uploader || ''; const uploadedBy = req.query.uploader || '';
const { rows, total } = songs.list({ q, genre, uploadedBy, limit: pageSize, offset: (page - 1) * pageSize, loggedIn }); const { rows, total } = songs.list({ q, genre, uploadedBy, limit: pageSize, offset: (page - 1) * pageSize, loggedIn, isVip });
social.enrichSongs(rows, userId); social.enrichSongs(rows, userId);
const genres = songs.listGenres(); const genres = songs.listGenres();
const uploaders = songs.listUploaders(loggedIn); const uploaders = songs.listUploaders(loggedIn, isVip);
const recentlyLiked = social.getRecentlyLikedSongs(5, loggedIn).map(songs.toView); const recentlyLiked = social.getRecentlyLikedSongs(5, loggedIn, isVip).map(songs.toView);
social.enrichSongs(recentlyLiked, userId); social.enrichSongs(recentlyLiked, userId);
res.render('public/songs', { res.render('public/songs', {
@ -74,13 +82,14 @@ router.get('/songs', (req, res) => {
router.get('/songs/:slug', (req, res, next) => { router.get('/songs/:slug', (req, res, next) => {
const song = songs.findBySlug(req.params.slug); const song = songs.findBySlug(req.params.slug);
if (!song) return next(); if (!song) return next();
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) { 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); return denyAccess(req, res, next, song.visibility);
} }
const userId = req.session.user?.id || null;
social.enrichSongs([song], userId); social.enrichSongs([song], userId);
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null; const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user); const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user, isVip);
const og = { const og = {
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`, title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`, description: `Check out ${song.artist}'s hit ${song.title}, available on ${res.locals.siteName}`,
@ -93,7 +102,8 @@ router.get('/songs/:slug', (req, res, next) => {
router.get('/playlists', (req, res) => { router.get('/playlists', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null; const userId = req.session.user?.id || null;
const list = social.enrichPlaylists(playlists.listVisible(loggedIn), userId); 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 }); res.render('public/playlists', { title: 'Playlists', playlists: list });
}); });
@ -101,13 +111,14 @@ router.get('/playlists/:slug', (req, res, next) => {
const playlist = playlists.findBySlug(req.params.slug); const playlist = playlists.findBySlug(req.params.slug);
if (!playlist) return next(); if (!playlist) return next();
const userId = req.session.user?.id || null; const userId = req.session.user?.id || null;
const isVip = !!(req.session.user?.isVip || req.session.user?.role === 'admin');
// Use effective visibility (most restrictive of playlist + all its tracks). // Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id); const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id);
if (!canView(effectiveVisibility, playlist.createdBy, userId)) { if (!canView(effectiveVisibility, playlist.createdBy, userId, isVip)) {
return denyAccess(req, res, next, effectiveVisibility); return denyAccess(req, res, next, effectiveVisibility);
} }
social.enrichPlaylists([playlist], userId); social.enrichPlaylists([playlist], userId);
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId)); const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip));
social.enrichSongs(tracks, userId); social.enrichSongs(tracks, userId);
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null; const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
const og = { const og = {
@ -129,18 +140,19 @@ router.get('/profiles/:slug', (req, res, next) => {
if (!profile) return next(); if (!profile) return next();
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null; 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), userId); const userPlaylists = social.enrichPlaylists(playlists.listPublicByUser(profile.id, loggedIn, isVip), userId);
const userSongs = social.enrichSongs(songs.listPublicByUser(profile.id, loggedIn), userId); const userSongs = social.enrichSongs(songs.listPublicByUser(profile.id, loggedIn, isVip), userId);
const likedSongRows = social.getLikedSongs(profile.id, loggedIn); const likedSongRows = social.getLikedSongs(profile.id, loggedIn, isVip);
// Map raw DB rows to song view objects // Map raw DB rows to song view objects
const likedSongs = social.enrichSongs( const likedSongs = social.enrichSongs(
likedSongRows.map(row => songs.findById(row.id)).filter(Boolean), likedSongRows.map(row => songs.findById(row.id)).filter(Boolean),
userId userId
); );
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn); const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn, isVip);
const likedPlaylists = social.enrichPlaylists( const likedPlaylists = social.enrichPlaylists(
likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean), likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean),
userId userId
@ -160,7 +172,8 @@ router.get('/profiles/:slug', (req, res, next) => {
router.get('/stream/:id', (req, res, next) => { router.get('/stream/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id)); const song = songs.findById(Number(req.params.id));
if (!song) return next(); if (!song) return next();
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) { 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); return denyAccess(req, res, next, song.visibility);
} }
const absPath = path.join(mediaDir, song.audioPath); const absPath = path.join(mediaDir, song.audioPath);

View File

@ -14,21 +14,32 @@ const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?'); const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
// For list queries, effective visibility = max(playlist.visibility, most restrictive track). // For list queries, effective visibility = max(playlist.visibility, most restrictive track).
// Guests only see playlists where nothing in them is logged_in or private. // Guests only see playlists where nothing in them is logged_in, vip, or private.
const listGuestStmt = db.prepare(` const listGuestStmt = db.prepare(`
SELECT p.* FROM playlists p SELECT p.* FROM playlists p
WHERE p.visibility = 'public' WHERE p.visibility = 'public'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private') WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
) )
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`); `);
// Logged-in users see public/logged_in playlists where nothing in them is private. // Logged-in non-VIP users see public/logged_in playlists where nothing in them is vip or private.
const listUserStmt = db.prepare(` const listUserStmt = db.prepare(`
SELECT p.* FROM playlists p SELECT p.* FROM playlists p
WHERE p.visibility IN ('public', 'logged_in') WHERE p.visibility IN ('public', 'logged_in')
AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
)
ORDER BY p.created_at DESC
`);
// VIP users: public + logged_in + vip playlists (no private tracks)
const listVipStmt = db.prepare(`
SELECT p.* FROM playlists p
WHERE p.visibility IN ('public', 'logged_in', 'vip')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
@ -36,6 +47,33 @@ const listUserStmt = db.prepare(`
) )
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`); `);
const featuredVipStmt = db.prepare(`
SELECT p.* FROM playlists p
WHERE p.visibility IN ('public', 'logged_in', 'vip')
AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
)
ORDER BY RANDOM() LIMIT ?
`);
const listPublicByUserVipStmt = db.prepare(`
SELECT p.* FROM playlists p
WHERE p.created_by = ? AND p.visibility IN ('public', 'logged_in', 'vip')
AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
)
ORDER BY p.created_at DESC
`);
const forSongVipStmt = db.prepare(`
SELECT p.* FROM playlists p
JOIN playlist_songs ps ON ps.playlist_id = p.id
WHERE ps.song_id = ? AND p.visibility IN ('public', 'logged_in', 'vip')
ORDER BY p.created_at DESC
`);
const listAllStmt = db.prepare('SELECT * FROM playlists ORDER BY created_at DESC'); const listAllStmt = db.prepare('SELECT * FROM playlists ORDER BY created_at DESC');
const listByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ? ORDER BY created_at DESC'); const listByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ? ORDER BY created_at DESC');
const featuredGuestStmt = db.prepare(` const featuredGuestStmt = db.prepare(`
@ -44,7 +82,7 @@ const featuredGuestStmt = db.prepare(`
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private') WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
) )
ORDER BY RANDOM() LIMIT ? ORDER BY RANDOM() LIMIT ?
`); `);
@ -54,7 +92,7 @@ const featuredUserStmt = db.prepare(`
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility = 'private' WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
) )
ORDER BY RANDOM() LIMIT ? ORDER BY RANDOM() LIMIT ?
`); `);
@ -66,6 +104,9 @@ const effectiveVisibilityStmt = db.prepare(`
WHEN p.visibility = 'private' WHEN p.visibility = 'private'
OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'private') OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'private')
THEN 'private' THEN 'private'
WHEN p.visibility = 'vip'
OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'vip')
THEN 'vip'
WHEN p.visibility = 'logged_in' WHEN p.visibility = 'logged_in'
OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'logged_in') OR EXISTS (SELECT 1 FROM playlist_songs ps JOIN songs s ON s.id = ps.song_id WHERE ps.playlist_id = p.id AND s.visibility = 'logged_in')
THEN 'logged_in' THEN 'logged_in'
@ -198,8 +239,8 @@ function getEffectiveVisibility(id) {
return row ? row.effective_visibility : null; return row ? row.effective_visibility : null;
} }
function listVisible(loggedIn = false) { function listVisible(loggedIn = false, isVip = false) {
return (loggedIn ? listUserStmt : listGuestStmt).all().map(toView); return (isVip ? listVipStmt : loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
} }
function listAll() { function listAll() {
@ -210,8 +251,8 @@ function listByUser(userId) {
return listByUserStmt.all(userId).map(toView); return listByUserStmt.all(userId).map(toView);
} }
function featured(n = 6, loggedIn = false) { function featured(n = 6, loggedIn = false, isVip = false) {
return (loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView); return (isVip ? featuredVipStmt : loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView);
} }
function getTracks(playlistId) { function getTracks(playlistId) {
@ -245,7 +286,7 @@ const listPublicByUserGuestStmt = db.prepare(`
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private') WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
) )
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`); `);
@ -255,13 +296,13 @@ const listPublicByUserLoggedStmt = db.prepare(`
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility = 'private' WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
) )
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`); `);
function listPublicByUser(userId, loggedIn = false) { function listPublicByUser(userId, loggedIn = false, isVip = false) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView); return (isVip ? listPublicByUserVipStmt : loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
} }
// Playlists that contain a given song and are visible to the viewer. // Playlists that contain a given song and are visible to the viewer.
@ -278,8 +319,8 @@ const forSongUserStmt = db.prepare(`
ORDER BY p.created_at DESC ORDER BY p.created_at DESC
`); `);
function getPlaylistsForSong(songId, loggedIn = false) { function getPlaylistsForSong(songId, loggedIn = false, isVip = false) {
return (loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView); return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
} }
module.exports = { module.exports = {

View File

@ -114,6 +114,12 @@ const likedSongsUserStmt = db.prepare(`
WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in') WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in')
ORDER BY sl.created_at DESC ORDER BY sl.created_at DESC
`); `);
const likedSongsVipStmt = db.prepare(`
SELECT s.* FROM song_likes sl
JOIN songs s ON s.id = sl.song_id
WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in', 'vip')
ORDER BY sl.created_at DESC
`);
const likedPlaylistsGuestStmt = db.prepare(` const likedPlaylistsGuestStmt = db.prepare(`
SELECT p.* FROM playlist_likes pl SELECT p.* FROM playlist_likes pl
JOIN playlists p ON p.id = pl.playlist_id JOIN playlists p ON p.id = pl.playlist_id
@ -121,7 +127,7 @@ const likedPlaylistsGuestStmt = db.prepare(`
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private') WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
) )
ORDER BY pl.created_at DESC ORDER BY pl.created_at DESC
`); `);
@ -129,6 +135,17 @@ const likedPlaylistsUserStmt = db.prepare(`
SELECT p.* FROM playlist_likes pl SELECT p.* FROM playlist_likes pl
JOIN playlists p ON p.id = pl.playlist_id JOIN playlists p ON p.id = pl.playlist_id
WHERE pl.user_id = ? AND p.visibility IN ('public', 'logged_in') WHERE pl.user_id = ? AND p.visibility IN ('public', 'logged_in')
AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
)
ORDER BY pl.created_at DESC
`);
const likedPlaylistsVipStmt = db.prepare(`
SELECT p.* FROM playlist_likes pl
JOIN playlists p ON p.id = pl.playlist_id
WHERE pl.user_id = ? AND p.visibility IN ('public', 'logged_in', 'vip')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM playlist_songs ps SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
@ -137,12 +154,12 @@ const likedPlaylistsUserStmt = db.prepare(`
ORDER BY pl.created_at DESC ORDER BY pl.created_at DESC
`); `);
function getLikedSongs(userId, loggedIn = false) { function getLikedSongs(userId, loggedIn = false, isVip = false) {
return (loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId); return (isVip ? likedSongsVipStmt : loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId);
} }
function getLikedPlaylists(userId, loggedIn = false) { function getLikedPlaylists(userId, loggedIn = false, isVip = false) {
return (loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId); return (isVip ? likedPlaylistsVipStmt : loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId);
} }
// Site-wide recently liked songs (distinct songs, ordered by most recent like from any user). // Site-wide recently liked songs (distinct songs, ordered by most recent like from any user).
@ -160,9 +177,16 @@ const recentlyLikedUserStmt = db.prepare(`
ORDER BY (SELECT MAX(sl2.created_at) FROM song_likes sl2 WHERE sl2.song_id = s.id) DESC ORDER BY (SELECT MAX(sl2.created_at) FROM song_likes sl2 WHERE sl2.song_id = s.id) DESC
LIMIT ? LIMIT ?
`); `);
const recentlyLikedVipStmt = db.prepare(`
SELECT s.* FROM songs s
WHERE s.visibility IN ('public', 'logged_in', 'vip')
AND EXISTS (SELECT 1 FROM song_likes sl WHERE sl.song_id = s.id)
ORDER BY (SELECT MAX(sl2.created_at) FROM song_likes sl2 WHERE sl2.song_id = s.id) DESC
LIMIT ?
`);
function getRecentlyLikedSongs(n = 5, loggedIn = false) { function getRecentlyLikedSongs(n = 5, loggedIn = false, isVip = false) {
return (loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n); return (isVip ? recentlyLikedVipStmt : loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
} }
module.exports = { module.exports = {

View File

@ -24,12 +24,16 @@ const countUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibili
const searchUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q) ORDER BY created_at DESC LIMIT @limit OFFSET @offset`); const searchUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q) ORDER BY created_at DESC LIMIT @limit OFFSET @offset`);
const countSearchUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)`); const countSearchUserStmt = db.prepare(`SELECT COUNT(*) AS c FROM songs WHERE visibility IN ('public', 'logged_in') AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)`);
// VIP user: public + logged_in + vip
const recentVipStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in', 'vip') ORDER BY created_at DESC LIMIT ?`);
const listPublicByUserVipStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility IN ('public', 'logged_in', 'vip') ORDER BY created_at DESC`);
const listAllStmt = db.prepare('SELECT * FROM songs ORDER BY created_at DESC'); const listAllStmt = db.prepare('SELECT * FROM songs ORDER BY created_at DESC');
const listByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ? ORDER BY created_at DESC'); const listByUserStmt = db.prepare('SELECT * FROM songs WHERE uploaded_by = ? ORDER BY created_at DESC');
// For the "add song to playlist" dropdown: public + logged_in + the user's own private songs. // For the "add song to playlist" dropdown: public + logged_in + the user's own private songs.
const listForManagementStmt = db.prepare(` const listForManagementStmt = db.prepare(`
SELECT * FROM songs SELECT * FROM songs
WHERE visibility IN ('public', 'logged_in') OR uploaded_by = ? WHERE visibility IN ('public', 'logged_in', 'vip') OR uploaded_by = ?
ORDER BY title ASC ORDER BY title ASC
`); `);
const listGenresStmt = db.prepare(`SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' ORDER BY genre ASC`); const listGenresStmt = db.prepare(`SELECT DISTINCT genre FROM songs WHERE genre IS NOT NULL AND genre != '' ORDER BY genre ASC`);
@ -146,11 +150,14 @@ function findById(id) {
} }
// loggedIn=true means the viewer is authenticated (sees public + logged_in). // loggedIn=true means the viewer is authenticated (sees public + logged_in).
// isVip=true adds vip visibility to the visible set.
// Supports optional genre and uploadedBy filters for faceted browsing. // Supports optional genre and uploadedBy filters for faceted browsing.
function list({ q, genre, uploadedBy, limit = 25, offset = 0, loggedIn = false }) { function list({ q, genre, uploadedBy, limit = 25, offset = 0, loggedIn = false, isVip = false }) {
const vis = loggedIn const vis = isVip
? `visibility IN ('public', 'logged_in')` ? `visibility IN ('public', 'logged_in', 'vip')`
: `visibility = 'public'`; : loggedIn
? `visibility IN ('public', 'logged_in')`
: `visibility = 'public'`;
const conds = [vis]; const conds = [vis];
const params = { limit, offset }; const params = { limit, offset };
@ -185,23 +192,25 @@ function listForManagement(userId) {
return listForManagementStmt.all(userId).map(toView); return listForManagementStmt.all(userId).map(toView);
} }
function recent(n = 8, loggedIn = false) { function recent(n = 8, loggedIn = false, isVip = false) {
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView); return (isVip ? recentVipStmt : loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
} }
const listPublicByUserGuestStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility = 'public' ORDER BY created_at DESC`); const listPublicByUserGuestStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility = 'public' ORDER BY created_at DESC`);
const listPublicByUserLoggedStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility IN ('public', 'logged_in') ORDER BY created_at DESC`); const listPublicByUserLoggedStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility IN ('public', 'logged_in') ORDER BY created_at DESC`);
function listPublicByUser(userId, loggedIn = false) { function listPublicByUser(userId, loggedIn = false, isVip = false) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView); return (isVip ? listPublicByUserVipStmt : loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
} }
function listGenres() { function listGenres() {
return listGenresStmt.all().map(r => r.genre); return listGenresStmt.all().map(r => r.genre);
} }
function listUploaders(loggedIn = false) { function listUploaders(loggedIn = false, isVip = false) {
const vis = loggedIn ? `IN ('public', 'logged_in')` : `= 'public'`; const vis = isVip
? `IN ('public', 'logged_in', 'vip')`
: loggedIn ? `IN ('public', 'logged_in')` : `= 'public'`;
return db.prepare(` return db.prepare(`
SELECT DISTINCT u.id, u.display_name SELECT DISTINCT u.id, u.display_name
FROM songs s JOIN users u ON u.id = s.uploaded_by FROM songs s JOIN users u ON u.id = s.uploaded_by

View File

@ -27,6 +27,12 @@
<input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>> <input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>>
Logged in only — members only Logged in only — members only
</label> </label>
<% if (user && (user.isVip || user.role === 'admin')) { %>
<label class="checkbox">
<input type="radio" name="visibility" value="vip" <%= (values.visibility === 'vip') ? 'checked' : '' %>>
VIP only — visible to VIP members
</label>
<% } %>
<label class="checkbox"> <label class="checkbox">
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>> <input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
Private — only you Private — only you

View File

@ -20,6 +20,8 @@
<td> <td>
<% if (p.visibility === 'logged_in') { %> <% if (p.visibility === 'logged_in') { %>
<span class="badge badge-logged-in">Members</span> <span class="badge badge-logged-in">Members</span>
<% } else if (p.visibility === 'vip') { %>
<span class="badge badge-vip">VIP</span>
<% } else if (p.visibility === 'private') { %> <% } else if (p.visibility === 'private') { %>
<span class="badge badge-private">Private</span> <span class="badge badge-private">Private</span>
<% } else { %> <% } else { %>

View File

@ -94,6 +94,12 @@
<input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>> <input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>>
Logged in only — members only Logged in only — members only
</label> </label>
<% if (user && (user.isVip || user.role === 'admin')) { %>
<label class="checkbox">
<input type="radio" name="visibility" value="vip" <%= (values.visibility === 'vip') ? 'checked' : '' %>>
VIP only — visible to VIP members
</label>
<% } %>
<label class="checkbox"> <label class="checkbox">
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>> <input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
Private — only you Private — only you

View File

@ -50,6 +50,8 @@
<td> <td>
<% if (s.visibility === 'logged_in') { %> <% if (s.visibility === 'logged_in') { %>
<span class="badge badge-logged-in">Members</span> <span class="badge badge-logged-in">Members</span>
<% } else if (s.visibility === 'vip') { %>
<span class="badge badge-vip">VIP</span>
<% } else if (s.visibility === 'private') { %> <% } else if (s.visibility === 'private') { %>
<span class="badge badge-private">Private</span> <span class="badge badge-private">Private</span>
<% } else { %> <% } else { %>

View File

@ -9,6 +9,7 @@
<div class="song-main"> <div class="song-main">
<a class="song-title" href="/songs/<%= s.slug %>"><%= s.title %></a> <a class="song-title" href="/songs/<%= s.slug %>"><%= s.title %></a>
<% if (s.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %> <% if (s.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (s.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
<% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
<div class="song-subtitle"> <div class="song-subtitle">
<span class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></span> <span class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></span>

View File

@ -19,6 +19,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-title"><%= p.title %> <div class="card-title"><%= p.title %>
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %> <% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (p.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>

View File

@ -13,6 +13,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-title"><%= p.title %> <div class="card-title"><%= p.title %>
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %> <% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (p.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
<% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (p.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>

View File

@ -29,6 +29,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-title"><%= p.title %> <div class="card-title"><%= p.title %>
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %> <% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (p.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<div class="card-footer"> <div class="card-footer">
@ -134,6 +135,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-title"><%= p.title %> <div class="card-title"><%= p.title %>
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %> <% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (p.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<div class="card-footer"> <div class="card-footer">

View File

@ -8,6 +8,7 @@
<div class="song-detail-meta"> <div class="song-detail-meta">
<h1><%= song.title %> <h1><%= song.title %>
<% if (song.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %> <% if (song.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
<% if (song.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
<% if (song.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (song.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
</h1> </h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p> <p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
@ -185,6 +186,7 @@
<% } %> <% } %>
<span class="appears-in-title"><%= pl.title %></span> <span class="appears-in-title"><%= pl.title %></span>
<% if (pl.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %> <% if (pl.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members only</span><% } %>
<% if (pl.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
</a> </a>
</li> </li>
<% }) %> <% }) %>