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-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); }
/* ---------- 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 playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
const VISIBILITY_VALUES = ['public', 'logged_in', 'private'];
const VISIBILITY_RANK = { public: 0, logged_in: 1, private: 2 };
const VISIBILITY_VALUES = ['public', 'logged_in', 'vip', 'private'];
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.
// 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({
title: merged.title,
artist: merged.artist,
@ -267,7 +269,7 @@ module.exports = function adminRoutes(csrfProtection) {
durationSeconds: tags.duration,
audioPath: relMedia(audioFile, 'audio'),
coverPath,
visibility: parsed.data.visibility,
visibility,
uploadedBy: req.session.user.id,
});
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);
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, {
title: parsed.data.title,
artist: parsed.data.artist,
album: parsed.data.album || null,
genre: parsed.data.genre || null,
year: parsed.data.year ?? null,
visibility: parsed.data.visibility,
visibility,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {
@ -369,11 +373,13 @@ module.exports = function adminRoutes(csrfProtection) {
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({
title: parsed.data.title,
description: parsed.data.description || null,
coverPath,
visibility: parsed.data.visibility,
visibility,
createdBy: req.session.user.id,
});
const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean);
@ -426,10 +432,12 @@ module.exports = function adminRoutes(csrfProtection) {
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, {
title: parsed.data.title,
description: parsed.data.description || null,
visibility: parsed.data.visibility,
visibility,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {

View File

@ -18,50 +18,58 @@ const MIME = {
};
// 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 === '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' && !req.session.user) {
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), userId),
recent: social.enrichSongs(songs.recent(8, loggedIn), userId),
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 });
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);
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);
res.render('public/songs', {
@ -74,13 +82,14 @@ router.get('/songs', (req, res) => {
router.get('/songs/:slug', (req, res, next) => {
const song = songs.findBySlug(req.params.slug);
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);
}
const userId = req.session.user?.id || null;
social.enrichSongs([song], userId);
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 = {
title: `${res.locals.siteName}: ${song.title} by ${song.artist}`,
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) => {
const loggedIn = !!req.session.user;
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 });
});
@ -101,13 +111,14 @@ 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');
// Use effective visibility (most restrictive of playlist + all its tracks).
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);
}
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);
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
const og = {
@ -129,18 +140,19 @@ router.get('/profiles/:slug', (req, res, next) => {
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), userId);
const userSongs = social.enrichSongs(songs.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, 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
const likedSongs = social.enrichSongs(
likedSongRows.map(row => songs.findById(row.id)).filter(Boolean),
userId
);
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn);
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn, isVip);
const likedPlaylists = social.enrichPlaylists(
likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean),
userId
@ -160,7 +172,8 @@ router.get('/profiles/:slug', (req, res, next) => {
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)) {
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);
}
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 = ?');
// 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(`
SELECT p.* FROM playlists p
WHERE p.visibility = 'public'
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 ('logged_in', 'private')
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
)
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(`
SELECT p.* FROM playlists p
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 (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
@ -36,6 +47,33 @@ const listUserStmt = db.prepare(`
)
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 listByUserStmt = db.prepare('SELECT * FROM playlists WHERE created_by = ? ORDER BY created_at DESC');
const featuredGuestStmt = db.prepare(`
@ -44,7 +82,7 @@ const featuredGuestStmt = db.prepare(`
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 ('logged_in', 'private')
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
)
ORDER BY RANDOM() LIMIT ?
`);
@ -54,7 +92,7 @@ const featuredUserStmt = db.prepare(`
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'
WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
)
ORDER BY RANDOM() LIMIT ?
`);
@ -66,6 +104,9 @@ const effectiveVisibilityStmt = db.prepare(`
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')
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'
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'
@ -198,8 +239,8 @@ function getEffectiveVisibility(id) {
return row ? row.effective_visibility : null;
}
function listVisible(loggedIn = false) {
return (loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
function listVisible(loggedIn = false, isVip = false) {
return (isVip ? listVipStmt : loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
}
function listAll() {
@ -210,8 +251,8 @@ function listByUser(userId) {
return listByUserStmt.all(userId).map(toView);
}
function featured(n = 6, loggedIn = false) {
return (loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView);
function featured(n = 6, loggedIn = false, isVip = false) {
return (isVip ? featuredVipStmt : loggedIn ? featuredUserStmt : featuredGuestStmt).all(n).map(toView);
}
function getTracks(playlistId) {
@ -245,7 +286,7 @@ const listPublicByUserGuestStmt = db.prepare(`
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 ('logged_in', 'private')
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
)
ORDER BY p.created_at DESC
`);
@ -255,13 +296,13 @@ const listPublicByUserLoggedStmt = db.prepare(`
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'
WHERE ps.playlist_id = p.id AND s.visibility IN ('vip', 'private')
)
ORDER BY p.created_at DESC
`);
function listPublicByUser(userId, loggedIn = false) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
function listPublicByUser(userId, loggedIn = false, isVip = false) {
return (isVip ? listPublicByUserVipStmt : loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
}
// 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
`);
function getPlaylistsForSong(songId, loggedIn = false) {
return (loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
function getPlaylistsForSong(songId, loggedIn = false, isVip = false) {
return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
}
module.exports = {

View File

@ -114,6 +114,12 @@ const likedSongsUserStmt = db.prepare(`
WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in')
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(`
SELECT p.* FROM playlist_likes pl
JOIN playlists p ON p.id = pl.playlist_id
@ -121,7 +127,7 @@ const likedPlaylistsGuestStmt = db.prepare(`
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 ('logged_in', 'private')
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'vip', 'private')
)
ORDER BY pl.created_at DESC
`);
@ -129,6 +135,17 @@ const likedPlaylistsUserStmt = 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')
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 (
SELECT 1 FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
@ -137,12 +154,12 @@ const likedPlaylistsUserStmt = db.prepare(`
ORDER BY pl.created_at DESC
`);
function getLikedSongs(userId, loggedIn = false) {
return (loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId);
function getLikedSongs(userId, loggedIn = false, isVip = false) {
return (isVip ? likedSongsVipStmt : loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId);
}
function getLikedPlaylists(userId, loggedIn = false) {
return (loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId);
function getLikedPlaylists(userId, loggedIn = false, isVip = false) {
return (isVip ? likedPlaylistsVipStmt : loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId);
}
// 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
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) {
return (loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
function getRecentlyLikedSongs(n = 5, loggedIn = false, isVip = false) {
return (isVip ? recentlyLikedVipStmt : loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
}
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 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 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.
const listForManagementStmt = db.prepare(`
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
`);
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).
// isVip=true adds vip visibility to the visible set.
// Supports optional genre and uploadedBy filters for faceted browsing.
function list({ q, genre, uploadedBy, limit = 25, offset = 0, loggedIn = false }) {
const vis = loggedIn
? `visibility IN ('public', 'logged_in')`
: `visibility = 'public'`;
function list({ q, genre, uploadedBy, limit = 25, offset = 0, loggedIn = false, isVip = false }) {
const vis = isVip
? `visibility IN ('public', 'logged_in', 'vip')`
: loggedIn
? `visibility IN ('public', 'logged_in')`
: `visibility = 'public'`;
const conds = [vis];
const params = { limit, offset };
@ -185,23 +192,25 @@ function listForManagement(userId) {
return listForManagementStmt.all(userId).map(toView);
}
function recent(n = 8, loggedIn = false) {
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
function recent(n = 8, loggedIn = false, isVip = false) {
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 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) {
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
function listPublicByUser(userId, loggedIn = false, isVip = false) {
return (isVip ? listPublicByUserVipStmt : loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
}
function listGenres() {
return listGenresStmt.all().map(r => r.genre);
}
function listUploaders(loggedIn = false) {
const vis = loggedIn ? `IN ('public', 'logged_in')` : `= 'public'`;
function listUploaders(loggedIn = false, isVip = false) {
const vis = isVip
? `IN ('public', 'logged_in', 'vip')`
: loggedIn ? `IN ('public', 'logged_in')` : `= 'public'`;
return db.prepare(`
SELECT DISTINCT u.id, u.display_name
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' : '' %>>
Logged in only — members only
</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">
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
Private — only you

View File

@ -20,6 +20,8 @@
<td>
<% if (p.visibility === 'logged_in') { %>
<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') { %>
<span class="badge badge-private">Private</span>
<% } else { %>

View File

@ -94,6 +94,12 @@
<input type="radio" name="visibility" value="logged_in" <%= (!values.visibility || values.visibility === 'logged_in') ? 'checked' : '' %>>
Logged in only — members only
</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">
<input type="radio" name="visibility" value="private" <%= (values.visibility === 'private') ? 'checked' : '' %>>
Private — only you

View File

@ -50,6 +50,8 @@
<td>
<% if (s.visibility === 'logged_in') { %>
<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') { %>
<span class="badge badge-private">Private</span>
<% } else { %>

View File

@ -9,6 +9,7 @@
<div class="song-main">
<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 === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
<% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
<div class="song-subtitle">
<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-title"><%= p.title %>
<% 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><% } %>
</div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>

View File

@ -13,6 +13,7 @@
<div class="card-body">
<div class="card-title"><%= p.title %>
<% 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><% } %>
</div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>

View File

@ -29,6 +29,7 @@
<div class="card-body">
<div class="card-title"><%= p.title %>
<% 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>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<div class="card-footer">
@ -134,6 +135,7 @@
<div class="card-body">
<div class="card-title"><%= p.title %>
<% 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>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<div class="card-footer">

View File

@ -8,6 +8,7 @@
<div class="song-detail-meta">
<h1><%= song.title %>
<% 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><% } %>
</h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
@ -185,6 +186,7 @@
<% } %>
<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 === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
</a>
</li>
<% }) %>