feat: add NSFW flag and visibility controls for explicit content

- Add `is_nsfw` column to `songs` and `playlists` tables
- Implement NSFW checkbox in song/playlist upload and edit forms
- Enforce NSFW content visibility: block unauthenticated users from viewing/streaming NSFW songs/playlists
- Filter NSFW content from public-facing lists (recent, popular, liked, etc.) unless user is logged in
- Add VIP-only visibility option for songs/playlists (admin/VIP users only)
- Display "EXPLICIT" badge on NSFW items in UI
- Add CSS styling for NSFW badge
- Update service layer to handle NSFW flag in CRUD operations and queries
This commit is contained in:
Brian Fertig 2026-04-16 19:55:26 -06:00
parent df14aa8975
commit 024c3a5e40
16 changed files with 71 additions and 17 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE songs ADD COLUMN is_nsfw INTEGER NOT NULL DEFAULT 0;
ALTER TABLE playlists ADD COLUMN is_nsfw INTEGER NOT NULL DEFAULT 0;

View File

@ -394,6 +394,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.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-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); }
.badge-nsfw { color: #ff3333; border: 1px solid #ff3333; box-shadow: 0 0 5px rgba(255,51,51,0.5); background: rgba(255,51,51,0.08); cursor: help; }
/* ---------- Visibility radio fieldset ---------- */ /* ---------- Visibility radio fieldset ---------- */
.visibility-field { .visibility-field {

View File

@ -1,8 +1,9 @@
const CACHE = 'bri-tunes-v1'; const CACHE = 'bri-tunes-v2';
const STATIC_PRECACHE = [ const STATIC_PRECACHE = [
'/', '/',
'/static/css/app.css', '/static/css/app.css',
'/static/js/player.js', '/static/js/player.js',
'/static/js/lightbox.js',
'/static/js/navigate.js', '/static/js/navigate.js',
'/static/js/bg-grid.js', '/static/js/bg-grid.js',
'/static/js/player-viz.js', '/static/js/player-viz.js',

View File

@ -51,6 +51,7 @@ const songUploadSchema = z.object({
genre: z.string().trim().max(80).optional().or(z.literal('')), genre: z.string().trim().max(80).optional().or(z.literal('')),
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()), year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
}); });
const songEditSchema = z.object({ const songEditSchema = z.object({
@ -60,12 +61,14 @@ const songEditSchema = z.object({
genre: z.string().trim().max(80).optional().or(z.literal('')), genre: z.string().trim().max(80).optional().or(z.literal('')),
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()), year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
}); });
const playlistSchema = z.object({ const playlistSchema = z.object({
title: z.string().trim().min(1).max(200), title: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).optional().or(z.literal('')), description: z.string().trim().max(2000).optional().or(z.literal('')),
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
}); });
// Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file. // Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file.
@ -292,6 +295,7 @@ module.exports = function adminRoutes(csrfProtection) {
audioPath: relMedia(audioFile, 'audio'), audioPath: relMedia(audioFile, 'audio'),
coverPath, coverPath,
visibility, visibility,
isNsfw: parsed.data.nsfw,
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.');
@ -332,6 +336,7 @@ module.exports = function adminRoutes(csrfProtection) {
genre: parsed.data.genre || null, genre: parsed.data.genre || null,
year: parsed.data.year ?? null, year: parsed.data.year ?? null,
visibility, visibility,
isNsfw: parsed.data.nsfw,
}); });
const coverFile = req.files?.cover?.[0]; const coverFile = req.files?.cover?.[0];
if (coverFile) { if (coverFile) {
@ -402,6 +407,7 @@ module.exports = function adminRoutes(csrfProtection) {
description: parsed.data.description || null, description: parsed.data.description || null,
coverPath, coverPath,
visibility, visibility,
isNsfw: parsed.data.nsfw,
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);
@ -460,6 +466,7 @@ module.exports = function adminRoutes(csrfProtection) {
title: parsed.data.title, title: parsed.data.title,
description: parsed.data.description || null, description: parsed.data.description || null,
visibility, visibility,
isNsfw: parsed.data.nsfw,
}); });
const coverFile = req.files?.cover?.[0]; const coverFile = req.files?.cover?.[0];
if (coverFile) { if (coverFile) {

View File

@ -288,8 +288,10 @@ module.exports = function generateRoutes(csrfProtection) {
const album = (req.body.album || '').trim().slice(0, 200) || null; const album = (req.body.album || '').trim().slice(0, 200) || null;
const genre = (req.body.genre !== '__new__' ? req.body.genre : '') || (req.body.genre_new || '').trim() || null; const genre = (req.body.genre !== '__new__' ? req.body.genre : '') || (req.body.genre_new || '').trim() || null;
const year = parseInt(req.body.year, 10) || null; const year = parseInt(req.body.year, 10) || null;
const visibility = ['public','logged_in','private'].includes(req.body.visibility) const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
const visibility = ['public','logged_in','vip','private'].includes(req.body.visibility) && (req.body.visibility !== 'vip' || canSetVip)
? req.body.visibility : 'private'; ? req.body.visibility : 'private';
const isNsfw = req.body.nsfw === 'on';
// Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG) // Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG)
let coverPath = null; let coverPath = null;
@ -326,6 +328,7 @@ module.exports = function generateRoutes(csrfProtection) {
audioPath: newRel, audioPath: newRel,
coverPath, coverPath,
visibility, visibility,
isNsfw,
uploadedBy: req.session.user.id, uploadedBy: req.session.user.id,
}); });

