feat: Add cover art support and auto-migration

- Extract cover art from MP3s during upload
- Display cover art in game result screens (win/loss)
- Add coverImage field to Song model
- Add migration script to backfill covers for existing songs
- Configure Docker to run migration script on startup
This commit is contained in:
Hördle Bot
2025-11-21 15:51:22 +01:00
parent 0c9508076f
commit 29d43effe3
8 changed files with 178 additions and 4 deletions

View File

@@ -47,6 +47,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
# Create uploads directory and set permissions # Create uploads directory and set permissions
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads

View File

@@ -35,6 +35,10 @@ export default function AdminPage() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10; const itemsPerPage = 10;
// Audio state
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const handleLogin = async () => { const handleLogin = async () => {
const res = await fetch('/api/admin/login', { const res = await fetch('/api/admin/login', {
method: 'POST', method: 'POST',
@@ -132,6 +136,29 @@ export default function AdminPage() {
} }
}; };
const handlePlayPause = (song: Song) => {
if (playingSongId === song.id) {
// Pause current song
audioElement?.pause();
setPlayingSongId(null);
} else {
// Stop any currently playing song
audioElement?.pause();
// Play new song
const audio = new Audio(`/uploads/${song.filename}`);
audio.play();
setAudioElement(audio);
setPlayingSongId(song.id);
// Reset when song ends
audio.onended = () => {
setPlayingSongId(null);
setAudioElement(null);
};
}
};
// Filter and sort songs // Filter and sort songs
const filteredSongs = songs.filter(song => const filteredSongs = songs.filter(song =>
song.title.toLowerCase().includes(searchQuery.toLowerCase()) || song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -297,6 +324,13 @@ export default function AdminPage() {
<td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td> <td style={{ padding: '0.75rem', color: '#666' }}>{song.activations}</td>
<td style={{ padding: '0.75rem' }}> <td style={{ padding: '0.75rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}> <div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => handlePlayPause(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}
title={playingSongId === song.id ? "Pause" : "Play"}
>
{playingSongId === song.id ? '⏸️' : '▶️'}
</button>
<button <button
onClick={() => startEditing(song)} onClick={() => startEditing(song)}
style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }} style={{ fontSize: '1.25rem', cursor: 'pointer', border: 'none', background: 'none' }}

View File

@@ -21,6 +21,7 @@ export async function GET() {
artist: song.artist, artist: song.artist,
filename: song.filename, filename: song.filename,
createdAt: song.createdAt, createdAt: song.createdAt,
coverImage: song.coverImage,
activations: song.puzzles.length, activations: song.puzzles.length,
})); }));
@@ -62,11 +63,30 @@ export async function POST(request: Request) {
await writeFile(path.join(uploadDir, filename), buffer); await writeFile(path.join(uploadDir, filename), buffer);
// Handle cover image
let coverImage = null;
try {
const metadata = await parseBuffer(buffer, file.type);
const picture = metadata.common.picture?.[0];
if (picture) {
const extension = picture.format.split('/')[1] || 'jpg';
const coverFilename = `cover-${Date.now()}.${extension}`;
const coverPath = path.join(process.cwd(), 'public/uploads/covers', coverFilename);
await writeFile(coverPath, picture.data);
coverImage = coverFilename;
}
} catch (e) {
console.error('Failed to extract cover image:', e);
}
const song = await prisma.song.create({ const song = await prisma.song.create({
data: { data: {
title, title,
artist, artist,
filename, filename,
coverImage,
}, },
}); });
@@ -123,6 +143,16 @@ export async function DELETE(request: Request) {
// Continue with DB deletion even if file deletion fails // Continue with DB deletion even if file deletion fails
} }
// Delete cover image if exists
if (song.coverImage) {
const coverPath = path.join(process.cwd(), 'public/uploads/covers', song.coverImage);
try {
await unlink(coverPath);
} catch (e) {
console.error('Failed to delete cover image:', e);
}
}
// Delete from database (will cascade delete related puzzles) // Delete from database (will cascade delete related puzzles)
await prisma.song.delete({ await prisma.song.delete({
where: { id: Number(id) }, where: { id: Number(id) },

View File

@@ -40,7 +40,10 @@ async function getDailyPuzzle() {
return { return {
id: dailyPuzzle.id, id: dailyPuzzle.id,
audioUrl: `/uploads/${dailyPuzzle.song.filename}`, audioUrl: `/uploads/${dailyPuzzle.song.filename}`,
songId: dailyPuzzle.songId songId: dailyPuzzle.songId,
title: dailyPuzzle.song.title,
artist: dailyPuzzle.song.artist,
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
}; };
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@@ -11,6 +11,9 @@ interface GameProps {
id: number; id: number;
audioUrl: string; audioUrl: string;
songId: number; songId: number;
title: string;
artist: string;
coverImage: string | null;
} | null; } | null;
} }
@@ -139,6 +142,24 @@ export default function Game({ dailyPuzzle }: GameProps) {
<div className="message-box success"> <div className="message-box success">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2> <h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
<p>Come back tomorrow for a new song.</p> <p>Come back tomorrow for a new song.</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{statistics && <Statistics statistics={statistics} />} {statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}> <button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText} {shareText}
@@ -149,7 +170,25 @@ export default function Game({ dailyPuzzle }: GameProps) {
{hasLost && ( {hasLost && (
<div className="message-box failure"> <div className="message-box failure">
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2> <h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
<p>The song was hidden.</p> <p>The song was:</p>
{/* Song Details */}
<div style={{ margin: '1.5rem 0', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
{dailyPuzzle.coverImage && (
<img
src={dailyPuzzle.coverImage}
alt="Album Cover"
style={{ width: '150px', height: '150px', objectFit: 'cover', borderRadius: '0.5rem', marginBottom: '1rem', boxShadow: '0 4px 6px rgba(0,0,0,0.1)' }}
/>
)}
<h3 style={{ fontSize: '1.125rem', fontWeight: 'bold', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.title}</h3>
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>{dailyPuzzle.artist}</p>
<audio controls style={{ width: '100%' }}>
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
{statistics && <Statistics statistics={statistics} />} {statistics && <Statistics statistics={statistics} />}
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}> <button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
{shareText} {shareText}

View File

@@ -14,6 +14,6 @@ services:
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./public/uploads:/app/public/uploads - ./public/uploads:/app/public/uploads
# Initialize DB if needed # Initialize DB if needed and run migration
command: > command: >
sh -c "npx -y prisma@6.19.0 db push && node server.js" sh -c "npx -y prisma@6.19.0 db push && node scripts/migrate-covers.mjs && node server.js"

View File

@@ -15,6 +15,7 @@ model Song {
title String title String
artist String artist String
filename String // Filename in public/uploads filename String // Filename in public/uploads
coverImage String? // Filename in public/uploads/covers
createdAt DateTime @default(now()) createdAt DateTime @default(now())
puzzles DailyPuzzle[] puzzles DailyPuzzle[]
} }

View File

@@ -0,0 +1,66 @@
import { PrismaClient } from '@prisma/client';
import { parseBuffer } from 'music-metadata';
import { readFile, writeFile } from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Adjust path to reach root from scripts/
const prisma = new PrismaClient();
async function migrate() {
console.log('Starting cover art migration...');
try {
// Find songs without cover image
const songs = await prisma.song.findMany({
where: {
coverImage: null
}
});
console.log(`Found ${songs.length} songs without cover image.`);
for (const song of songs) {
try {
const filePath = path.join(process.cwd(), 'public/uploads', song.filename);
console.log(`Processing ${song.title} (${song.filename})...`);
const buffer = await readFile(filePath);
const metadata = await parseBuffer(buffer);
const picture = metadata.common.picture?.[0];
if (picture) {
const extension = picture.format.split('/')[1] || 'jpg';
const coverFilename = `cover-${Date.now()}-${Math.floor(Math.random() * 1000)}.${extension}`;
const coverPath = path.join(process.cwd(), 'public/uploads/covers', coverFilename);
// Ensure directory exists
await writeFile(coverPath, picture.data);
// Update DB
await prisma.song.update({
where: { id: song.id },
data: { coverImage: coverFilename }
});
console.log(`✅ Extracted cover for ${song.title}`);
} else {
console.log(`⚠️ No cover found for ${song.title}`);
}
} catch (e) {
console.error(`❌ Failed to process ${song.title}:`, e.message);
}
}
console.log('Migration completed.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
await prisma.$disconnect();
}
}
migrate();