feat: implement social actions (like/favorite) for songs and playlists

- Add database tables for song/playlist likes and favorites
- Create `social` service with toggle functions and enrichment helpers
- Implement `/api/:type/:id/(like|favorite)` routes with CSRF protection
- Add like/favorite buttons to player, song lists, playlists, and cards
- Support guest → login redirect via `data-require-login`
- Inject CSRF token into layout for JS API requests
This commit is contained in:
Brian Fertig 2026-04-11 09:12:21 -06:00
parent a24f2fe75d
commit f044d98afc
15 changed files with 331 additions and 9 deletions

View File

@ -0,0 +1,24 @@
CREATE TABLE song_likes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, song_id)
);
CREATE TABLE song_favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, song_id)
);
CREATE TABLE playlist_likes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, playlist_id)
);
CREATE TABLE playlist_favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, playlist_id)
);

View File

@ -486,6 +486,29 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.data-table .actions { white-space: nowrap; font-size: 0.85rem; }
.data-table tr.row-disabled td { opacity: 0.45; }
/* ---------- Social buttons ---------- */
.btn-social {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 0.8rem;
padding: 2px 5px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.btn-social:hover { color: var(--fg); background: rgba(255,255,255,0.06); }
.btn-social[data-social-action="like"].active { color: var(--neon-cyan); text-shadow: var(--glow-cyan); }
.btn-social[data-social-action="favorite"].active { color: var(--neon-amber); text-shadow: 0 0 8px rgba(255,179,71,0.7); }
.btn-social:disabled { opacity: 0.5; cursor: wait; }
.song-social { display: flex; align-items: center; gap: 2px; }
.social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; }
.card-social { display: flex; gap: 4px; margin-top: 0.5rem; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); }
.track-list { padding-left: 0; list-style: none; }
.track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }
.track-list li:hover { background: rgba(255,255,255,0.04); }

View File

@ -6,8 +6,25 @@
const queueInfoEl = document.getElementById('player-queue-info');
const nextBtn = document.getElementById('player-next');
const repeatBtn = document.getElementById('player-repeat');
const playerLikeBtn = document.getElementById('player-like');
const playerFavBtn = document.getElementById('player-favorite');
let repeatOn = false;
function updatePlayerSocial(song) {
if (!playerLikeBtn || !playerFavBtn) return;
const active = !!song;
playerLikeBtn.disabled = playerFavBtn.disabled = !active;
playerLikeBtn.dataset.socialId = playerFavBtn.dataset.socialId = active ? song.id : '';
playerLikeBtn.classList.toggle('active', active && !!song.userLiked);
playerLikeBtn.setAttribute('aria-pressed', String(active && !!song.userLiked));
playerFavBtn.classList.toggle('active', active && !!song.userFavorited);
playerFavBtn.setAttribute('aria-pressed', String(active && !!song.userFavorited));
const lc = playerLikeBtn.querySelector('.social-count');
const fc = playerFavBtn.querySelector('.social-count');
if (lc) lc.textContent = active ? (song.likeCount ?? 0) : 0;
if (fc) fc.textContent = active ? (song.favoriteCount ?? 0) : 0;
}
const state = {
queue: [], // array of {id, title, artist, cover}
index: -1,
@ -20,6 +37,7 @@
artistEl.textContent = '';
coverEl.src = '/static/vendor/cover-placeholder.svg';
queueInfoEl.textContent = '';
updatePlayerSocial(null);
return;
}
titleEl.textContent = cur.title;
@ -28,6 +46,7 @@
queueInfoEl.textContent = state.queue.length > 1
? `${state.index + 1} / ${state.queue.length}`
: '';
updatePlayerSocial(cur);
}
function highlightNowPlaying(id) {

41
public/js/social.js Normal file
View File

@ -0,0 +1,41 @@
(function () {
const meta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = meta ? meta.content : '';
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-social-action]');
if (!btn) return;
// Guest: redirect to login
if (btn.hasAttribute('data-require-login')) {
window.location.href = '/login';
return;
}
// Prevent card <a> from navigating when button is inside one
e.stopPropagation();
const { socialAction, socialType, socialId } = btn.dataset;
const url = `/api/${socialType}s/${socialId}/${socialAction}`;
btn.disabled = true;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const isOn = data.liked ?? data.favorited;
btn.classList.toggle('active', isOn);
btn.setAttribute('aria-pressed', String(isOn));
const countEl = btn.querySelector('.social-count');
if (countEl) countEl.textContent = data.count;
} catch (err) {
console.error('[social] toggle failed:', err);
} finally {
btn.disabled = false;
}
});
})();

