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:
parent
df14aa8975
commit
024c3a5e40
|
|
@ -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;
|
||||
|
|
@ -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-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-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-field {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
const CACHE = 'bri-tunes-v1';
|
||||
const CACHE = 'bri-tunes-v2';
|
||||
const STATIC_PRECACHE = [
|
||||
'/',
|
||||
'/static/css/app.css',
|
||||
'/static/js/player.js',
|
||||
'/static/js/lightbox.js',
|
||||
'/static/js/navigate.js',
|
||||
'/static/js/bg-grid.js',
|
||||
'/static/js/player-viz.js',
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const songUploadSchema = z.object({
|
|||
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()),
|
||||
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
|
||||
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
|
||||
});
|
||||
|
||||
const songEditSchema = z.object({
|
||||
|
|
@ -60,12 +61,14 @@ const songEditSchema = z.object({
|
|||
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()),
|
||||
visibility: z.enum(VISIBILITY_VALUES).default('logged_in'),
|
||||
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
|
||||
});
|
||||
|
||||
const playlistSchema = z.object({
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().max(2000).optional().or(z.literal('')),
|
||||
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.
|
||||
|
|
@ -292,6 +295,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
|||
audioPath: relMedia(audioFile, 'audio'),
|
||||
coverPath,
|
||||
visibility,
|
||||
isNsfw: parsed.data.nsfw,
|
||||
uploadedBy: req.session.user.id,
|
||||
});
|
||||
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,
|
||||
year: parsed.data.year ?? null,
|
||||
visibility,
|
||||
isNsfw: parsed.data.nsfw,
|
||||
});
|
||||
const coverFile = req.files?.cover?.[0];
|
||||
if (coverFile) {
|
||||
|
|
@ -402,6 +407,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
|||
description: parsed.data.description || null,
|
||||
coverPath,
|
||||
visibility,
|
||||
isNsfw: parsed.data.nsfw,
|
||||
createdBy: req.session.user.id,
|
||||
});
|
||||
const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean);
|
||||
|
|
@ -460,6 +466,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
|||
title: parsed.data.title,
|
||||
description: parsed.data.description || null,
|
||||
visibility,
|
||||
isNsfw: parsed.data.nsfw,
|
||||
});
|
||||
const coverFile = req.files?.cover?.[0];
|
||||
if (coverFile) {
|
||||
|
|
|
|||
|
|
@ -288,8 +288,10 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
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 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';
|
||||
const isNsfw = req.body.nsfw === 'on';
|
||||
|
||||
// Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG)
|
||||
let coverPath = null;
|
||||
|
|
@ -326,6 +328,7 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
audioPath: newRel,
|
||||
coverPath,
|
||||
visibility,
|
||||
isNsfw,
|
||||
uploadedBy: req.session.user.id,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ router.get('/songs/:slug', (req, res, next) => {
|
|||
if (!canView(song.visibility, song.uploadedBy, userId, isVip)) {
|
||||
return denyAccess(req, res, next, song.visibility);
|
||||
}
|
||||
if (song.isNsfw && !userId) {
|
||||
return denyAccess(req, res, next, 'logged_in');
|
||||
}
|
||||
social.enrichSongs([song], userId);
|
||||
const creator = song.uploadedBy ? users.findById(song.uploadedBy) : null;
|
||||
const songPlaylists = playlists.getPlaylistsForSong(song.id, !!req.session.user, isVip);
|
||||
|
|
@ -123,7 +126,7 @@ router.get('/playlists/:slug', (req, res, next) => {
|
|||
return denyAccess(req, res, next, effectiveVisibility);
|
||||
}
|
||||
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);
|
||||
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
|
||||
const og = {
|
||||
|
|
@ -181,6 +184,9 @@ router.get('/stream/:id', (req, res, next) => {
|
|||
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id, isVip)) {
|
||||
return denyAccess(req, res, next, song.visibility);
|
||||
}
|
||||
if (song.isNsfw && !req.session.user) {
|
||||
return res.status(403).end();
|
||||
}
|
||||
const absPath = path.join(mediaDir, song.audioPath);
|
||||
fs.stat(absPath, (err, stat) => {
|
||||
if (err) return next();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const { db } = require('../../db');
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO playlists (title, description, cover_path, visibility, created_by)
|
||||
VALUES (@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, @is_nsfw, @created_by)
|
||||
`);
|
||||
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
|
||||
`);
|
||||
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
|
||||
|
|
@ -172,6 +172,7 @@ function toView(row) {
|
|||
description: row.description,
|
||||
coverPath: row.cover_path,
|
||||
visibility: row.visibility || 'logged_in',
|
||||
isNsfw: !!row.is_nsfw,
|
||||
createdBy: row.created_by,
|
||||
createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null,
|
||||
createdAt: row.created_at,
|
||||
|
|
@ -188,6 +189,7 @@ function trackView(row) {
|
|||
durationSeconds: row.duration_seconds,
|
||||
coverPath: row.cover_path,
|
||||
visibility: row.visibility || 'logged_in',
|
||||
isNsfw: !!row.is_nsfw,
|
||||
uploadedBy: row.uploaded_by,
|
||||
position: row.position,
|
||||
};
|
||||
|
|
@ -199,6 +201,7 @@ function create(data) {
|
|||
description: data.description || null,
|
||||
cover_path: data.coverPath || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
is_nsfw: data.isNsfw ? 1 : 0,
|
||||
created_by: data.createdBy || null,
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, info.lastInsertRowid);
|
||||
|
|
@ -212,6 +215,7 @@ function update(id, data) {
|
|||
title: data.title,
|
||||
description: data.description || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
is_nsfw: data.isNsfw ? 1 : 0,
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, id);
|
||||
updateSlugStmt.run(slug, id);
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ function enrichPlaylists(playlists, userId = null) {
|
|||
const likedSongsGuestStmt = db.prepare(`
|
||||
SELECT s.* FROM song_likes sl
|
||||
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
|
||||
`);
|
||||
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).
|
||||
const recentlyLikedGuestStmt = db.prepare(`
|
||||
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)
|
||||
ORDER BY (SELECT MAX(sl2.created_at) FROM song_likes sl2 WHERE sl2.song_id = s.id) DESC
|
||||
LIMIT ?
|
||||
|
|
@ -196,7 +196,7 @@ const mostPopularGuestStmt = db.prepare(`
|
|||
FROM songs s
|
||||
LEFT JOIN song_likes sl ON sl.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
|
||||
HAVING score > 0
|
||||
ORDER BY score DESC, s.created_at DESC
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
const { db } = require('../../db');
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO songs (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, @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, @is_nsfw, @uploaded_by)
|
||||
`);
|
||||
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
|
||||
`);
|
||||
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
|
||||
|
|
@ -37,7 +37,7 @@ const listForManagementStmt = db.prepare(`
|
|||
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 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 uploaderNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
|
||||
const findBySlugIdStmt = db.prepare('SELECT id FROM songs WHERE slug = ?');
|
||||
|
|
@ -96,6 +96,7 @@ function toView(row) {
|
|||
audioPath: row.audio_path,
|
||||
coverPath: row.cover_path,
|
||||
visibility: row.visibility || 'logged_in',
|
||||
isNsfw: !!row.is_nsfw,
|
||||
uploadedBy: row.uploaded_by,
|
||||
uploadedByName: row.uploaded_by ? (uploaderNameStmt.get(row.uploaded_by)?.display_name || null) : null,
|
||||
createdAt: row.created_at,
|
||||
|
|
@ -113,6 +114,7 @@ function create(data) {
|
|||
audio_path: data.audioPath,
|
||||
cover_path: data.coverPath || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
is_nsfw: data.isNsfw ? 1 : 0,
|
||||
uploaded_by: data.uploadedBy || null,
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, data.artist, info.lastInsertRowid);
|
||||
|
|
@ -129,6 +131,7 @@ function update(id, data) {
|
|||
genre: data.genre || null,
|
||||
year: data.year || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
is_nsfw: data.isNsfw ? 1 : 0,
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, data.artist, 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 = 'public'`;
|
||||
const conds = [vis];
|
||||
if (!loggedIn) conds.push('is_nsfw = 0');
|
||||
const params = { limit, offset };
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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`);
|
||||
|
||||
function listPublicByUser(userId, loggedIn = false, isVip = false) {
|
||||
|
|
@ -214,7 +218,7 @@ function listUploaders(loggedIn = false, isVip = false) {
|
|||
return db.prepare(`
|
||||
SELECT DISTINCT u.id, u.display_name
|
||||
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
|
||||
`).all();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,12 +70,23 @@
|
|||
<input type="radio" name="visibility" value="logged_in" <%= 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 || values.visibility === 'private') ? 'checked' : '' %>>
|
||||
Private — only you
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="nsfw" value="on">
|
||||
Parental Advisory (Explicit Lyrics)
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<button type="submit" class="btn-primary">Publish to Library</button>
|
||||
<a href="/generate" class="btn-link">Cancel</a>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@
|
|||
</label>
|
||||
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
|
||||
</fieldset>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="nsfw" value="on" <%= values.isNsfw ? 'checked' : '' %>>
|
||||
Parental Advisory (Explicit Lyrics)
|
||||
</label>
|
||||
|
||||
<% if (!playlist && preselectIds && preselectIds.length) { %>
|
||||
<div class="preselect-notice">
|
||||
<strong><%= preselectIds.length %></strong> song<%= preselectIds.length === 1 ? '' : 's' %> will be added after creation.
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@
|
|||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="nsfw" value="on" <%= values.isNsfw ? 'checked' : '' %>>
|
||||
Parental Advisory (Explicit Lyrics)
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>
|
||||
<a href="/mymusic/songs" class="btn-link">Cancel</a>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
<% 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.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
|
||||
<div class="song-subtitle">
|
||||
<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><% } %>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<% featured.forEach((p) => { %>
|
||||
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||
<% if (p.coverPath) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" class="lb-trigger" data-lightbox />
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
<div class="cover-fallback">🎧</div>
|
||||
<% } %>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
<% } %>
|
||||
<div class="song-detail-info">
|
||||
<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><% } %>
|
||||
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
|
||||
<div class="social-row">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<% 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.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
|
||||
</h1>
|
||||
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
|
||||
<div class="social-row">
|
||||
|
|
|
|||
Loading…
Reference in New Issue