feat: replace ID-based URLs with slugs for songs and playlists

- Add `slug` column to `songs` and `playlists` tables via migrations 005 & 006
- Implement slug generation logic (lowercase, hyphenated, unique) in both services
- Backfill existing rows with auto-generated slugs on startup
- Update public routes to use `/songs/:slug` and `/playlists/:slug`
- Replace all view templates to link using slugs instead of numeric IDs
This commit is contained in:
Brian Fertig 2026-04-11 08:14:27 -06:00
parent 08cc6b2b03
commit 5394e0cef4
10 changed files with 103 additions and 14 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE playlists ADD COLUMN slug TEXT;
CREATE UNIQUE INDEX playlists_slug_unique ON playlists (slug);

View File

@ -0,0 +1,2 @@
ALTER TABLE songs ADD COLUMN slug TEXT;
CREATE UNIQUE INDEX songs_slug_unique ON songs (slug);

View File

@ -64,8 +64,8 @@ router.get('/songs', (req, res) => {
}); });
}); });
router.get('/songs/:id', (req, res, next) => { router.get('/songs/:slug', (req, res, next) => {
const song = songs.findById(Number(req.params.id)); const song = songs.findBySlug(req.params.slug);
if (!song) return next(); if (!song) return next();
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) { if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
return denyAccess(req, res, next, song.visibility); return denyAccess(req, res, next, song.visibility);
@ -78,17 +78,16 @@ router.get('/playlists', (req, res) => {
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) }); res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) });
}); });
router.get('/playlists/:id', (req, res, next) => { router.get('/playlists/:slug', (req, res, next) => {
const id = Number(req.params.id); const playlist = playlists.findBySlug(req.params.slug);
const playlist = playlists.findById(id);
if (!playlist) return next(); if (!playlist) return next();
const userId = req.session.user?.id; const userId = req.session.user?.id;
// Use effective visibility (most restrictive of playlist + all its tracks). // Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(id); const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id);
if (!canView(effectiveVisibility, playlist.createdBy, userId)) { if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
return denyAccess(req, res, next, effectiveVisibility); return denyAccess(req, res, next, effectiveVisibility);
} }
const tracks = playlists.getTracks(id).filter(t => canView(t.visibility, t.uploadedBy, userId)); const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId));
res.render('public/playlist', { title: playlist.title, playlist, tracks }); res.render('public/playlist', { title: playlist.title, playlist, tracks });
}); });

View File

@ -86,11 +86,47 @@ const removeTrackStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id
const clearTracksStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ?'); const clearTracksStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ?');
const countStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_songs WHERE playlist_id = ?'); const countStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_songs WHERE playlist_id = ?');
const creatorNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?'); const creatorNameStmt = db.prepare('SELECT display_name FROM users WHERE id = ?');
const findBySlugIdStmt = db.prepare('SELECT id FROM playlists WHERE slug = ?');
const findBySlugExactStmt = db.prepare('SELECT * FROM playlists WHERE slug = ?');
const updateSlugStmt = db.prepare('UPDATE playlists SET slug = ? WHERE id = ?');
function toSlug(title) {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function generateUniqueSlug(title, excludeId = null) {
const base = toSlug(title) || 'playlist';
let candidate = base;
let n = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const row = findBySlugIdStmt.get(candidate);
if (!row || row.id === excludeId) return candidate;
candidate = `${base}-${n++}`;
}
}
// Backfill slugs for any rows missing one (runs once after migration 005).
const _noSlugRows = db.prepare('SELECT id, title FROM playlists WHERE slug IS NULL').all();
for (const row of _noSlugRows) {
updateSlugStmt.run(generateUniqueSlug(row.title, row.id), row.id);
}
function findBySlug(slug) {
return toView(findBySlugExactStmt.get(slug));
}
function toView(row) { function toView(row) {
if (!row) return null; if (!row) return null;
return { return {
id: row.id, id: row.id,
slug: row.slug,
title: row.title, title: row.title,
description: row.description, description: row.description,
coverPath: row.cover_path, coverPath: row.cover_path,
@ -123,6 +159,8 @@ function create(data) {
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
created_by: data.createdBy || null, created_by: data.createdBy || null,
}); });
const slug = generateUniqueSlug(data.title, info.lastInsertRowid);
updateSlugStmt.run(slug, info.lastInsertRowid);
return toView(findByIdStmt.get(info.lastInsertRowid)); return toView(findByIdStmt.get(info.lastInsertRowid));
} }
@ -133,6 +171,8 @@ function update(id, data) {
description: data.description || null, description: data.description || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
}); });
const slug = generateUniqueSlug(data.title, id);
updateSlugStmt.run(slug, id);
return toView(findByIdStmt.get(id)); return toView(findByIdStmt.get(id));
} }
@ -199,7 +239,7 @@ function setOrder(playlistId, songIds) {
} }
module.exports = { module.exports = {
create, update, setCover, setVisibility, remove, findById, getEffectiveVisibility, create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
listVisible, listAll, listByUser, featured, listVisible, listAll, listByUser, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder, getTracks, trackCount, addTrack, removeTrack, setOrder,
}; };

