Finalize scoring system, release year integration, and fix song deletion
This commit is contained in:
1
.release-years-migrated
Normal file
1
.release-years-migrated
Normal file
@@ -0,0 +1 @@
|
||||
2025-11-23T18:51:44.711Z
|
||||
13
README.md
13
README.md
@@ -39,6 +39,19 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
||||
- Einzelne Segmente zum Testen abspielen.
|
||||
- Manuelle Speicherung mit visueller Bestätigung.
|
||||
|
||||
## Spielregeln & Punktesystem
|
||||
|
||||
Das Ziel ist es, den Song mit so wenigen Hinweisen wie möglich zu erraten und dabei einen möglichst hohen Highscore zu erzielen.
|
||||
|
||||
- **Start-Punktestand:** 90 Punkte
|
||||
- **Richtige Antwort:** +20 Punkte
|
||||
- **Falsche Antwort:** -3 Punkte
|
||||
- **Überspringen (Skip):** -5 Punkte
|
||||
- **Snippet erneut abspielen (Replay):** -1 Punkt
|
||||
- **Bonus-Runde (Release-Jahr erraten):** +10 Punkte (0 bei falscher Antwort)
|
||||
- **Aufgeben / Verloren:** Der Punktestand wird auf 0 gesetzt.
|
||||
- **Minimum:** Der Punktestand kann nicht unter 0 fallen.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16 (App Router)
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
const GOTIFY_URL = process.env.GOTIFY_URL;
|
||||
const GOTIFY_APP_TOKEN = process.env.GOTIFY_APP_TOKEN;
|
||||
|
||||
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null) {
|
||||
export async function sendGotifyNotification(attempts: number, status: 'won' | 'lost', puzzleId: number, genre?: string | null, score?: number) {
|
||||
try {
|
||||
const genreText = genre ? `[${genre}] ` : '';
|
||||
const title = `Hördle ${genreText}#${puzzleId} ${status === 'won' ? 'Solved!' : 'Failed'}`;
|
||||
const scoreText = score !== undefined ? ` with a score of ${score}` : '';
|
||||
const message = status === 'won'
|
||||
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s).`
|
||||
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s).`;
|
||||
? `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was solved in ${attempts} attempt(s)${scoreText}.`
|
||||
: `Puzzle #${puzzleId} ${genre ? `(${genre}) ` : ''}was failed after ${attempts} attempt(s)${scoreText}.`;
|
||||
|
||||
const response = await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -40,6 +40,7 @@ interface Song {
|
||||
artist: string;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
releaseYear: number | null;
|
||||
activations: number;
|
||||
puzzles: DailyPuzzle[];
|
||||
genres: Genre[];
|
||||
@@ -48,7 +49,7 @@ interface Song {
|
||||
ratingCount: number;
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt';
|
||||
type SortField = 'id' | 'title' | 'artist' | 'createdAt' | 'releaseYear';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function AdminPage() {
|
||||
@@ -91,6 +92,7 @@ export default function AdminPage() {
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editArtist, setEditArtist] = useState('');
|
||||
const [editReleaseYear, setEditReleaseYear] = useState<number | ''>('');
|
||||
const [editGenreIds, setEditGenreIds] = useState<number[]>([]);
|
||||
const [editSpecialIds, setEditSpecialIds] = useState<number[]>([]);
|
||||
|
||||
@@ -577,6 +579,7 @@ export default function AdminPage() {
|
||||
setEditingId(song.id);
|
||||
setEditTitle(song.title);
|
||||
setEditArtist(song.artist);
|
||||
setEditReleaseYear(song.releaseYear || '');
|
||||
setEditGenreIds(song.genres.map(g => g.id));
|
||||
setEditSpecialIds(song.specials ? song.specials.map(s => s.id) : []);
|
||||
};
|
||||
@@ -585,6 +588,7 @@ export default function AdminPage() {
|
||||
setEditingId(null);
|
||||
setEditTitle('');
|
||||
setEditArtist('');
|
||||
setEditReleaseYear('');
|
||||
setEditGenreIds([]);
|
||||
setEditSpecialIds([]);
|
||||
};
|
||||
@@ -597,6 +601,7 @@ export default function AdminPage() {
|
||||
id,
|
||||
title: editTitle,
|
||||
artist: editArtist,
|
||||
releaseYear: editReleaseYear === '' ? null : Number(editReleaseYear),
|
||||
genreIds: editGenreIds,
|
||||
specialIds: editSpecialIds
|
||||
}),
|
||||
@@ -706,10 +711,15 @@ export default function AdminPage() {
|
||||
});
|
||||
|
||||
const sortedSongs = [...filteredSongs].sort((a, b) => {
|
||||
// Handle numeric sorting for ID
|
||||
// Handle numeric sorting for ID and Release Year
|
||||
if (sortField === 'id') {
|
||||
return sortDirection === 'asc' ? a.id - b.id : b.id - a.id;
|
||||
}
|
||||
if (sortField === 'releaseYear') {
|
||||
const yearA = a.releaseYear || 0;
|
||||
const yearB = b.releaseYear || 0;
|
||||
return sortDirection === 'asc' ? yearA - yearB : yearB - yearA;
|
||||
}
|
||||
|
||||
// String sorting for other fields
|
||||
const valA = String(a[sortField]).toLowerCase();
|
||||
@@ -1223,10 +1233,15 @@ export default function AdminPage() {
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('title')}
|
||||
>
|
||||
Title {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
Song {sortField === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
|
||||
<th style={{ padding: '0.75rem' }}>Genres</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('releaseYear')}
|
||||
>
|
||||
Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th style={{ padding: '0.75rem' }}>Genres / Specials</th>
|
||||
<th
|
||||
style={{ padding: '0.75rem', cursor: 'pointer', userSelect: 'none' }}
|
||||
onClick={() => handleSort('createdAt')}
|
||||
@@ -1263,6 +1278,16 @@ export default function AdminPage() {
|
||||
placeholder="Artist"
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<input
|
||||
type="number"
|
||||
value={editReleaseYear}
|
||||
onChange={e => setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
className="form-input"
|
||||
style={{ padding: '0.25rem', width: '80px' }}
|
||||
placeholder="Year"
|
||||
/>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{genres.map(genre => (
|
||||
@@ -1369,6 +1394,9 @@ export default function AdminPage() {
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', color: '#666' }}>
|
||||
{song.releaseYear || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||
{song.genres?.map(g => (
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function GET() {
|
||||
filename: song.filename,
|
||||
createdAt: song.createdAt,
|
||||
coverImage: song.coverImage,
|
||||
releaseYear: song.releaseYear,
|
||||
activations: song.puzzles.length,
|
||||
puzzles: song.puzzles,
|
||||
genres: song.genres,
|
||||
@@ -179,12 +180,25 @@ export async function POST(request: Request) {
|
||||
console.error('Failed to extract cover image:', e);
|
||||
}
|
||||
|
||||
// Fetch release year from MusicBrainz
|
||||
let releaseYear = null;
|
||||
try {
|
||||
const { getReleaseYear } = await import('@/lib/musicbrainz');
|
||||
releaseYear = await getReleaseYear(artist, title);
|
||||
if (releaseYear) {
|
||||
console.log(`Fetched release year ${releaseYear} for "${title}" by "${artist}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch release year from MusicBrainz:', e);
|
||||
}
|
||||
|
||||
const song = await prisma.song.create({
|
||||
data: {
|
||||
title,
|
||||
artist,
|
||||
filename,
|
||||
coverImage,
|
||||
releaseYear,
|
||||
},
|
||||
include: { genres: true, specials: true }
|
||||
});
|
||||
@@ -201,7 +215,7 @@ export async function POST(request: Request) {
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const { id, title, artist, genreIds, specialIds } = await request.json();
|
||||
const { id, title, artist, releaseYear, genreIds, specialIds } = await request.json();
|
||||
|
||||
if (!id || !title || !artist) {
|
||||
return NextResponse.json({ error: 'Missing fields' }, { status: 400 });
|
||||
@@ -209,6 +223,11 @@ export async function PUT(request: Request) {
|
||||
|
||||
const data: any = { title, artist };
|
||||
|
||||
// Update releaseYear if provided (can be null to clear it)
|
||||
if (releaseYear !== undefined) {
|
||||
data.releaseYear = releaseYear;
|
||||
}
|
||||
|
||||
if (genreIds) {
|
||||
data.genres = {
|
||||
set: genreIds.map((gId: number) => ({ id: gId }))
|
||||
|
||||
@@ -7,13 +7,15 @@ interface AudioPlayerProps {
|
||||
unlockedSeconds: number; // 2, 4, 7, 11, 16, 30 (or full length)
|
||||
startTime?: number; // Start offset in seconds (for curated specials)
|
||||
onPlay?: () => void;
|
||||
onReplay?: () => void;
|
||||
autoPlay?: boolean;
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, autoPlay = false }: AudioPlayerProps) {
|
||||
export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPlay, onReplay, autoPlay = false }: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioRef.current) {
|
||||
@@ -21,6 +23,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
audioRef.current.currentTime = startTime;
|
||||
setIsPlaying(false);
|
||||
setProgress(0);
|
||||
setHasPlayedOnce(false); // Reset for new segment
|
||||
|
||||
if (autoPlay) {
|
||||
const playPromise = audioRef.current.play();
|
||||
@@ -29,6 +32,7 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
onPlay?.();
|
||||
setHasPlayedOnce(true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.log("Autoplay prevented:", error);
|
||||
@@ -47,6 +51,12 @@ export default function AudioPlayer({ src, unlockedSeconds, startTime = 0, onPla
|
||||
} else {
|
||||
audioRef.current.play();
|
||||
onPlay?.();
|
||||
|
||||
if (hasPlayedOnce) {
|
||||
onReplay?.();
|
||||
} else {
|
||||
setHasPlayedOnce(true);
|
||||
}
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ interface GameProps {
|
||||
title: string;
|
||||
artist: string;
|
||||
coverImage: string | null;
|
||||
releaseYear?: number | null;
|
||||
startTime?: number;
|
||||
} | null;
|
||||
genre?: string | null;
|
||||
@@ -27,7 +28,7 @@ interface GameProps {
|
||||
const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60];
|
||||
|
||||
export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) {
|
||||
const { gameState, statistics, addGuess } = useGameState(genre, maxAttempts);
|
||||
const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus } = useGameState(genre, maxAttempts);
|
||||
const [hasWon, setHasWon] = useState(false);
|
||||
const [hasLost, setHasLost] = useState(false);
|
||||
const [shareText, setShareText] = useState('🔗 Share');
|
||||
@@ -35,6 +36,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const [isProcessingGuess, setIsProcessingGuess] = useState(false);
|
||||
const [timeUntilNext, setTimeUntilNext] = useState('');
|
||||
const [hasRated, setHasRated] = useState(false);
|
||||
const [showYearModal, setShowYearModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const updateCountdown = () => {
|
||||
@@ -50,7 +52,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
const interval = setInterval(updateCountdown, 1000); // Update every second to be accurate
|
||||
const interval = setInterval(updateCountdown, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
@@ -58,6 +60,11 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (gameState && dailyPuzzle) {
|
||||
setHasWon(gameState.isSolved);
|
||||
setHasLost(gameState.isFailed);
|
||||
|
||||
// Show year modal if won but year not guessed yet and release year is available
|
||||
if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) {
|
||||
setShowYearModal(true);
|
||||
}
|
||||
}
|
||||
}, [gameState, dailyPuzzle]);
|
||||
|
||||
@@ -87,37 +94,61 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
if (!gameState) return <div>Loading state...</div>;
|
||||
|
||||
const handleGuess = (song: any) => {
|
||||
if (isProcessingGuess) return; // Prevent multiple guesses
|
||||
if (isProcessingGuess) return;
|
||||
|
||||
setIsProcessingGuess(true);
|
||||
setLastAction('GUESS');
|
||||
if (song.id === dailyPuzzle.songId) {
|
||||
addGuess(song.title, true);
|
||||
setHasWon(true);
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre);
|
||||
// Notification sent after year guess or skip
|
||||
if (!dailyPuzzle.releaseYear) {
|
||||
sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
}
|
||||
} else {
|
||||
addGuess(song.title, false);
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false); // Ensure won is false
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
}
|
||||
// Reset after a short delay to allow UI update
|
||||
setTimeout(() => setIsProcessingGuess(false), 500);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
|
||||
if (gameState.guesses.length + 1 >= maxAttempts) {
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure
|
||||
}
|
||||
};
|
||||
|
||||
const handleGiveUp = () => {
|
||||
setLastAction('SKIP');
|
||||
addGuess("SKIPPED", false);
|
||||
giveUp(); // Ensure game is marked as failed and score reset to 0
|
||||
setHasLost(true);
|
||||
setHasWon(false);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre);
|
||||
sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0);
|
||||
};
|
||||
|
||||
const handleYearGuess = (year: number) => {
|
||||
const correct = year === dailyPuzzle.releaseYear;
|
||||
addYearBonus(correct);
|
||||
setShowYearModal(false);
|
||||
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0));
|
||||
};
|
||||
|
||||
const handleYearSkip = () => {
|
||||
setShowYearModal(false);
|
||||
// Send notification now that game is fully complete
|
||||
sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score);
|
||||
};
|
||||
|
||||
const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)];
|
||||
@@ -126,21 +157,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
let emojiGrid = '';
|
||||
const totalGuesses = maxAttempts;
|
||||
|
||||
// Build the grid
|
||||
for (let i = 0; i < totalGuesses; i++) {
|
||||
if (i < gameState.guesses.length) {
|
||||
// If this was the winning guess (last one and won)
|
||||
if (hasWon && i === gameState.guesses.length - 1) {
|
||||
emojiGrid += '🟩';
|
||||
} else if (gameState.guesses[i] === 'SKIPPED') {
|
||||
// Skipped
|
||||
emojiGrid += '⬛';
|
||||
} else {
|
||||
// Wrong guess
|
||||
emojiGrid += '🟥';
|
||||
}
|
||||
} else {
|
||||
// Unused attempts
|
||||
emojiGrid += '⬜';
|
||||
}
|
||||
}
|
||||
@@ -148,7 +174,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
const speaker = hasWon ? '🔉' : '🔇';
|
||||
const genreText = genre ? `Genre: ${genre}\n` : '';
|
||||
|
||||
// Generate URL with genre/special path
|
||||
let shareUrl = 'https://hoerdle.elpatron.me';
|
||||
if (genre) {
|
||||
if (isSpecial) {
|
||||
@@ -158,9 +183,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
}
|
||||
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`;
|
||||
|
||||
// Try native Web Share API only on mobile devices
|
||||
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
||||
|
||||
if (isMobile && navigator.share) {
|
||||
@@ -173,14 +197,12 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
setTimeout(() => setShareText('🔗 Share'), 2000);
|
||||
return;
|
||||
} catch (err) {
|
||||
// User cancelled or error - fall through to clipboard
|
||||
if ((err as Error).name !== 'AbortError') {
|
||||
console.error('Share failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Copy to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setShareText('✓ Copied!');
|
||||
@@ -192,8 +214,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleRatingSubmit = async (rating: number) => {
|
||||
if (!dailyPuzzle) return;
|
||||
|
||||
@@ -201,7 +221,6 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber);
|
||||
setHasRated(true);
|
||||
|
||||
// Persist to localStorage
|
||||
const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]');
|
||||
if (!ratedPuzzles.includes(dailyPuzzle.id)) {
|
||||
ratedPuzzles.push(dailyPuzzle.id);
|
||||
@@ -222,17 +241,20 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</header>
|
||||
|
||||
<main className="game-board">
|
||||
|
||||
<div style={{ borderBottom: '1px solid #e5e7eb', paddingBottom: '1rem' }}>
|
||||
<div className="status-bar">
|
||||
<span>Attempt {gameState.guesses.length + 1} / {maxAttempts}</span>
|
||||
<span>{unlockedSeconds}s unlocked</span>
|
||||
</div>
|
||||
|
||||
<ScoreDisplay score={gameState.score} breakdown={gameState.scoreBreakdown} />
|
||||
|
||||
<AudioPlayer
|
||||
src={dailyPuzzle.audioUrl}
|
||||
unlockedSeconds={unlockedSeconds}
|
||||
startTime={dailyPuzzle.startTime}
|
||||
autoPlay={lastAction === 'SKIP'}
|
||||
onReplay={addReplay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +275,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
{!hasWon && !hasLost && (
|
||||
<>
|
||||
<GuessInput onGuess={handleGuess} disabled={isProcessingGuess} />
|
||||
{gameState.guesses.length < 6 ? (
|
||||
{gameState.guesses.length < maxAttempts - 1 ? (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="skip-button"
|
||||
@@ -275,12 +297,32 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasWon && (
|
||||
<div className="message-box success">
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>You won!</h2>
|
||||
<p>Come back tomorrow for a new song.</p>
|
||||
{(hasWon || hasLost) && (
|
||||
<div className={`message-box ${hasWon ? 'success' : 'failure'}`}>
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
||||
{hasWon ? 'You won!' : 'Game Over'}
|
||||
</h2>
|
||||
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold', margin: '1rem 0', color: hasWon ? '#059669' : '#dc2626' }}>
|
||||
Score: {gameState.score}
|
||||
</div>
|
||||
|
||||
<details style={{ marginBottom: '1rem', cursor: 'pointer', fontSize: '0.9rem', color: '#666' }}>
|
||||
<summary>Score Breakdown</summary>
|
||||
<ul style={{ listStyle: 'none', padding: '0.5rem', textAlign: 'left', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem', marginTop: '0.5rem' }}>
|
||||
{gameState.scoreBreakdown.map((item, i) => (
|
||||
<li key={i} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.25rem 0' }}>
|
||||
<span>{item.reason}</span>
|
||||
<span style={{ fontWeight: 'bold', color: item.value >= 0 ? 'green' : 'red' }}>
|
||||
{item.value > 0 ? '+' : ''}{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<p>{hasWon ? 'Come back tomorrow for a new song.' : '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' }}>
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
@@ -288,14 +330,16 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
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>
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 0.5rem 0' }}>{dailyPuzzle.artist}</p>
|
||||
{dailyPuzzle.releaseYear && (
|
||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: '0 0 1rem 0' }}>Released: {dailyPuzzle.releaseYear}</p>
|
||||
)}
|
||||
<audio controls style={{ width: '100%' }}>
|
||||
<source src={dailyPuzzle.audioUrl} type="audio/mpeg" />
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
{/* Rating Component */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
@@ -306,40 +350,150 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLost && (
|
||||
<div className="message-box failure">
|
||||
<h2 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>Game Over</h2>
|
||||
<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' }}>
|
||||
<img
|
||||
src={dailyPuzzle.coverImage || '/favicon.ico'}
|
||||
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>
|
||||
|
||||
{/* Rating Component */}
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<StarRating onRate={handleRatingSubmit} hasRated={hasRated} />
|
||||
</div>
|
||||
|
||||
{statistics && <Statistics statistics={statistics} />}
|
||||
<button onClick={handleShare} className="btn-primary" style={{ marginTop: '1rem' }}>
|
||||
{shareText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
{showYearModal && dailyPuzzle.releaseYear && (
|
||||
<YearGuessModal
|
||||
correctYear={dailyPuzzle.releaseYear}
|
||||
onGuess={handleYearGuess}
|
||||
onSkip={handleYearSkip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) {
|
||||
const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n');
|
||||
|
||||
// Create expression: "90 - 2 - 5 + 10"
|
||||
// Limit to last 5 items to avoid overflow if too long
|
||||
const displayItems = breakdown.length > 5 ?
|
||||
[{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] :
|
||||
breakdown;
|
||||
|
||||
const expression = displayItems.map((item, index) => {
|
||||
if (index === 0 && breakdown.length <= 5) return item.value.toString();
|
||||
if (index === 0 && breakdown.length > 5) return `${item.value} ...`;
|
||||
return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`;
|
||||
}).join(' ');
|
||||
|
||||
return (
|
||||
<div className="score-display" title={tooltipText} style={{
|
||||
textAlign: 'center',
|
||||
margin: '0.5rem 0',
|
||||
padding: '0.5rem',
|
||||
background: '#f3f4f6',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'monospace',
|
||||
cursor: 'help'
|
||||
}}>
|
||||
<span style={{ color: '#666' }}>{expression} = </span>
|
||||
<span style={{ fontWeight: 'bold', color: 'var(--primary)', fontSize: '1.1rem' }}>{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) {
|
||||
const [options, setOptions] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const minYear = 1950;
|
||||
|
||||
const closeOptions = new Set<number>();
|
||||
closeOptions.add(correctYear);
|
||||
|
||||
// Add 2 close years (+/- 2)
|
||||
while (closeOptions.size < 3) {
|
||||
const offset = Math.floor(Math.random() * 5) - 2;
|
||||
const year = correctYear + offset;
|
||||
if (year <= currentYear && year >= minYear && year !== correctYear) {
|
||||
closeOptions.add(year);
|
||||
}
|
||||
}
|
||||
|
||||
const allOptions = new Set(closeOptions);
|
||||
|
||||
// Fill up to 10 with random years
|
||||
while (allOptions.size < 10) {
|
||||
const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear;
|
||||
allOptions.add(year);
|
||||
}
|
||||
|
||||
setOptions(Array.from(allOptions).sort((a, b) => a - b));
|
||||
}, [correctYear]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
padding: '1rem'
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'white',
|
||||
padding: '2rem',
|
||||
borderRadius: '1rem',
|
||||
maxWidth: '500px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<h3 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '0.5rem', color: '#1f2937' }}>Bonus Round!</h3>
|
||||
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>Guess the release year for <strong style={{ color: '#10b981' }}>+10 points</strong>!</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{options.map(year => (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => onGuess(year)}
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
background: '#f3f4f6',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: '#374151',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onMouseOver={e => e.currentTarget.style.borderColor = '#10b981'}
|
||||
onMouseOut={e => e.currentTarget.style.borderColor = '#e5e7eb'}
|
||||
>
|
||||
{year}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#6b7280',
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Skip Bonus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ export async function getOrCreateDailyPuzzle(genreName: string | null = null) {
|
||||
title: dailyPuzzle.song.title,
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
genre: genreName
|
||||
};
|
||||
|
||||
@@ -230,6 +231,7 @@ export async function getOrCreateSpecialPuzzle(specialName: string) {
|
||||
title: dailyPuzzle.song.title,
|
||||
artist: dailyPuzzle.song.artist,
|
||||
coverImage: dailyPuzzle.song.coverImage ? `/uploads/covers/${dailyPuzzle.song.coverImage}` : null,
|
||||
releaseYear: dailyPuzzle.song.releaseYear,
|
||||
special: specialName,
|
||||
maxAttempts: special.maxAttempts,
|
||||
unlockSteps: JSON.parse(special.unlockSteps),
|
||||
|
||||
154
lib/gameState.ts
154
lib/gameState.ts
@@ -9,6 +9,11 @@ export interface GameState {
|
||||
isSolved: boolean;
|
||||
isFailed: boolean;
|
||||
lastPlayed: number; // Timestamp
|
||||
score: number;
|
||||
replayCount: number;
|
||||
skipCount: number;
|
||||
scoreBreakdown: Array<{ value: number; reason: string }>;
|
||||
yearGuessed: boolean;
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
@@ -22,19 +27,31 @@ export interface Statistics {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hoerdle_game_state';
|
||||
const STATS_KEY = 'hoerdle_statistics';
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const INITIAL_SCORE = 90;
|
||||
|
||||
export function useGameState(genre: string | null = null, maxAttempts: number = 7) {
|
||||
const [gameState, setGameState] = useState<GameState | null>(null);
|
||||
const [statistics, setStatistics] = useState<Statistics | null>(null);
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'hoerdle_game_state';
|
||||
const STATS_KEY_PREFIX = 'hoerdle_statistics';
|
||||
|
||||
const getStorageKey = () => genre ? `${STORAGE_KEY_PREFIX}_${genre}` : STORAGE_KEY_PREFIX;
|
||||
const getStatsKey = () => genre ? `${STATS_KEY_PREFIX}_${genre}` : STATS_KEY_PREFIX;
|
||||
|
||||
const createNewState = (date: string): GameState => ({
|
||||
date,
|
||||
guesses: [],
|
||||
isSolved: false,
|
||||
isFailed: false,
|
||||
lastPlayed: Date.now(),
|
||||
score: INITIAL_SCORE,
|
||||
replayCount: 0,
|
||||
skipCount: 0,
|
||||
scoreBreakdown: [{ value: INITIAL_SCORE, reason: 'Start value' }],
|
||||
yearGuessed: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Load game state
|
||||
const storageKey = getStorageKey();
|
||||
@@ -42,30 +59,29 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
const today = getTodayISOString();
|
||||
|
||||
if (stored) {
|
||||
const parsed: GameState = JSON.parse(stored);
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed.date === today) {
|
||||
setGameState(parsed);
|
||||
// Migration for existing states without score
|
||||
if (parsed.score === undefined) {
|
||||
parsed.score = INITIAL_SCORE;
|
||||
parsed.replayCount = 0;
|
||||
parsed.skipCount = 0;
|
||||
parsed.scoreBreakdown = [{ value: INITIAL_SCORE, reason: 'Start value' }];
|
||||
parsed.yearGuessed = false;
|
||||
|
||||
// Retroactively deduct points for existing guesses if possible,
|
||||
// but simpler to just start at 90 for active games to avoid confusion
|
||||
}
|
||||
setGameState(parsed as GameState);
|
||||
} else {
|
||||
// New day
|
||||
const newState: GameState = {
|
||||
date: today,
|
||||
guesses: [],
|
||||
isSolved: false,
|
||||
isFailed: false,
|
||||
lastPlayed: Date.now(),
|
||||
};
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
} else {
|
||||
// No state
|
||||
const newState: GameState = {
|
||||
date: today,
|
||||
guesses: [],
|
||||
isSolved: false,
|
||||
isFailed: false,
|
||||
lastPlayed: Date.now(),
|
||||
};
|
||||
const newState = createNewState(today);
|
||||
setGameState(newState);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newState));
|
||||
}
|
||||
@@ -116,8 +132,6 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
case 6: newStats.solvedIn6++; break;
|
||||
case 7: newStats.solvedIn7++; break;
|
||||
default:
|
||||
// For custom attempts > 7, we currently don't have specific stats buckets
|
||||
// We could add a 'solvedInOther' or just ignore for now
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
@@ -135,12 +149,43 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
const isSolved = correct;
|
||||
const isFailed = !correct && newGuesses.length >= maxAttempts;
|
||||
|
||||
let newScore = gameState.score;
|
||||
const newBreakdown = [...gameState.scoreBreakdown];
|
||||
|
||||
if (correct) {
|
||||
newScore += 20;
|
||||
newBreakdown.push({ value: 20, reason: 'Correct Answer' });
|
||||
} else {
|
||||
if (guess === 'SKIPPED') {
|
||||
newScore -= 5;
|
||||
newBreakdown.push({ value: -5, reason: 'Skip' });
|
||||
} else {
|
||||
newScore -= 3;
|
||||
newBreakdown.push({ value: -3, reason: 'Wrong guess' });
|
||||
}
|
||||
}
|
||||
|
||||
// If failed, reset score to 0
|
||||
if (isFailed) {
|
||||
if (newScore > 0) {
|
||||
newBreakdown.push({ value: -newScore, reason: 'Game Over' });
|
||||
newScore = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure score doesn't go below 0
|
||||
newScore = Math.max(0, newScore);
|
||||
|
||||
const newState = {
|
||||
...gameState,
|
||||
guesses: newGuesses,
|
||||
isSolved,
|
||||
isFailed,
|
||||
lastPlayed: Date.now(),
|
||||
score: newScore,
|
||||
scoreBreakdown: newBreakdown,
|
||||
// Update skip count if skipped
|
||||
skipCount: guess === 'SKIPPED' ? gameState.skipCount + 1 : gameState.skipCount
|
||||
};
|
||||
|
||||
saveState(newState);
|
||||
@@ -151,5 +196,66 @@ export function useGameState(genre: string | null = null, maxAttempts: number =
|
||||
}
|
||||
};
|
||||
|
||||
return { gameState, statistics, addGuess };
|
||||
const giveUp = () => {
|
||||
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
let newScore = 0;
|
||||
const newBreakdown = [...gameState.scoreBreakdown];
|
||||
|
||||
if (gameState.score > 0) {
|
||||
newBreakdown.push({ value: -gameState.score, reason: 'Gave Up' });
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...gameState,
|
||||
isFailed: true,
|
||||
score: 0,
|
||||
scoreBreakdown: newBreakdown,
|
||||
lastPlayed: Date.now()
|
||||
};
|
||||
saveState(newState);
|
||||
updateStatistics(gameState.guesses.length, false);
|
||||
};
|
||||
|
||||
const addReplay = () => {
|
||||
if (!gameState || gameState.isSolved || gameState.isFailed) return;
|
||||
|
||||
let newScore = gameState.score - 1;
|
||||
// Ensure score doesn't go below 0
|
||||
newScore = Math.max(0, newScore);
|
||||
|
||||
const newBreakdown = [...gameState.scoreBreakdown, { value: -1, reason: 'Replay snippet' }];
|
||||
|
||||
const newState = {
|
||||
...gameState,
|
||||
replayCount: gameState.replayCount + 1,
|
||||
score: newScore,
|
||||
scoreBreakdown: newBreakdown
|
||||
};
|
||||
saveState(newState);
|
||||
};
|
||||
|
||||
const addYearBonus = (correct: boolean) => {
|
||||
if (!gameState) return;
|
||||
|
||||
let newScore = gameState.score;
|
||||
const newBreakdown = [...gameState.scoreBreakdown];
|
||||
|
||||
if (correct) {
|
||||
newScore += 10;
|
||||
newBreakdown.push({ value: 10, reason: 'Bonus: Correct Year' });
|
||||
} else {
|
||||
newBreakdown.push({ value: 0, reason: 'Bonus: Wrong Year' });
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...gameState,
|
||||
score: newScore,
|
||||
scoreBreakdown: newBreakdown,
|
||||
yearGuessed: true
|
||||
};
|
||||
saveState(newState);
|
||||
};
|
||||
|
||||
return { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus };
|
||||
}
|
||||
|
||||
121
lib/musicbrainz.ts
Normal file
121
lib/musicbrainz.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* MusicBrainz API integration for fetching release years
|
||||
* API Documentation: https://musicbrainz.org/doc/MusicBrainz_API
|
||||
* Rate Limiting: 50 requests per second for meaningful User-Agent strings
|
||||
*/
|
||||
|
||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
||||
const RATE_LIMIT_DELAY = 25; // 25ms between requests = ~40 req/s (safe margin)
|
||||
|
||||
/**
|
||||
* Sleep utility for rate limiting
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry logic for HTTP 503 (rate limit exceeded)
|
||||
*/
|
||||
async function fetchWithRetry(url: string, maxRetries = 5): Promise<Response> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// If rate limited (503), wait with exponential backoff
|
||||
if (response.status === 503) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s, 8s, 16s
|
||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the earliest release year for a song from MusicBrainz
|
||||
* @param artist Artist name
|
||||
* @param title Song title
|
||||
* @returns Release year or null if not found
|
||||
*/
|
||||
export async function getReleaseYear(artist: string, title: string): Promise<number | null> {
|
||||
try {
|
||||
// Build search query using Lucene syntax
|
||||
const query = `artist:"${artist}" AND recording:"${title}"`;
|
||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
|
||||
|
||||
// Add rate limiting delay
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
|
||||
const response = await fetchWithRetry(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.recordings || data.recordings.length === 0) {
|
||||
console.log(`No recordings found for "${title}" by "${artist}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find the earliest release year from all recordings
|
||||
let earliestYear: number | null = null;
|
||||
|
||||
for (const recording of data.recordings) {
|
||||
// Check if recording has releases
|
||||
if (recording.releases && recording.releases.length > 0) {
|
||||
for (const release of recording.releases) {
|
||||
if (release.date) {
|
||||
// Extract year from date (format: YYYY-MM-DD or YYYY)
|
||||
const year = parseInt(release.date.split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check first-release-date on the recording itself
|
||||
if (recording['first-release-date']) {
|
||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (earliestYear) {
|
||||
console.log(`Found release year ${earliestYear} for "${title}" by "${artist}"`);
|
||||
} else {
|
||||
console.log(`No release year found for "${title}" by "${artist}"`);
|
||||
}
|
||||
|
||||
return earliestYear;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching release year for "${title}" by "${artist}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_DailyPuzzle" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"date" TEXT NOT NULL,
|
||||
"songId" INTEGER NOT NULL,
|
||||
"genreId" INTEGER,
|
||||
"specialId" INTEGER,
|
||||
CONSTRAINT "DailyPuzzle_songId_fkey" FOREIGN KEY ("songId") REFERENCES "Song" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "DailyPuzzle_genreId_fkey" FOREIGN KEY ("genreId") REFERENCES "Genre" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "DailyPuzzle_specialId_fkey" FOREIGN KEY ("specialId") REFERENCES "Special" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_DailyPuzzle" ("date", "genreId", "id", "songId", "specialId") SELECT "date", "genreId", "id", "songId", "specialId" FROM "DailyPuzzle";
|
||||
DROP TABLE "DailyPuzzle";
|
||||
ALTER TABLE "new_DailyPuzzle" RENAME TO "DailyPuzzle";
|
||||
CREATE UNIQUE INDEX "DailyPuzzle_date_genreId_specialId_key" ON "DailyPuzzle"("date", "genreId", "specialId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
@@ -16,6 +16,7 @@ model Song {
|
||||
artist String
|
||||
filename String // Filename in public/uploads
|
||||
coverImage String? // Filename in public/uploads/covers
|
||||
releaseYear Int? // Release year from MusicBrainz
|
||||
createdAt DateTime @default(now())
|
||||
puzzles DailyPuzzle[]
|
||||
genres Genre[]
|
||||
@@ -62,7 +63,7 @@ model DailyPuzzle {
|
||||
id Int @id @default(autoincrement())
|
||||
date String // Format: YYYY-MM-DD
|
||||
songId Int
|
||||
song Song @relation(fields: [songId], references: [id])
|
||||
song Song @relation(fields: [songId], references: [id], onDelete: Cascade)
|
||||
genreId Int?
|
||||
genre Genre? @relation(fields: [genreId], references: [id])
|
||||
specialId Int?
|
||||
|
||||
@@ -7,6 +7,12 @@ echo "Starting deployment..."
|
||||
echo "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Run release year migration (only if not already done)
|
||||
if [ ! -f /app/.release-years-migrated ]; then
|
||||
echo "Running release year migration (this will take ~12 seconds for 600 songs)..."
|
||||
node scripts/migrate-release-years.mjs
|
||||
fi
|
||||
|
||||
# Start the application
|
||||
echo "Starting application..."
|
||||
exec node server.js
|
||||
|
||||
190
scripts/migrate-release-years.mjs
Normal file
190
scripts/migrate-release-years.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// --- MusicBrainz Logic (Embedded to avoid TS import issues in Docker) ---
|
||||
|
||||
const MUSICBRAINZ_API_BASE = 'https://musicbrainz.org/ws/2';
|
||||
const USER_AGENT = 'hoerdle/0.1.0 ( elpatron@mailbox.org )';
|
||||
const RATE_LIMIT_DELAY = 25; // 25ms between requests
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, maxRetries = 5) {
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 503) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Rate limited (503), waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries - 1) {
|
||||
const waitTime = Math.pow(2, attempt) * 1000;
|
||||
console.log(`Network error, waiting ${waitTime}ms before retry ${attempt + 1}/${maxRetries}...`);
|
||||
await sleep(waitTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Max retries exceeded');
|
||||
}
|
||||
|
||||
async function getReleaseYear(artist, title) {
|
||||
try {
|
||||
const query = `artist:"${artist}" AND recording:"${title}"`;
|
||||
const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=10`;
|
||||
|
||||
await sleep(RATE_LIMIT_DELAY);
|
||||
|
||||
const response = await fetchWithRetry(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`MusicBrainz API error: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.recordings || data.recordings.length === 0) {
|
||||
// console.log(`No recordings found for "${title}" by "${artist}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let earliestYear = null;
|
||||
|
||||
for (const recording of data.recordings) {
|
||||
if (recording.releases && recording.releases.length > 0) {
|
||||
for (const release of recording.releases) {
|
||||
if (release.date) {
|
||||
const year = parseInt(release.date.split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (recording['first-release-date']) {
|
||||
const year = parseInt(recording['first-release-date'].split('-')[0]);
|
||||
if (!isNaN(year) && (earliestYear === null || year < earliestYear)) {
|
||||
earliestYear = year;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earliestYear;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching release year for "${title}" by "${artist}":`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Migration Logic ---
|
||||
|
||||
async function migrate() {
|
||||
console.log('🎵 Starting release year migration...');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
try {
|
||||
// Find songs without release year
|
||||
const songs = await prisma.song.findMany({
|
||||
where: {
|
||||
releaseYear: null
|
||||
},
|
||||
orderBy: {
|
||||
id: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📊 Found ${songs.length} songs without release year.\n`);
|
||||
|
||||
if (songs.length === 0) {
|
||||
console.log('✅ All songs already have release years!');
|
||||
await createFlagFile();
|
||||
return;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
let successful = 0;
|
||||
let failed = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (const song of songs) {
|
||||
processed++;
|
||||
// const progress = `[${processed}/${songs.length}]`;
|
||||
|
||||
try {
|
||||
// console.log(`${progress} Processing: "${song.title}" by "${song.artist}"`);
|
||||
|
||||
const releaseYear = await getReleaseYear(song.artist, song.title);
|
||||
|
||||
if (releaseYear) {
|
||||
await prisma.song.update({
|
||||
where: { id: song.id },
|
||||
data: { releaseYear }
|
||||
});
|
||||
successful++;
|
||||
// console.log(` ✅ Updated with year: ${releaseYear}`);
|
||||
} else {
|
||||
failed++;
|
||||
// console.log(` ⚠️ No release year found`);
|
||||
}
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(` ❌ Error processing song:`, error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
// Progress update every 10 songs (less verbose)
|
||||
if (processed % 10 === 0 || processed === songs.length) {
|
||||
const elapsed = Math.round((Date.now() - startTime) / 1000);
|
||||
const rate = processed / (elapsed || 1);
|
||||
const remaining = songs.length - processed;
|
||||
const eta = Math.round(remaining / rate);
|
||||
process.stdout.write(`\r📈 Progress: ${processed}/${songs.length} | Success: ${successful} | Failed: ${failed} | ETA: ${eta}s`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Math.round((Date.now() - startTime) / 1000);
|
||||
console.log('\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('✅ Migration completed!');
|
||||
console.log(`📊 Total: ${processed} | Success: ${successful} | Failed: ${failed}`);
|
||||
console.log(`⏱️ Time: ${totalTime}s (${(processed / (totalTime || 1)).toFixed(2)} songs/s)`);
|
||||
|
||||
await createFlagFile();
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function createFlagFile() {
|
||||
const flagPath = path.join(process.cwd(), '.release-years-migrated');
|
||||
await writeFile(flagPath, new Date().toISOString());
|
||||
console.log(`\n🏁 Created flag file: ${flagPath}`);
|
||||
}
|
||||
|
||||
migrate();
|
||||
Reference in New Issue
Block a user