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) => {
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 { %>
|
||||||
|
|
|
||||||
|
|
@ -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 { %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue