feat: add playlist top genres and similar playlists

- Add `top_genres` column to playlists table to store the top 3 genres
- Implement `computeTopGenres`, `updateTopGenres`, and `reindexAllGenres` in playlists service
- Add "Similar Playlists" section to public playlist view based on genre overlap
- Display genre tags on playlist details page
- Add admin panel route to re-index all playlist genres
- Update social tooltip positioning to use fixed positioning for better visibility
- Add CSS styles for genre tags and track numbers
- Increment service worker cache version
This commit is contained in:
Brian Fertig 2026-04-19 12:10:39 -06:00
parent b0d9c45265
commit cb2ff2492f
11 changed files with 160 additions and 15 deletions

View File

@ -0,0 +1 @@
ALTER TABLE playlists ADD COLUMN top_genres TEXT;

View File

@ -395,6 +395,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.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; } .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; }
.genre-tag { display: inline-block; font-size: 0.68rem; padding: 1px 7px; border-radius: 20px; background: rgba(143,91,255,0.12); border: 1px solid rgba(143,91,255,0.4); color: var(--muted); letter-spacing: 0.03em; }
/* ---------- Visibility radio fieldset ---------- */ /* ---------- Visibility radio fieldset ---------- */
.visibility-field { .visibility-field {
@ -423,6 +424,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
transition: background .15s; transition: background .15s;
} }
.song-row:hover { background: rgba(143, 91, 255, 0.08); } .song-row:hover { background: rgba(143, 91, 255, 0.08); }
.track-num { min-width: 1.6rem; text-align: right; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-shrink: 0; }
.song-cover { width: 44px; height: 44px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border); } .song-cover { width: 44px; height: 44px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border); }
.song-cover.cover-fallback { font-size: 1.25rem; } .song-cover.cover-fallback { font-size: 1.25rem; }
.song-main { flex: 1; min-width: 0; } .song-main { flex: 1; min-width: 0; }
@ -777,17 +779,15 @@ a.detail-creator-card:hover {
/* ---------- Social hover tooltip ---------- */ /* ---------- Social hover tooltip ---------- */
.social-tooltip { .social-tooltip {
position: absolute; position: fixed;
z-index: 800; z-index: 800;
top: calc(100% + 6px);
left: 0;
min-width: 120px; min-width: 120px;
max-width: 220px; max-width: 220px;
padding: 0.45rem 0.65rem; padding: 0.45rem 0.65rem;
background: var(--card-bg); background: #0c0e1a;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.5); box-shadow: 0 4px 20px rgba(0,0,0,0.85);
font-size: 0.72rem; font-size: 0.72rem;
color: var(--muted); color: var(--muted);
white-space: nowrap; white-space: nowrap;
@ -799,7 +799,6 @@ a.detail-creator-card:hover {
.social-tooltip strong { display: block; color: var(--fg); margin-bottom: 0.2rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; } .social-tooltip strong { display: block; color: var(--fg); margin-bottom: 0.2rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
.social-tooltip ul { margin: 0; padding: 0; list-style: none; } .social-tooltip ul { margin: 0; padding: 0; list-style: none; }
.social-tooltip ul li { overflow: hidden; text-overflow: ellipsis; } .social-tooltip ul li { overflow: hidden; text-overflow: ellipsis; }
.btn-social { position: relative; }
/* ---------- Lightbox ---------- */ /* ---------- Lightbox ---------- */
.lb-trigger { .lb-trigger {

View File

@ -13,7 +13,7 @@
function showTooltip(btn, data) { function showTooltip(btn, data) {
removeTooltip(); removeTooltip();
const action = btn.dataset.socialAction; // 'like' or 'favorite' const action = btn.dataset.socialAction;
const names = action === 'like' ? data.likers : data.favoriters; const names = action === 'like' ? data.likers : data.favoriters;
if (!names || names.length === 0) return; if (!names || names.length === 0) return;
@ -22,8 +22,16 @@
const label = action === 'like' ? 'Liked by' : 'Favorited by'; const label = action === 'like' ? 'Liked by' : 'Favorited by';
const ul = names.map(n => `<li>${n.replace(/</g, '&lt;')}</li>`).join(''); const ul = names.map(n => `<li>${n.replace(/</g, '&lt;')}</li>`).join('');
tip.innerHTML = `<strong>${label}</strong><ul>${ul}</ul>`; tip.innerHTML = `<strong>${label}</strong><ul>${ul}</ul>`;
btn.appendChild(tip);
// Attach to body so overflow:hidden on ancestor cards can't clip it.
document.body.appendChild(tip);
activeTooltip = tip; activeTooltip = tip;
const rect = btn.getBoundingClientRect();
tip.style.position = 'fixed';
tip.style.top = (rect.bottom + 6) + 'px';
tip.style.left = rect.left + 'px';
requestAnimationFrame(() => tip.classList.add('visible')); requestAnimationFrame(() => tip.classList.add('visible'));
} }

View File

@ -1,4 +1,4 @@
const CACHE = 'bri-tunes-v6'; const CACHE = 'bri-tunes-v7';
const STATIC_PRECACHE = [ const STATIC_PRECACHE = [
'/', '/',
'/static/css/app.css', '/static/css/app.css',

View File

@ -6,6 +6,7 @@ const multer = require('multer');
const { requireAdmin } = require('../middleware/auth'); const { requireAdmin } = require('../middleware/auth');
const users = require('../services/users'); const users = require('../services/users');
const settings = require('../services/settings'); const settings = require('../services/settings');
const playlists = require('../services/playlists');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -195,5 +196,13 @@ module.exports = function adminPanelRoutes(csrfProtection) {
}); });
}); });
router.post('/reindex-playlist-genres', csrfProtection, (req, res, next) => {
try {
const count = playlists.reindexAllGenres();
req.flash('success', `Re-indexed genres for ${count} playlist${count === 1 ? '' : 's'}.`);
res.redirect('/admin#playlists');
} catch (err) { next(err); }
});
return router; return router;
}; };