View File

@ -46,7 +46,7 @@ app.use(session({
}));
const { csrfSynchronisedProtection, generateToken } = csrfSync({
getTokenFromRequest: (req) => req.body && req.body._csrf,
getTokenFromRequest: (req) => req.body?._csrf || req.headers['x-csrf-token'],
});
// Attach helpers for templates.
@ -69,6 +69,7 @@ app.use((req, _res, next) => {
});
// Routes.
app.use('/api', require('./routes/social')(csrfSynchronisedProtection));
app.use('/', require('./routes/public'));
app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
app.use('/account', require('./routes/account')(csrfSynchronisedProtection));

View File

@ -4,6 +4,7 @@ const express = require('express');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const social = require('../services/social');
const router = express.Router();
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -36,15 +37,17 @@ function denyAccess(req, res, next, visibility) {
router.get('/', (req, res) => {
const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null;
res.render('public/home', {
title: res.locals.siteName,
featured: playlists.featured(5, loggedIn),
recent: songs.recent(8, loggedIn),
featured: social.enrichPlaylists(playlists.featured(5, loggedIn), userId),
recent: social.enrichSongs(songs.recent(8, loggedIn), userId),
});
});
router.get('/songs', (req, res) => {
const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null;
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25;
const { rows, total } = songs.list({
@ -53,6 +56,7 @@ router.get('/songs', (req, res) => {
offset: (page - 1) * pageSize,
loggedIn,
});
social.enrichSongs(rows, userId);
res.render('public/songs', {
title: 'Songs',
songs: rows,
@ -70,24 +74,30 @@ router.get('/songs/:slug', (req, res, next) => {
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
return denyAccess(req, res, next, song.visibility);
}
const userId = req.session.user?.id || null;
social.enrichSongs([song], userId);
res.render('public/song', { title: song.title, song });
});
router.get('/playlists', (req, res) => {
const loggedIn = !!req.session.user;
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) });
const userId = req.session.user?.id || null;
const list = social.enrichPlaylists(playlists.listVisible(loggedIn), userId);
res.render('public/playlists', { title: 'Playlists', playlists: list });
});
router.get('/playlists/:slug', (req, res, next) => {
const playlist = playlists.findBySlug(req.params.slug);
if (!playlist) return next();
const userId = req.session.user?.id;
const userId = req.session.user?.id || null;
// Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id);
if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
return denyAccess(req, res, next, effectiveVisibility);
}
social.enrichPlaylists([playlist], userId);
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId));
social.enrichSongs(tracks, userId);
res.render('public/playlist', { title: playlist.title, playlist, tracks });
});

44
src/routes/social.js Normal file
View File

@ -0,0 +1,44 @@
const express = require('express');
const { requireUser } = require('../middleware/auth');
const social = require('../services/social');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
module.exports = function socialRoutes(csrfProtection) {
const router = express.Router();
router.use(requireUser);
router.post('/songs/:id/like', csrfProtection, (req, res, next) => {
try {
const song = songs.findById(Number(req.params.id));
if (!song) return res.status(404).json({ error: 'Not found' });
res.json(social.toggleSongLike(req.session.user.id, song.id));
} catch (err) { next(err); }
});
router.post('/songs/:id/favorite', csrfProtection, (req, res, next) => {
try {
const song = songs.findById(Number(req.params.id));
if (!song) return res.status(404).json({ error: 'Not found' });
res.json(social.toggleSongFavorite(req.session.user.id, song.id));
} catch (err) { next(err); }
});
router.post('/playlists/:id/like', csrfProtection, (req, res, next) => {
try {
const playlist = playlists.findById(Number(req.params.id));
if (!playlist) return res.status(404).json({ error: 'Not found' });
res.json(social.togglePlaylistLike(req.session.user.id, playlist.id));
} catch (err) { next(err); }
});
router.post('/playlists/:id/favorite', csrfProtection, (req, res, next) => {
try {
const playlist = playlists.findById(Number(req.params.id));
if (!playlist) return res.status(404).json({ error: 'Not found' });
res.json(social.togglePlaylistFavorite(req.session.user.id, playlist.id));
} catch (err) { next(err); }
});
return router;
};