View File

@ -92,6 +92,9 @@ router.get('/songs/:slug', (req, res, next) => {
if (!canView(song.visibility, song.uploadedBy, userId, isVip)) { if (!canView(song.visibility, song.uploadedBy, userId, isVip)) {
return denyAccess(req, res, next, song.visibility); return denyAccess(req, res, next, song.visibility);
} }
if (song.isNsfw && !userId) {
return denyAccess(req, res, next, 'logged_in');
}
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, isVip); const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user, isVip);
@ -123,7 +126,7 @@ router.get('/playlists/:slug', (req, res, next) => {
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, isVip)); const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip) && (!t.isNsfw || !!userId));
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 = {
@ -181,6 +184,9 @@ router.get('/stream/:id', (req, res, next) => {
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id, isVip)) { 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);
} }
if (song.isNsfw && !req.session.user) {
return res.status(403).end();
}
const absPath = path.join(mediaDir, song.audioPath); const absPath = path.join(mediaDir, song.audioPath);
fs.stat(absPath, (err, stat) => { fs.stat(absPath, (err, stat) => {
if (err) return next(); if (err) return next();

View File

@ -1,11 +1,11 @@
const { db } = require('../../db'); const { db } = require('../../db');
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO playlists (title, description, cover_path, visibility, created_by) INSERT INTO playlists (title, description, cover_path, visibility, is_nsfw, created_by)
VALUES (@title, @description, @cover_path, @visibility, @created_by) VALUES (@title, @description, @cover_path, @visibility, @is_nsfw, @created_by)
`); `);
const updateStmt = db.prepare(` const updateStmt = db.prepare(`
UPDATE playlists SET title = @title, description = @description, visibility = @visibility UPDATE playlists SET title = @title, description = @description, visibility = @visibility, is_nsfw = @is_nsfw
WHERE id = @id WHERE id = @id
`); `);
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?'); const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
@ -172,6 +172,7 @@ function toView(row) {
description: row.description, description: row.description,
coverPath: row.cover_path, coverPath: row.cover_path,
visibility: row.visibility || 'logged_in', visibility: row.visibility || 'logged_in',
isNsfw: !!row.is_nsfw,
createdBy: row.created_by, createdBy: row.created_by,
createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null, createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null,
createdAt: row.created_at, createdAt: row.created_at,
@ -188,6 +189,7 @@ function trackView(row) {
durationSeconds: row.duration_seconds, durationSeconds: row.duration_seconds,
coverPath: row.cover_path, coverPath: row.cover_path,
visibility: row.visibility || 'logged_in', visibility: row.visibility || 'logged_in',
isNsfw: !!row.is_nsfw,
uploadedBy: row.uploaded_by, uploadedBy: row.uploaded_by,
position: row.position, position: row.position,
}; };
@ -199,6 +201,7 @@ function create(data) {
description: data.description || null, description: data.description || null,
cover_path: data.coverPath || null, cover_path: data.coverPath || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
is_nsfw: data.isNsfw ? 1 : 0,
created_by: data.createdBy || null, created_by: data.createdBy || null,
}); });
const slug = generateUniqueSlug(data.title, info.lastInsertRowid); const slug = generateUniqueSlug(data.title, info.lastInsertRowid);
@ -212,6 +215,7 @@ function update(id, data) {
title: data.title, title: data.title,
description: data.description || null, description: data.description || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
is_nsfw: data.isNsfw ? 1 : 0,
}); });
const slug = generateUniqueSlug(data.title, id); const slug = generateUniqueSlug(data.title, id);
updateSlugStmt.run(slug, id); updateSlugStmt.run(slug, id);