View File

@ -421,6 +421,7 @@ module.exports = function adminRoutes(csrfProtection) {
playlists.setVisibility(created.id, song.visibility); playlists.setVisibility(created.id, song.visibility);
} }
} }
playlists.updateTopGenres(created.id);
req.flash('success', 'Playlist created.'); req.flash('success', 'Playlist created.');
res.redirect(`/mymusic/playlists/${created.id}/edit`); res.redirect(`/mymusic/playlists/${created.id}/edit`);
} catch (err) { next(err); } } catch (err) { next(err); }
@ -482,6 +483,7 @@ module.exports = function adminRoutes(csrfProtection) {
console.warn('[admin] failed to process playlist cover on edit:', err.message); console.warn('[admin] failed to process playlist cover on edit:', err.message);
} }
} }
playlists.updateTopGenres(id);
req.flash('success', 'Playlist updated.'); req.flash('success', 'Playlist updated.');
res.redirect(`/mymusic/playlists/${id}/edit`); res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); } } catch (err) { next(err); }
@ -500,6 +502,7 @@ module.exports = function adminRoutes(csrfProtection) {
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) { if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
playlists.setVisibility(id, song.visibility); playlists.setVisibility(id, song.visibility);
} }
playlists.updateTopGenres(id);
res.redirect(`/mymusic/playlists/${id}/edit`); res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -512,6 +515,7 @@ module.exports = function adminRoutes(csrfProtection) {
if (!playlist) return next(); if (!playlist) return next();
if (!canManage(playlist, req.session.user)) return forbidden(next); if (!canManage(playlist, req.session.user)) return forbidden(next);
playlists.removeTrack(id, songId); playlists.removeTrack(id, songId);
playlists.updateTopGenres(id);
res.redirect(`/mymusic/playlists/${id}/edit`); res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -525,6 +529,7 @@ module.exports = function adminRoutes(csrfProtection) {
const order = String(req.body.order || '') const order = String(req.body.order || '')
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite); .split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
playlists.setOrder(id, order); playlists.setOrder(id, order);
playlists.updateTopGenres(id);
res.redirect(`/mymusic/playlists/${id}/edit`); res.redirect(`/mymusic/playlists/${id}/edit`);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@ -129,13 +129,15 @@ router.get('/playlists/:slug', (req, res, next) => {
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip) && (!t.isNsfw || !!userId)); 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 similar = playlists.getSimilar(playlist.id, !!userId, isVip);
social.enrichPlaylists(similar, userId);
const og = { const og = {
title: `${res.locals.siteName} Playlist: ${playlist.title}`, title: `${res.locals.siteName} Playlist: ${playlist.title}`,
description: playlist.description || null, description: playlist.description || null,
image: playlist.coverPath ? `${res.locals.appBaseUrl}/media/${playlist.coverPath}` : null, image: playlist.coverPath ? `${res.locals.appBaseUrl}/media/${playlist.coverPath}` : null,
url: `${res.locals.appBaseUrl}/playlists/${playlist.slug}`, url: `${res.locals.appBaseUrl}/playlists/${playlist.slug}`,
}; };
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator, og }); res.render('public/playlist', { title: playlist.title, playlist, tracks, creator, similar, og });
}); });
router.get('/profiles', (req, res) => { router.get('/profiles', (req, res) => {

View File

@ -10,6 +10,7 @@ const updateStmt = db.prepare(`
`); `);
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?'); const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
const setVisibilityStmt = db.prepare('UPDATE playlists SET visibility = ? WHERE id = ?'); const setVisibilityStmt = db.prepare('UPDATE playlists SET visibility = ? WHERE id = ?');
const setTopGenresStmt = db.prepare('UPDATE playlists SET top_genres = ? WHERE id = ?');
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?'); const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?'); const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
@ -115,6 +116,13 @@ const effectiveVisibilityStmt = db.prepare(`
FROM playlists p WHERE p.id = ? FROM playlists p WHERE p.id = ?
`); `);
const trackGenresStmt = db.prepare(`
SELECT s.genre FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = ? AND s.genre IS NOT NULL AND s.genre != ''
`);
const allPlaylistIdsStmt = db.prepare('SELECT id FROM playlists');
const tracksStmt = db.prepare(` const tracksStmt = db.prepare(`
SELECT s.*, ps.position FROM playlist_songs ps SELECT s.*, ps.position FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id JOIN songs s ON s.id = ps.song_id
@ -165,6 +173,8 @@ function findBySlug(slug) {
function toView(row) { function toView(row) {
if (!row) return null; if (!row) return null;
let topGenres = [];
try { if (row.top_genres) topGenres = JSON.parse(row.top_genres); } catch (_) {}
return { return {
id: row.id, id: row.id,
slug: row.slug, slug: row.slug,
@ -176,9 +186,34 @@ function toView(row) {
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,
topGenres,
}; };
} }
function computeTopGenres(playlistId) {
const rows = trackGenresStmt.all(playlistId);
const counts = {};
for (const row of rows) {
const key = row.genre.trim().toLowerCase();
if (key) counts[key] = (counts[key] || 0) + 1;
}
return Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([g]) => g.charAt(0).toUpperCase() + g.slice(1));
}
function updateTopGenres(playlistId) {
const genres = computeTopGenres(playlistId);
setTopGenresStmt.run(genres.length ? JSON.stringify(genres) : null, playlistId);
}
function reindexAllGenres() {
const ids = allPlaylistIdsStmt.all();
for (const { id } of ids) updateTopGenres(id);
return ids.length;
}
function trackView(row) { function trackView(row) {
return { return {
id: row.id, id: row.id,
@ -327,8 +362,31 @@ function getPlaylistsForSong(songId, loggedIn = false, isVip = false) {
return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView); return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
} }
function getSimilar(playlistId, loggedIn = false, isVip = false, limit = 5) {
const source = toView(findByIdStmt.get(playlistId));
if (!source || !source.topGenres.length) return [];
const sourceGenres = source.topGenres.map(g => g.toLowerCase());
const candidates = (isVip ? listVipStmt : loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
const scored = candidates
.filter(p => p.id !== playlistId && p.topGenres.length > 0)
.map(p => {
const pGenres = p.topGenres.map(g => g.toLowerCase());
const score = sourceGenres.filter(g => pGenres.includes(g)).length;
return { playlist: p, score };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map(({ playlist }) => playlist);
return scored;
}
module.exports = { module.exports = {
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility, create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
listVisible, listAll, listByUser, listPublicByUser, featured, listVisible, listAll, listByUser, listPublicByUser, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong, getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong,
updateTopGenres, reindexAllGenres, getSimilar,
}; };

View File

@ -65,6 +65,19 @@
</tbody> </tbody>
</table> </table>
<!-- ── Playlists ───────────────────────────────────────────────────────── -->
<h2 id="playlists" style="margin-top:2.5rem">Playlists</h2>
<div class="form-card" style="padding:1.25rem 1.5rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<div>
<strong>Re-index playlist genres</strong>
<p class="muted small" style="margin:0.25rem 0 0">Scans every playlist's tracks and recomputes the top 3 genres used for Similar Playlists matching.</p>
</div>
<form method="post" action="/admin/reindex-playlist-genres" style="margin:0;flex-shrink:0">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="btn-secondary">Re-index Now</button>
</form>
</div>
<!-- ── Song Generation Details ────────────────────────────────────────── --> <!-- ── Song Generation Details ────────────────────────────────────────── -->
<h2 id="generation" style="margin-top:2.5rem">Song Generation Details</h2> <h2 id="generation" style="margin-top:2.5rem">Song Generation Details</h2>

View File

@ -5,8 +5,9 @@
</div> </div>
<% } %> <% } %>
<ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>"> <ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>">
<% songs.forEach((s) => { %> <% songs.forEach((s, i) => { %>
<li class="song-row" data-song-id="<%= s.id %>"> <li class="song-row" data-song-id="<%= s.id %>">
<% if (typeof showNumbers !== 'undefined' && showNumbers) { %><span class="track-num"><%= i + 1 %></span><% } %>
<% if (typeof selectable !== 'undefined' && selectable) { %> <% if (typeof selectable !== 'undefined' && selectable) { %>
<input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>"> <input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>">
<% } %> <% } %>

View File

@ -10,7 +10,12 @@
<% if (playlist.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %> <% if (playlist.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
</h1> </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' %>
<% if (playlist.topGenres && playlist.topGenres.length) { %>
&nbsp;·&nbsp;<% playlist.topGenres.forEach((g, i) => { %><span class="genre-tag"><%= g %></span><% if (i < playlist.topGenres.length - 1) { %> <% } %><% }) %>
<% } %>
</p>
<div class="social-row"> <div class="social-row">
<button type="button" <button type="button"
class="btn-social<%= playlist.userLiked ? ' active' : '' %>" class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
@ -171,5 +176,49 @@
</div> </div>
<% if (tracks.length > 0) { %> <% if (tracks.length > 0) { %>
<%- include('../partials/song_list', { songs: tracks }) %> <%- include('../partials/song_list', { songs: tracks, showNumbers: true }) %>
<% } %>
<% if (similar && similar.length > 0) { %>
<section style="margin-top:2.5rem">
<h2 style="margin-bottom:1rem">Similar Playlists</h2>
<div class="grid">
<% similar.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
<% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %>
<div class="cover-fallback">🎧</div>
<% } %>
<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.topGenres && p.topGenres.length) { %>
<div style="margin:0.25rem 0 0.1rem"><% p.topGenres.forEach((g) => { %><span class="genre-tag"><%= g %></span> <% }) %></div>
<% } %>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<div class="card-footer">
<% 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' %>"
>👍 <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' %>"
>⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
</div>
</div>
</div>
</a>
<% }) %>
</div>
</section>
<% } %> <% } %>