feat(admin): support auto-populating song metadata from embedded audio tags on upload
- Introduce `songUploadSchema` for flexible uploads where title/artist are optional - Add `extractAudioMetadata()` to parse ID3/Vorbis tags and embedded cover art from uploaded files - Merge form values with extracted metadata, falling back to filename-derived title if needed - Automatically write embedded cover art to `media/covers/` when no manual cover is uploaded - Update song form UI to indicate optional fields and tag-based fallback behavior - Switch JSON rendering in partials from `<%-` (unescaped) to `<%=`, improving safety
This commit is contained in:
parent
ae46232851
commit
ed13c2821c
|
|
@ -38,7 +38,18 @@ const upload = multer({
|
||||||
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
|
const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]);
|
||||||
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
|
const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]);
|
||||||
|
|
||||||
const songSchema = z.object({
|
// For uploads, all text fields are optional — we'll fill blanks from ID3 tags.
|
||||||
|
// The edit form enforces title/artist via a stricter schema below.
|
||||||
|
const songUploadSchema = z.object({
|
||||||
|
title: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
|
artist: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
|
album: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
|
genre: z.string().trim().max(80).optional().or(z.literal('')),
|
||||||
|
year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()),
|
||||||
|
is_public: z.any().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const songEditSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
artist: z.string().trim().min(1).max(200),
|
artist: z.string().trim().min(1).max(200),
|
||||||
album: z.string().trim().max(200).optional().or(z.literal('')),
|
album: z.string().trim().max(200).optional().or(z.literal('')),
|
||||||
|
|
@ -53,15 +64,61 @@ const playlistSchema = z.object({
|
||||||
is_public: z.any().optional(),
|
is_public: z.any().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function extractDuration(absPath) {
|
// Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file.
|
||||||
|
// Returns a plain object; every field may be null if not present in the file.
|
||||||
|
async function extractAudioMetadata(absPath) {
|
||||||
|
const result = {
|
||||||
|
duration: null, title: null, artist: null, album: null,
|
||||||
|
genre: null, year: null, picture: null,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const { parseFile } = await import('music-metadata');
|
const { parseFile } = await import('music-metadata');
|
||||||
const meta = await parseFile(absPath);
|
const meta = await parseFile(absPath);
|
||||||
if (meta.format && meta.format.duration) return Math.round(meta.format.duration);
|
if (meta.format?.duration) result.duration = Math.round(meta.format.duration);
|
||||||
|
const c = meta.common || {};
|
||||||
|
if (c.title) result.title = String(c.title).trim() || null;
|
||||||
|
if (c.artist) result.artist = String(c.artist).trim() || null;
|
||||||
|
else if (Array.isArray(c.artists) && c.artists.length) result.artist = String(c.artists[0]).trim() || null;
|
||||||
|
if (c.album) result.album = String(c.album).trim() || null;
|
||||||
|
if (Array.isArray(c.genre) && c.genre.length) result.genre = String(c.genre[0]).trim() || null;
|
||||||
|
if (c.year && Number.isFinite(Number(c.year))) result.year = Number(c.year);
|
||||||
|
if (Array.isArray(c.picture) && c.picture.length) {
|
||||||
|
const pic = c.picture[0];
|
||||||
|
if (pic && pic.data && pic.format) {
|
||||||
|
result.picture = { data: pic.data, mime: String(pic.format) };
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[admin] could not parse audio metadata:', err.message);
|
console.warn('[admin] could not parse audio metadata:', err.message);
|
||||||
}
|
}
|
||||||
return null;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn an image MIME type into a reasonable file extension.
|
||||||
|
function extForMime(mime) {
|
||||||
|
switch ((mime || '').toLowerCase()) {
|
||||||
|
case 'image/jpeg':
|
||||||
|
case 'image/jpg': return '.jpg';
|
||||||
|
case 'image/png': return '.png';
|
||||||
|
case 'image/webp': return '.webp';
|
||||||
|
case 'image/gif': return '.gif';
|
||||||
|
default: return '.jpg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write an embedded cover picture to media/covers/ and return its relative path.
|
||||||
|
function writeEmbeddedCover(picture) {
|
||||||
|
const ext = extForMime(picture.mime);
|
||||||
|
const filename = `${crypto.randomUUID()}${ext}`;
|
||||||
|
const abs = path.join(mediaDir, 'covers', filename);
|
||||||
|
fs.writeFileSync(abs, picture.data);
|
||||||
|
return path.posix.join('covers', filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive a fallback title from the uploaded filename (strip extension, tidy up).
|
||||||
|
function titleFromFilename(originalName) {
|
||||||
|
const base = path.basename(originalName, path.extname(originalName));
|
||||||
|
return base.replace(/[._-]+/g, ' ').trim() || 'Untitled';
|
||||||
}
|
}
|
||||||
|
|
||||||
function relMedia(file, sub) {
|
function relMedia(file, sub) {
|
||||||
|
|
@ -89,33 +146,55 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
|
|
||||||
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
|
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const parsed = songSchema.safeParse(req.body);
|
const parsed = songUploadSchema.safeParse(req.body);
|
||||||
if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) {
|
if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
||||||
if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.';
|
if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.';
|
||||||
// Clean up any uploaded files on failure.
|
|
||||||
if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path);
|
if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path);
|
||||||
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
||||||
return res.status(400).render('admin/song_form', { title: 'New song', song: null, errors, values: req.body });
|
return res.status(400).render('admin/song_form', { title: 'New song', song: null, errors, values: req.body });
|
||||||
}
|
}
|
||||||
const audioFile = req.files.audio[0];
|
const audioFile = req.files.audio[0];
|
||||||
const coverFile = req.files.cover?.[0];
|
const coverFile = req.files.cover?.[0];
|
||||||
const duration = await extractDuration(audioFile.path);
|
|
||||||
|
// Read embedded tags. Form values win; tags fill any blanks.
|
||||||
|
const tags = await extractAudioMetadata(audioFile.path);
|
||||||
|
const pick = (formVal, tagVal) => {
|
||||||
|
const s = (formVal == null ? '' : String(formVal)).trim();
|
||||||
|
return s || tagVal || null;
|
||||||
|
};
|
||||||
|
const merged = {
|
||||||
|
title: pick(parsed.data.title, tags.title) || titleFromFilename(audioFile.originalname),
|
||||||
|
artist: pick(parsed.data.artist, tags.artist) || 'Unknown Artist',
|
||||||
|
album: pick(parsed.data.album, tags.album),
|
||||||
|
genre: pick(parsed.data.genre, tags.genre),
|
||||||
|
year: parsed.data.year ?? tags.year ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the admin didn't upload a cover but the file has embedded art, use it.
|
||||||
|
let coverPath = coverFile ? relMedia(coverFile, 'covers') : null;
|
||||||
|
if (!coverPath && tags.picture) {
|
||||||
|
try {
|
||||||
|
coverPath = writeEmbeddedCover(tags.picture);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[admin] failed to write embedded cover:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
songs.create({
|
songs.create({
|
||||||
title: parsed.data.title,
|
title: merged.title,
|
||||||
artist: parsed.data.artist,
|
artist: merged.artist,
|
||||||
album: parsed.data.album || null,
|
album: merged.album,
|
||||||
genre: parsed.data.genre || null,
|
genre: merged.genre,
|
||||||
year: parsed.data.year ?? null,
|
year: merged.year,
|
||||||
durationSeconds: duration,
|
durationSeconds: tags.duration,
|
||||||
audioPath: relMedia(audioFile, 'audio'),
|
audioPath: relMedia(audioFile, 'audio'),
|
||||||
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
|
coverPath,
|
||||||
isPublic: !!parsed.data.is_public,
|
isPublic: !!parsed.data.is_public,
|
||||||
uploadedBy: req.session.user.id,
|
uploadedBy: req.session.user.id,
|
||||||
});
|
});
|
||||||
req.flash('success', 'Song uploaded.');
|
req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.');
|
||||||
res.redirect('/admin/songs');
|
res.redirect('/admin/songs');
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -134,7 +213,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
const existing = songs.findById(id);
|
const existing = songs.findById(id);
|
||||||
if (!existing) return next();
|
if (!existing) return next();
|
||||||
const parsed = songSchema.safeParse(req.body);
|
const parsed = songEditSchema.safeParse(req.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
<h1><%= song ? 'Edit song' : 'Upload song' %></h1>
|
<h1><%= song ? 'Edit song' : 'Upload song' %></h1>
|
||||||
|
|
||||||
|
<% if (!song) { %>
|
||||||
|
<p class="muted small">Tip: leave the fields below blank and they'll be auto-filled from the file's embedded tags (ID3, Vorbis comments, etc.). You can edit them afterwards.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<form method="post"
|
<form method="post"
|
||||||
action="<%= song ? '/admin/songs/' + song.id : '/admin/songs' %>"
|
action="<%= song ? '/admin/songs/' + song.id : '/admin/songs' %>"
|
||||||
enctype="multipart/form-data" class="stacked form-card">
|
<% if (!song) { %>enctype="multipart/form-data"<% } %>
|
||||||
|
class="stacked form-card">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Title
|
Title<% if (!song) { %> <span class="muted small">(optional — read from tags)</span><% } %>
|
||||||
<input type="text" name="title" value="<%= values.title || '' %>" required>
|
<input type="text" name="title" value="<%= values.title || '' %>" <%= song ? 'required' : '' %>>
|
||||||
<% if (errors.title) { %><span class="err"><%= errors.title %></span><% } %>
|
<% if (errors.title) { %><span class="err"><%= errors.title %></span><% } %>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Artist
|
Artist<% if (!song) { %> <span class="muted small">(optional — read from tags)</span><% } %>
|
||||||
<input type="text" name="artist" value="<%= values.artist || '' %>" required>
|
<input type="text" name="artist" value="<%= values.artist || '' %>" <%= song ? 'required' : '' %>>
|
||||||
<% if (errors.artist) { %><span class="err"><%= errors.artist %></span><% } %>
|
<% if (errors.artist) { %><span class="err"><%= errors.artist %></span><% } %>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|
@ -37,7 +42,7 @@
|
||||||
<% if (errors.audio) { %><span class="err"><%= errors.audio %></span><% } %>
|
<% if (errors.audio) { %><span class="err"><%= errors.audio %></span><% } %>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Cover art (optional)
|
Cover art <span class="muted small">(optional — embedded artwork will be used if present)</span>
|
||||||
<input type="file" name="cover" accept="image/*">
|
<input type="file" name="cover" accept="image/*">
|
||||||
</label>
|
</label>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container header-inner">
|
<div class="container header-inner">
|
||||||
<a class="brand" href="/">🎵 Tunes</a>
|
<a class="brand" href="/">🎵 Bri-Tunes</a>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
||||||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
|
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn-play"
|
<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 }) %>">
|
||||||
▶ Play
|
▶ Play
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
|
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
|
||||||
<button type="button" class="btn-primary"
|
<button type="button" class="btn-primary"
|
||||||
id="play-playlist-btn"
|
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 }))) %>">
|
||||||
▶ Play all
|
▶ Play all
|
||||||
</button>
|
</button>
|
||||||
<a href="/playlists" class="btn-link">← Back</a>
|
<a href="/playlists" class="btn-link">← Back</a>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
|
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
|
||||||
</p>
|
</p>
|
||||||
<button type="button" class="btn-play btn-primary"
|
<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 }) %>">
|
||||||
▶ Play
|
▶ Play
|
||||||
</button>
|
</button>
|
||||||
<a href="/songs" class="btn-link">← Back to songs</a>
|
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue