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:
@@ -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
|
||||||
|
|||||||
@@ -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' }}
|
||||||
|
|||||||
@@ -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) },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
66
scripts/migrate-covers.mjs
Normal file
66
scripts/migrate-covers.mjs
Normal 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();
|
||||||
Reference in New Issue
Block a user