From 80f8c4ca903d8a69ea1e61a25729572cc51d1e72 Mon Sep 17 00:00:00 2001 From: Elmar Kresse Date: Thu, 4 Sep 2025 20:58:28 +0200 Subject: [PATCH] feat: add endpoint to serve embedded cover art from audio files --- public/js/main.js | 15 ++++++++++++--- src/server/routes/audio.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/public/js/main.js b/public/js/main.js index e4fedf8..75c8e02 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -57,9 +57,7 @@ function handlePlayTrack(msg) { if ($guessTitle) $guessTitle.value = ''; if ($guessArtist) $guessArtist.value = ''; if ($answerResult) { $answerResult.textContent=''; $answerResult.className='mt-1 text-sm'; } - try { - $audio.preload = 'auto'; - } catch {} + try { $audio.preload = 'auto'; } catch {} $audio.src = t.url; const pf = document.getElementById('progressFill'); if (pf) { @@ -68,6 +66,8 @@ function handlePlayTrack(msg) { const rd = document.getElementById('recordDisc'); if (rd) { rd.classList.remove('spin-record'); + // Reset cover to default logo at the start of a new track + rd.src = '/hitstar.png'; } const { startAt, serverNow } = msg; const now = Date.now(); @@ -134,6 +134,15 @@ function handleReveal(msg) { if ($placeArea) { $placeArea.classList.add('hidden'); } + // Try to load embedded cover art and replace the center image + const rd = document.getElementById('recordDisc'); + if (rd && track?.id) { + const coverUrl = `/cover/${encodeURIComponent(track.id)}`; + const img = new Image(); + img.onload = () => { rd.src = coverUrl; }; + img.onerror = () => { /* keep default logo */ }; + img.src = coverUrl + `?t=${Date.now()}`; // bypass cache just in case + } } function handleGameEnded(msg) { diff --git a/src/server/routes/audio.js b/src/server/routes/audio.js index a8e1c49..39e71fa 100644 --- a/src/server/routes/audio.js +++ b/src/server/routes/audio.js @@ -1,9 +1,13 @@ import fs from 'fs'; import path from 'path'; import mime from 'mime'; +import { parseFile as mmParseFile } from 'music-metadata'; import { DATA_DIR } from '../config.js'; export function registerAudioRoutes(app) { + // Simple in-memory cover cache: name -> { mime, buf } + const coverCache = new Map(); + app.head('/audio/:name', (req, res) => { const name = req.params.name; const filePath = path.join(DATA_DIR, name); @@ -55,4 +59,36 @@ export function registerAudioRoutes(app) { fs.createReadStream(filePath).pipe(res); } }); + + // Serve embedded cover art from audio files, if present + app.get('/cover/:name', async (req, res) => { + try { + const name = req.params.name; + const filePath = path.join(DATA_DIR, name); + const resolved = path.resolve(filePath); + if (!resolved.startsWith(DATA_DIR)) return res.status(400).send('Invalid path'); + if (!fs.existsSync(resolved)) return res.status(404).send('Not found'); + + if (coverCache.has(resolved)) { + const c = coverCache.get(resolved); + res.setHeader('Content-Type', c.mime || 'image/jpeg'); + res.setHeader('Cache-Control', 'no-store'); + return res.status(200).end(c.buf); + } + + const meta = await mmParseFile(resolved, { duration: false }); + const pic = meta.common?.picture?.[0]; + if (!pic || !pic.data || !pic.data.length) { + return res.status(404).send('No cover'); + } + const mimeType = pic.format || 'image/jpeg'; + const buf = Buffer.from(pic.data); + coverCache.set(resolved, { mime: mimeType, buf }); + res.setHeader('Content-Type', mimeType); + res.setHeader('Cache-Control', 'no-store'); + return res.status(200).end(buf); + } catch (e) { + return res.status(500).send('Error reading cover'); + } + }); }