78
src/services/social.js Normal file
View File

@ -0,0 +1,78 @@
const { db } = require('../../db');
// ── Song statements ──────────────────────────────────────────────────────────
const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?');
const songFavCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_favorites WHERE song_id = ?');
const songUserLikedStmt = db.prepare('SELECT 1 FROM song_likes WHERE user_id = ? AND song_id = ?');
const songUserFavoritedStmt = db.prepare('SELECT 1 FROM song_favorites WHERE user_id = ? AND song_id = ?');
const insertSongLike = db.prepare('INSERT OR IGNORE INTO song_likes (user_id, song_id) VALUES (?, ?)');
const deleteSongLike = db.prepare('DELETE FROM song_likes WHERE user_id = ? AND song_id = ?');
const insertSongFav = db.prepare('INSERT OR IGNORE INTO song_favorites (user_id, song_id) VALUES (?, ?)');
const deleteSongFav = db.prepare('DELETE FROM song_favorites WHERE user_id = ? AND song_id = ?');
// ── Playlist statements ──────────────────────────────────────────────────────
const plLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_likes WHERE playlist_id = ?');
const plFavCountStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_favorites WHERE playlist_id = ?');
const plUserLikedStmt = db.prepare('SELECT 1 FROM playlist_likes WHERE user_id = ? AND playlist_id = ?');
const plUserFavoritedStmt = db.prepare('SELECT 1 FROM playlist_favorites WHERE user_id = ? AND playlist_id = ?');
const insertPlLike = db.prepare('INSERT OR IGNORE INTO playlist_likes (user_id, playlist_id) VALUES (?, ?)');
const deletePlLike = db.prepare('DELETE FROM playlist_likes WHERE user_id = ? AND playlist_id = ?');
const insertPlFav = db.prepare('INSERT OR IGNORE INTO playlist_favorites (user_id, playlist_id) VALUES (?, ?)');
const deletePlFav = db.prepare('DELETE FROM playlist_favorites WHERE user_id = ? AND playlist_id = ?');
// ── Toggle functions ─────────────────────────────────────────────────────────
function toggleSongLike(userId, songId) {
const exists = songUserLikedStmt.get(userId, songId);
if (exists) deleteSongLike.run(userId, songId);
else insertSongLike.run(userId, songId);
return { liked: !exists, count: songLikeCountStmt.get(songId).c };
}
function toggleSongFavorite(userId, songId) {
const exists = songUserFavoritedStmt.get(userId, songId);
if (exists) deleteSongFav.run(userId, songId);
else insertSongFav.run(userId, songId);
return { favorited: !exists, count: songFavCountStmt.get(songId).c };
}
function togglePlaylistLike(userId, playlistId) {
const exists = plUserLikedStmt.get(userId, playlistId);
if (exists) deletePlLike.run(userId, playlistId);
else insertPlLike.run(userId, playlistId);
return { liked: !exists, count: plLikeCountStmt.get(playlistId).c };
}
function togglePlaylistFavorite(userId, playlistId) {
const exists = plUserFavoritedStmt.get(userId, playlistId);
if (exists) deletePlFav.run(userId, playlistId);
else insertPlFav.run(userId, playlistId);
return { favorited: !exists, count: plFavCountStmt.get(playlistId).c };
}
// ── Enrichment helpers ───────────────────────────────────────────────────────
// Mutates each item in-place, adding likeCount/favoriteCount/userLiked/userFavorited.
function enrichSongs(songs, userId = null) {
for (const s of songs) {
s.likeCount = songLikeCountStmt.get(s.id).c;
s.favoriteCount = songFavCountStmt.get(s.id).c;
s.userLiked = userId ? !!songUserLikedStmt.get(userId, s.id) : false;
s.userFavorited = userId ? !!songUserFavoritedStmt.get(userId, s.id) : false;
}
return songs;
}
function enrichPlaylists(playlists, userId = null) {
for (const p of playlists) {
p.likeCount = plLikeCountStmt.get(p.id).c;
p.favoriteCount = plFavCountStmt.get(p.id).c;
p.userLiked = userId ? !!plUserLikedStmt.get(userId, p.id) : false;
p.userFavorited = userId ? !!plUserFavoritedStmt.get(userId, p.id) : false;
}
return playlists;
}
module.exports = {
toggleSongLike, toggleSongFavorite,
togglePlaylistLike, togglePlaylistFavorite,
enrichSongs, enrichPlaylists,
};

