`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:
parent
8b4a28e062
commit
4046bc20e0
|
|
@ -8,6 +8,7 @@ const { z } = require('zod');
|
|||
const { requireUser, requireAdmin } = require('../middleware/auth');
|
||||
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`);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { requireUser } = require('../middleware/auth');
|
|||
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,8 +246,10 @@ 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) => {
|
||||
// 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);
|
||||
|
|
@ -280,11 +283,19 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
const visibility = ['public','logged_in','private'].includes(req.body.visibility)
|
||||
? req.body.visibility : 'private';
|
||||
|
||||
// Cover art — use uploaded file if provided
|
||||
// Cover art — process through sharp (1:1 crop, max 1000×1000, JPEG)
|
||||
let coverPath = null;
|
||||
if (req.file) {
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
coverPath = `covers/${req.file.filename}`;
|
||||
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/
|
||||
|
|
@ -318,7 +329,6 @@ module.exports = function generateRoutes(csrfProtection) {
|
|||
next(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /:id/delete ───────────────────────────────────────────────────
|
||||
router.post('/:id/delete', csrfProtection, (req, res) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
Loading…
Reference in New Issue