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:
parent
08cc6b2b03
commit
5394e0cef4
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE playlists ADD COLUMN slug TEXT;
|
||||
CREATE UNIQUE INDEX playlists_slug_unique ON playlists (slug);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE songs ADD COLUMN slug TEXT;
|
||||
CREATE UNIQUE INDEX songs_slug_unique ON songs (slug);
|
||||
|
|
@ -64,8 +64,8 @@ router.get('/songs', (req, res) => {
|
|||
});
|
||||
});
|
||||
|
||||
router.get('/songs/:id', (req, res, next) => {
|
||||
const song = songs.findById(Number(req.params.id));
|
||||
router.get('/songs/:slug', (req, res, next) => {
|
||||
const song = songs.findBySlug(req.params.slug);
|
||||
if (!song) return next();
|
||||
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
|
||||
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) });
|
||||
});
|
||||
|
||||
router.get('/playlists/:id', (req, res, next) => {
|
||||
const id = Number(req.params.id);
|
||||
const playlist = playlists.findById(id);
|
||||
router.get('/playlists/:slug', (req, res, next) => {
|
||||
const playlist = playlists.findBySlug(req.params.slug);
|
||||
if (!playlist) return next();
|
||||
const userId = req.session.user?.id;
|
||||
// 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)) {
|
||||
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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 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) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
coverPath: row.cover_path,
|
||||
|
|
@ -123,6 +159,8 @@ function create(data) {
|
|||
visibility: data.visibility || 'logged_in',
|
||||
created_by: data.createdBy || null,
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, info.lastInsertRowid);
|
||||
updateSlugStmt.run(slug, info.lastInsertRowid);
|
||||
return toView(findByIdStmt.get(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +171,8 @@ function update(id, data) {
|
|||
description: data.description || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, id);
|
||||
updateSlugStmt.run(slug, id);
|
||||
return toView(findByIdStmt.get(id));
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +239,7 @@ function setOrder(playlistId, songIds) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
create, update, setCover, setVisibility, remove, findById, getEffectiveVisibility,
|
||||
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
||||
listVisible, listAll, listByUser, featured,
|
||||
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 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 = ?');
|
||||
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) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
artist: row.artist,
|
||||
album: row.album,
|
||||
|
|
@ -68,6 +110,8 @@ function create(data) {
|
|||
visibility: data.visibility || 'logged_in',
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +125,8 @@ function update(id, data) {
|
|||
year: data.year || null,
|
||||
visibility: data.visibility || 'logged_in',
|
||||
});
|
||||
const slug = generateUniqueSlug(data.title, data.artist, id);
|
||||
updateSlugStmt.run(slug, id);
|
||||
return toView(findByIdStmt.get(id));
|
||||
}
|
||||
|
||||
|
|
@ -129,4 +175,4 @@ function recent(n = 8, loggedIn = false) {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<tbody>
|
||||
<% playlists.forEach((p) => { %>
|
||||
<tr>
|
||||
<td><a href="/playlists/<%= p.id %>"><%= p.title %></a></td>
|
||||
<td><a href="/playlists/<%= p.slug %>"><%= p.title %></a></td>
|
||||
<td>
|
||||
<% if (p.visibility === 'logged_in') { %>
|
||||
<span class="badge badge-logged-in">Members</span>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
<tbody>
|
||||
<% songs.forEach((s) => { %>
|
||||
<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.album || '' %></td>
|
||||
<td>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<div class="song-cover cover-fallback">♪</div>
|
||||
<% } %>
|
||||
<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 === 'private') { %><span class="badge badge-private">Private</span><% } %>
|
||||
<div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% featured.forEach((p) => { %>
|
||||
<a class="card" href="/playlists/<%= p.id %>">
|
||||
<a class="card" href="/playlists/<%= p.slug %>">
|
||||
<% if (p.coverPath) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<% } else { %>
|
||||
<div class="grid">
|
||||
<% playlists.forEach((p) => { %>
|
||||
<a class="card" href="/playlists/<%= p.id %>">
|
||||
<a class="card" href="/playlists/<%= p.slug %>">
|
||||
<% if (p.coverPath) { %>
|
||||
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||
<% } else { %>
|
||||
|
|
|
|||
Loading…
Reference in New Issue