View File

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf-token" content="<%= csrfToken %>">
<title><%= typeof title !== 'undefined' ? title : siteName %></title>
<link rel="preload" href="/static/vendor/fonts/Orbitron.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/vendor/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
@ -34,6 +35,7 @@
<script src="/static/js/navigate.js" defer></script>
<script src="/static/js/player.js" defer></script>
<script src="/static/js/social.js" defer></script>
<script type="module" src="/static/js/bg-grid.js"></script>
<script type="module" src="/static/js/player-viz.js"></script>
<script type="module" src="/static/js/boot-sequence.js"></script>

View File

@ -10,6 +10,16 @@
<audio id="player-audio" preload="metadata" controls crossorigin="anonymous"></audio>
<div class="player-queue">
<span id="player-queue-info" class="muted"></span>
<button id="player-like" type="button"
class="btn-social" title="Like"
data-social-action="like" data-social-type="song" data-social-id=""
<% if (!user) { %>data-require-login<% } %>
aria-pressed="false" disabled>👍 <span class="social-count">0</span></button>
<button id="player-favorite" type="button"
class="btn-social" title="Favorite"
data-social-action="favorite" data-social-type="song" data-social-id=""
<% if (!user) { %>data-require-login<% } %>
aria-pressed="false" disabled>⭐ <span class="social-count">0</span></button>
<button id="player-repeat" type="button" title="Repeat" aria-pressed="false">🔁</button>
<button id="player-next" type="button" title="Next">⏭</button>
</div>

View File

@ -16,8 +16,22 @@
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
</div>
<span class="eq-bars" aria-hidden="true"><span></span><span></span><span></span></span>
<div class="song-social">
<button type="button"
class="btn-social<%= s.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= s.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= s.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= s.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= s.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= s.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= s.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= s.favoriteCount || 0 %></span></button>
</div>
<button type="button" class="btn-play"
data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null }) %>">
data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null, userLiked: !!s.userLiked, userFavorited: !!s.userFavorited, likeCount: s.likeCount || 0, favoriteCount: s.favoriteCount || 0 }) %>">
▶ Play
</button>
</li>

View File

@ -23,6 +23,20 @@
</div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
<div class="card-social">
<button type="button"
class="btn-social<%= p.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
</div>
</div>
</a>
<% }) %>

View File

@ -9,13 +9,27 @@
<% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %>
<% 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">
<button type="button"
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= playlist.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= playlist.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
</div>
<button type="button" class="btn-primary"
id="play-playlist-btn"
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>">
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
▶ Play all
</button>
<button type="button" class="btn-primary"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>">
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all
</button>
<a href="/playlists" class="btn-link">← Back</a>

View File

@ -17,6 +17,20 @@
</div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
<div class="card-social">
<button type="button"
class="btn-social<%= p.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
</div>
</div>
</a>
<% }) %>

View File

@ -11,13 +11,27 @@
</h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
<% if (song.uploadedByName) { %><p class="card-creator">Added by: <%= song.uploadedByName %></p><% } %>
<div class="social-row">
<button type="button"
class="btn-social<%= song.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= song.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= song.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= song.favoriteCount || 0 %></span></button>
</div>
<p class="muted small">
<% if (song.genre) { %><%= song.genre %> · <% } %>
<% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p>
<button type="button" class="btn-play btn-primary"
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null }) %>">
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>">
▶ Play
</button>
<a href="/songs" class="btn-link">← Back to songs</a>