View File

@ -105,7 +105,7 @@ function enrichPlaylists(playlists, userId = null) {
const likedSongsGuestStmt = db.prepare(` const likedSongsGuestStmt = db.prepare(`
SELECT s.* FROM song_likes sl SELECT s.* FROM song_likes sl
JOIN songs s ON s.id = sl.song_id JOIN songs s ON s.id = sl.song_id
WHERE sl.user_id = ? AND s.visibility = 'public' WHERE sl.user_id = ? AND s.visibility = 'public' AND s.is_nsfw = 0
ORDER BY sl.created_at DESC ORDER BY sl.created_at DESC
`); `);
const likedSongsUserStmt = db.prepare(` const likedSongsUserStmt = db.prepare(`
@ -165,7 +165,7 @@ function getLikedPlaylists(userId, loggedIn = false, isVip = false) {
// 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).
const recentlyLikedGuestStmt = db.prepare(` const recentlyLikedGuestStmt = db.prepare(`
SELECT s.* FROM songs s SELECT s.* FROM songs s
WHERE s.visibility = 'public' WHERE s.visibility = 'public' AND s.is_nsfw = 0
AND EXISTS (SELECT 1 FROM song_likes sl WHERE sl.song_id = s.id) 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 ORDER BY (SELECT MAX(sl2.created_at) FROM song_likes sl2 WHERE sl2.song_id = s.id) DESC
LIMIT ? LIMIT ?
@ -196,7 +196,7 @@ const mostPopularGuestStmt = db.prepare(`
FROM songs s FROM songs s
LEFT JOIN song_likes sl ON sl.song_id = s.id LEFT JOIN song_likes sl ON sl.song_id = s.id
LEFT JOIN song_favorites sf ON sf.song_id = s.id LEFT JOIN song_favorites sf ON sf.song_id = s.id
WHERE s.visibility = 'public' WHERE s.visibility = 'public' AND s.is_nsfw = 0
GROUP BY s.id GROUP BY s.id
HAVING score > 0 HAVING score > 0
ORDER BY score DESC, s.created_at DESC ORDER BY score DESC, s.created_at DESC

View File

@ -1,11 +1,11 @@
const { db } = require('../../db'); const { db } = require('../../db');
const insertStmt = db.prepare(` const insertStmt = db.prepare(`
INSERT INTO songs (title, artist, album, genre, year, duration_seconds, audio_path, cover_path, visibility, uploaded_by) INSERT INTO songs (title, artist, album, genre, year, duration_seconds, audio_path, cover_path, visibility, is_nsfw, uploaded_by)
VALUES (@title, @artist, @album, @genre, @year, @duration_seconds, @audio_path, @cover_path, @visibility, @uploaded_by) VALUES (@title, @artist, @album, @genre, @year, @duration_seconds, @audio_path, @cover_path, @visibility, @is_nsfw, @uploaded_by)
`); `);
const updateStmt = db.prepare(` const updateStmt = db.prepare(`
UPDATE songs SET title = @title, artist = @artist, album = @album, genre = @genre, year = @year, visibility = @visibility UPDATE songs SET title = @title, artist = @artist, album = @album, genre = @genre, year = @year, visibility = @visibility, is_nsfw = @is_nsfw
WHERE id = @id WHERE id = @id
`); `);
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?'); const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
@ -37,7 +37,7 @@ const listForManagementStmt = db.prepare(`
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`);
const recentGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' ORDER BY created_at DESC LIMIT ?`); const recentGuestStmt = db.prepare(`SELECT * FROM songs WHERE visibility = 'public' AND is_nsfw = 0 ORDER BY created_at DESC LIMIT ?`);
const recentUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ?`); const recentUserStmt = db.prepare(`SELECT * FROM songs WHERE visibility IN ('public', 'logged_in') ORDER BY created_at DESC LIMIT ?`);
const uploaderNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?'); const uploaderNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
const findBySlugIdStmt = db.prepare('SELECT id FROM songs WHERE slug = ?'); const findBySlugIdStmt = db.prepare('SELECT id FROM songs WHERE slug = ?');
@ -96,6 +96,7 @@ function toView(row) {
audioPath: row.audio_path, audioPath: row.audio_path,
coverPath: row.cover_path, coverPath: row.cover_path,
visibility: row.visibility || 'logged_in', visibility: row.visibility || 'logged_in',
isNsfw: !!row.is_nsfw,
uploadedBy: row.uploaded_by, uploadedBy: row.uploaded_by,
uploadedByName: row.uploaded_by ? (uploaderNameStmt.get(row.uploaded_by)?.display_name || null) : null, uploadedByName: row.uploaded_by ? (uploaderNameStmt.get(row.uploaded_by)?.display_name || null) : null,
createdAt: row.created_at, createdAt: row.created_at,
@ -113,6 +114,7 @@ function create(data) {
audio_path: data.audioPath, audio_path: data.audioPath,
cover_path: data.coverPath || null, cover_path: data.coverPath || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
is_nsfw: data.isNsfw ? 1 : 0,
uploaded_by: data.uploadedBy || null, uploaded_by: data.uploadedBy || null,
}); });
const slug = generateUniqueSlug(data.title, data.artist, info.lastInsertRowid); const slug = generateUniqueSlug(data.title, data.artist, info.lastInsertRowid);
@ -129,6 +131,7 @@ function update(id, data) {
genre: data.genre || null, genre: data.genre || null,
year: data.year || null, year: data.year || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
is_nsfw: data.isNsfw ? 1 : 0,
}); });
const slug = generateUniqueSlug(data.title, data.artist, id); const slug = generateUniqueSlug(data.title, data.artist, id);
updateSlugStmt.run(slug, id); updateSlugStmt.run(slug, id);
@ -159,6 +162,7 @@ function list({ q, genre, uploadedBy, limit = 25, offset = 0, loggedIn = false,
? `visibility IN ('public', 'logged_in')` ? `visibility IN ('public', 'logged_in')`
: `visibility = 'public'`; : `visibility = 'public'`;
const conds = [vis]; const conds = [vis];
if (!loggedIn) conds.push('is_nsfw = 0');
const params = { limit, offset }; const params = { limit, offset };
if (q && q.trim()) { if (q && q.trim()) {
@ -196,7 +200,7 @@ function recent(n = 8, loggedIn = false, isVip = false) {
return (isVip ? recentVipStmt : 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' AND is_nsfw = 0 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, isVip = false) { function listPublicByUser(userId, loggedIn = false, isVip = false) {
@ -214,7 +218,7 @@ function listUploaders(loggedIn = false, isVip = false) {
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
WHERE s.visibility ${vis} AND s.uploaded_by IS NOT NULL WHERE s.visibility ${vis} AND s.uploaded_by IS NOT NULL${!loggedIn ? ' AND s.is_nsfw = 0' : ''}
ORDER BY u.display_name ASC ORDER BY u.display_name ASC
`).all(); `).all();
} }

View File

@ -70,12 +70,23 @@
<input type="radio" name="visibility" value="logged_in" <%= values.visibility === 'logged_in' ? 'checked' : '' %>> <input type="radio" name="visibility" value="logged_in" <%= 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 || values.visibility === 'private') ? 'checked' : '' %>> <input type="radio" name="visibility" value="private" <%= (!values.visibility || values.visibility === 'private') ? 'checked' : '' %>>
Private — only you Private — only you
</label> </label>
</fieldset> </fieldset>
<label class="checkbox">
<input type="checkbox" name="nsfw" value="on">
Parental Advisory (Explicit Lyrics)
</label>
<div class="row"> <div class="row">
<button type="submit" class="btn-primary">Publish to Library</button> <button type="submit" class="btn-primary">Publish to Library</button>
<a href="/generate" class="btn-link">Cancel</a> <a href="/generate" class="btn-link">Cancel</a>

View File

@ -39,6 +39,12 @@
</label> </label>
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p> <p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
</fieldset> </fieldset>
<label class="checkbox">
<input type="checkbox" name="nsfw" value="on" <%= values.isNsfw ? 'checked' : '' %>>
Parental Advisory (Explicit Lyrics)
</label>
<% if (!playlist && preselectIds && preselectIds.length) { %> <% if (!playlist && preselectIds && preselectIds.length) { %>
<div class="preselect-notice"> <div class="preselect-notice">
<strong><%= preselectIds.length %></strong> song<%= preselectIds.length === 1 ? '' : 's' %> will be added after creation. <strong><%= preselectIds.length %></strong> song<%= preselectIds.length === 1 ? '' : 's' %> will be added after creation.

View File

@ -106,6 +106,11 @@
</label> </label>
</fieldset> </fieldset>
<label class="checkbox">
<input type="checkbox" name="nsfw" value="on" <%= values.isNsfw ? 'checked' : '' %>>
Parental Advisory (Explicit Lyrics)
</label>
<div class="row"> <div class="row">
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button> <button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>
<a href="/mymusic/songs" class="btn-link">Cancel</a> <a href="/mymusic/songs" class="btn-link">Cancel</a>

View File

@ -20,6 +20,7 @@
<% 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 === '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><% } %>
<% if (s.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</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>
<% if (s.durationSeconds) { %><span class="song-duration muted small"><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %></span><% } %> <% if (s.durationSeconds) { %><span class="song-duration muted small"><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %></span><% } %>

View File

@ -12,7 +12,7 @@
<% featured.forEach((p) => { %> <% featured.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>"> <a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
<% if (p.coverPath) { %> <% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" class="lb-trigger" data-lightbox /> <img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %> <% } else { %>
<div class="cover-fallback">🎧</div> <div class="cover-fallback">🎧</div>
<% } %> <% } %>

View File

@ -6,7 +6,9 @@
<% } %> <% } %>
<div class="song-detail-info"> <div class="song-detail-info">
<div class="song-detail-meta"> <div class="song-detail-meta">
<h1><%= playlist.title %></h1> <h1><%= playlist.title %>
<% if (playlist.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
</h1>
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %> <% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p> <p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<div class="social-row"> <div class="social-row">

View File

@ -10,6 +10,7 @@
<% 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 === '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><% } %>
<% if (song.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</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>
<div class="social-row"> <div class="social-row">