View File

@ -35,11 +35,53 @@ const listForManagementStmt = db.prepare(`
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' 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 findBySlugExactStmt = db.prepare('SELECT * FROM songs WHERE slug = ?');
const updateSlugStmt = db.prepare('UPDATE songs SET slug = ? WHERE id = ?');
function toSlug(text) {
return (text || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function buildSongSlugBase(title, artist) {
const t = toSlug(title) || 'song';
const a = toSlug(artist);
return a ? `${t}-by-${a}` : t;
}
function generateUniqueSlug(title, artist, excludeId = null) {
const base = buildSongSlugBase(title, artist);
let candidate = base;
let n = 2;
// eslint-disable-next-line no-constant-condition
while (true) {
const row = findBySlugIdStmt.get(candidate);
if (!row || row.id === excludeId) return candidate;
candidate = `${base}-${n++}`;
}
}
// Backfill slugs for any rows missing one (runs once after migration 006).
const _noSlugRows = db.prepare('SELECT id, title, artist FROM songs WHERE slug IS NULL').all();
for (const row of _noSlugRows) {
updateSlugStmt.run(generateUniqueSlug(row.title, row.artist, row.id), row.id);
}
function findBySlug(slug) {
return toView(findBySlugExactStmt.get(slug));
}
function toView(row) { function toView(row) {
if (!row) return null; if (!row) return null;
return { return {
id: row.id, id: row.id,
slug: row.slug,
title: row.title, title: row.title,
artist: row.artist, artist: row.artist,
album: row.album, album: row.album,
@ -68,6 +110,8 @@ function create(data) {
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
uploaded_by: data.uploadedBy || null, uploaded_by: data.uploadedBy || null,
}); });
const slug = generateUniqueSlug(data.title, data.artist, info.lastInsertRowid);
updateSlugStmt.run(slug, info.lastInsertRowid);
return toView(findByIdStmt.get(info.lastInsertRowid)); return toView(findByIdStmt.get(info.lastInsertRowid));
} }
@ -81,6 +125,8 @@ function update(id, data) {
year: data.year || null, year: data.year || null,
visibility: data.visibility || 'logged_in', visibility: data.visibility || 'logged_in',
}); });
const slug = generateUniqueSlug(data.title, data.artist, id);
updateSlugStmt.run(slug, id);
return toView(findByIdStmt.get(id)); return toView(findByIdStmt.get(id));
} }
@ -129,4 +175,4 @@ function recent(n = 8, loggedIn = false) {
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView); return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
} }
module.exports = { create, update, setCover, remove, findById, list, listAll, listByUser, listForManagement, recent }; module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listForManagement, recent };

View File

@ -16,7 +16,7 @@
<tbody> <tbody>
<% playlists.forEach((p) => { %> <% playlists.forEach((p) => { %>
<tr> <tr>
<td><a href="/playlists/<%= p.id %>"><%= p.title %></a></td> <td><a href="/playlists/<%= p.slug %>"><%= p.title %></a></td>
<td> <td>
<% if (p.visibility === 'logged_in') { %> <% if (p.visibility === 'logged_in') { %>
<span class="badge badge-logged-in">Members</span> <span class="badge badge-logged-in">Members</span>

View File

@ -16,7 +16,7 @@
<tbody> <tbody>
<% songs.forEach((s) => { %> <% songs.forEach((s) => { %>
<tr> <tr>
<td><a href="/songs/<%= s.id %>"><%= s.title %></a></td> <td><a href="/songs/<%= s.slug %>"><%= s.title %></a></td>
<td><%= s.artist %></td> <td><%= s.artist %></td>
<td><%= s.album || '' %></td> <td><%= s.album || '' %></td>
<td> <td>

View File

@ -7,7 +7,7 @@
<div class="song-cover cover-fallback">♪</div> <div class="song-cover cover-fallback">♪</div>
<% } %> <% } %>
<div class="song-main"> <div class="song-main">
<a class="song-title" href="/songs/<%= s.id %>"><%= s.title %></a> <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 === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
<% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %> <% if (s.visibility === 'private') { %><span class="badge badge-private">Private</span><% } %>
<div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div> <div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div>

View File

@ -10,7 +10,7 @@
<% } else { %> <% } else { %>
<div class="grid"> <div class="grid">
<% featured.forEach((p) => { %> <% featured.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.id %>"> <a class="card" href="/playlists/<%= p.slug %>">
<% if (p.coverPath) { %> <% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" /> <img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %> <% } else { %>

View File

@ -4,7 +4,7 @@
<% } else { %> <% } else { %>
<div class="grid"> <div class="grid">
<% playlists.forEach((p) => { %> <% playlists.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.id %>"> <a class="card" href="/playlists/<%= p.slug %>">
<% if (p.coverPath) { %> <% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" /> <img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %> <% } else { %>