`feat: normalize cover image handling with centralized processing`

- Centralize cover image processing into a new `imageService.processCoverImage()` helper that resizes and crops images to 1000×1000 JPEG.
- Replace ad-hoc embedded-art handling in admin routes (`writeEmbeddedCover`) and publish route (`uploadCover`) with unified `saveCover` function supporting both uploaded files and embedded picture buffers.
- Ensure all cover uploads (songs, playlists, generated songs) go through the new async processing pipeline with proper temp-file cleanup and error handling.
- Update routes to use `async` handlers where cover processing is involved.
This commit is contained in:
Brian Fertig 2026-04-12 18:06:35 -06:00
parent 8b4a28e062
commit 4046bc20e0
3 changed files with 162 additions and 92 deletions

View File

@ -6,8 +6,9 @@ const multer = require('multer');
const { z } = require('zod');
const { requireUser, requireAdmin } = require('../middleware/auth');
const songs = require('../services/songs');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const imageService = require('../services/image');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -109,13 +110,34 @@ function extForMime(mime) {
}
}
// 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);
// Process any cover source (multer file or embedded picture buffer) into a
// 1000×1000 JPEG and save it to media/covers/. Returns the relative path.
// Pass { multerFile } or { pictureBuffer, pictureMime }.
async function saveCover({ multerFile, pictureBuffer, pictureMime } = {}) {
let tempAbs = null;
let ownTemp = false;
if (multerFile) {
tempAbs = multerFile.path; // multer already wrote it to disk
} else if (pictureBuffer) {
// Write raw bytes to a temp file so sharp can read it
const tempExt = extForMime(pictureMime);
tempAbs = path.join(mediaDir, 'covers', `tmp-${crypto.randomUUID()}${tempExt}`);
fs.writeFileSync(tempAbs, pictureBuffer);
ownTemp = true;
} else {
return null;
}
const finalName = `${crypto.randomUUID()}.jpg`;
const finalAbs = path.join(mediaDir, 'covers', finalName);
try {
await imageService.processCoverImage(tempAbs, finalAbs);
} finally {
// Always remove the temp/multer source file
unlinkQuiet(tempAbs);
}
return path.posix.join('covers', finalName);
}
// Derive a fallback title from the uploaded filename (strip extension, tidy up).
@ -219,11 +241,18 @@ module.exports = function adminRoutes(csrfProtection) {
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) {
// If the user didn't upload a cover but the file has embedded art, use it.
let coverPath = null;
if (coverFile) {
try {
coverPath = writeEmbeddedCover(tags.picture);
coverPath = await saveCover({ multerFile: coverFile });
} catch (err) {
console.warn('[admin] failed to process uploaded cover:', err.message);
unlinkQuiet(coverFile.path);
}
} else if (tags.picture) {
try {
coverPath = await saveCover({ pictureBuffer: tags.picture.data, pictureMime: tags.picture.mime });
} catch (err) {
console.warn('[admin] failed to write embedded cover:', err.message);
}
@ -256,7 +285,7 @@ module.exports = function adminRoutes(csrfProtection) {
});
});
router.post('/songs/:id', songFields, csrfProtection, (req, res, next) => {
router.post('/songs/:id', songFields, csrfProtection, async (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = songs.findById(id);
@ -281,7 +310,12 @@ module.exports = function adminRoutes(csrfProtection) {
const coverFile = req.files?.cover?.[0];
if (coverFile) {
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
songs.setCover(id, relMedia(coverFile, 'covers'));
try {
const newCoverPath = await saveCover({ multerFile: coverFile });
songs.setCover(id, newCoverPath);
} catch (err) {
console.warn('[admin] failed to process cover on edit:', err.message);
}
}
req.flash('success', 'Song updated.');
res.redirect('/mymusic/songs');
@ -315,7 +349,7 @@ module.exports = function adminRoutes(csrfProtection) {
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds });
});
router.post('/playlists', playlistFields, csrfProtection, (req, res, next) => {
router.post('/playlists', playlistFields, csrfProtection, async (req, res, next) => {
try {
const user = req.session.user;
const parsed = playlistSchema.safeParse(req.body);
@ -327,10 +361,18 @@ module.exports = function adminRoutes(csrfProtection) {
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [] });
}
const coverFile = req.files?.cover?.[0];
let coverPath = null;
if (coverFile) {
try {
coverPath = await saveCover({ multerFile: coverFile });
} catch (err) {
console.warn('[admin] failed to process playlist cover:', err.message);
}
}
const created = playlists.create({
title: parsed.data.title,
description: parsed.data.description || null,
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
coverPath,
visibility: parsed.data.visibility,
createdBy: req.session.user.id,
});
@ -365,7 +407,7 @@ module.exports = function adminRoutes(csrfProtection) {
});
});
router.post('/playlists/:id', playlistFields, csrfProtection, (req, res, next) => {
router.post('/playlists/:id', playlistFields, csrfProtection, async (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = playlists.findById(id);
@ -392,7 +434,12 @@ module.exports = function adminRoutes(csrfProtection) {
const coverFile = req.files?.cover?.[0];
if (coverFile) {
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
playlists.setCover(id, relMedia(coverFile, 'covers'));
try {
const newCoverPath = await saveCover({ multerFile: coverFile });
playlists.setCover(id, newCoverPath);
} catch (err) {
console.warn('[admin] failed to process playlist cover on edit:', err.message);
}
}
req.flash('success', 'Playlist updated.');
res.redirect(`/mymusic/playlists/${id}/edit`);

View File

@ -5,9 +5,10 @@ const express = require('express');
const multer = require('multer');
const { requireUser } = require('../middleware/auth');
const settings = require('../services/settings');
const generation = require('../services/generation');
const songs = require('../services/songs');
const settings = require('../services/settings');
const generation = require('../services/generation');
const songs = require('../services/songs');
const imageService = require('../services/image');
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']);
@ -245,79 +246,88 @@ module.exports = function generateRoutes(csrfProtection) {
});
// ── POST /:id/publish — create song record + move file ─────────────────
router.post('/:id/publish', csrfProtection, (req, res, next) => {
uploadCover(req, res, (uploadErr) => {
try {
const id = Number(req.params.id);
const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id) return next();
if (row.status !== 'done' || !row.audio_path) {
if (req.file) fs.unlink(req.file.path, () => {});
req.flash('error', 'Song is not ready to publish.');
return res.redirect('/generate');
}
const title = (req.body.title || '').trim().slice(0, 200);
const artist = (req.body.artist || '').trim().slice(0, 200);
const errors = {};
if (!title) errors.title = 'Title is required.';
if (!artist) errors.artist = 'Artist is required.';
if (Object.keys(errors).length) {
if (req.file) fs.unlink(req.file.path, () => {});
return res.render('generate/publish', {
title: 'Publish Song',
row,
genres: songs.listGenres(),
values: { ...req.body },
errors,
});
}
const album = (req.body.album || '').trim().slice(0, 200) || null;
const genre = (req.body.genre !== '__new__' ? req.body.genre : '') || (req.body.genre_new || '').trim() || null;
const year = parseInt(req.body.year, 10) || null;
const visibility = ['public','logged_in','private'].includes(req.body.visibility)
? req.body.visibility : 'private';
// Cover art — use uploaded file if provided
let coverPath = null;
if (req.file) {
const ext = path.extname(req.file.originalname).toLowerCase();
coverPath = `covers/${req.file.filename}`;
}
// Move audio from media/generated/ to media/audio/
const oldAbs = path.join(generation.MEDIA_DIR, row.audio_path);
const audioExt = path.extname(row.audio_path);
const newName = `${Date.now()}${audioExt}`;
const newRel = `audio/${newName}`;
const newAbs = path.join(generation.MEDIA_DIR, newRel);
try {
fs.renameSync(oldAbs, newAbs);
} catch {
fs.copyFileSync(oldAbs, newAbs);
fs.unlinkSync(oldAbs);
}
songs.create({
title, artist, album, genre, year,
durationSeconds: row.duration_seconds || null,
audioPath: newRel,
coverPath,
visibility,
uploadedBy: req.session.user.id,
});
generation.remove(id);
req.flash('success', 'Song published to your library!');
res.redirect('/mymusic/songs');
} catch (err) {
// uploadCover (multer) must run before csrfProtection so req.body._csrf is
// available when the CSRF middleware checks it (multipart bodies aren't parsed
// by express.urlencoded).
router.post('/:id/publish', uploadCover, csrfProtection, async (req, res, next) => {
try {
const id = Number(req.params.id);
const row = generation.findById(id);
if (!row || row.user_id !== req.session.user.id) return next();
if (row.status !== 'done' || !row.audio_path) {
if (req.file) fs.unlink(req.file.path, () => {});
next(err);
req.flash('error', 'Song is not ready to publish.');
return res.redirect('/generate');
}
});
const title = (req.body.title || '').trim().slice(0, 200);
const artist = (req.body.artist || '').trim().slice(0, 200);
const errors = {};
if (!title) errors.title = 'Title is required.';
if (!artist) errors.artist = 'Artist is required.';
if (Object.keys(errors).length) {
if (req.file) fs.unlink(req.file.path, () => {});
return res.render('generate/publish', {
title: 'Publish Song',
row,
genres: songs.listGenres(),
values: { ...req.body },
errors,
});
}
const album = (req.body.album || '').trim().slice(0, 200) || null;
const genre = (req.body.genre !== '__new__' ? req.body.genre : '') || (req.body.genre_new || '').trim() || null;
const year = parseInt(req.body.year, 10) || null;
const visibility = ['public','logged_in','private'].includes(req.body.visibility)
? req.body.visibility : 'private';
// Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG)
let coverPath = null;
if (req.file) {
const finalName = `${crypto.randomUUID()}.jpg`;
const finalAbs = path.join(mediaDir, 'covers', finalName);
try {
await imageService.processCoverImage(req.file.path, finalAbs);
coverPath = `covers/${finalName}`;
} catch (err) {
console.warn('[generate] failed to process cover image:', err.message);
} finally {
fs.unlink(req.file.path, () => {});
}
}
// Move audio from media/generated/ to media/audio/
const oldAbs = path.join(generation.MEDIA_DIR, row.audio_path);
const audioExt = path.extname(row.audio_path);
const newName = `${Date.now()}${audioExt}`;
const newRel = `audio/${newName}`;
const newAbs = path.join(generation.MEDIA_DIR, newRel);
try {
fs.renameSync(oldAbs, newAbs);
} catch {
fs.copyFileSync(oldAbs, newAbs);
fs.unlinkSync(oldAbs);
}
songs.create({
title, artist, album, genre, year,
durationSeconds: row.duration_seconds || null,
audioPath: newRel,
coverPath,
visibility,
uploadedBy: req.session.user.id,
});
generation.remove(id);
req.flash('success', 'Song published to your library!');
res.redirect('/mymusic/songs');
} catch (err) {
if (req.file) fs.unlink(req.file.path, () => {});
next(err);
}
});
// ── POST /:id/delete ───────────────────────────────────────────────────

13
src/services/image.js Normal file
View File

@ -0,0 +1,13 @@
const sharp = require('sharp');
// Resize and center-crop an image to a 1:1 square at max 1000×1000, saved as JPEG.
// inputPath — absolute path of the source file (any format sharp supports)
// outputPath — absolute path for the output .jpg file
async function processCoverImage(inputPath, outputPath) {
await sharp(inputPath)
.resize(1000, 1000, { fit: 'cover', position: 'centre' })
.jpeg({ quality: 88 })
.toFile(outputPath);
}
module.exports = { processCoverImage };