diff --git a/.release-years-migrated b/.release-years-migrated new file mode 100644 index 0000000..f4af870 --- /dev/null +++ b/.release-years-migrated @@ -0,0 +1 @@ +2025-11-23T18:51:44.711Z \ No newline at end of file diff --git a/README.md b/README.md index d9659ce..3d3f81a 100644 --- a/README.md +++ b/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) diff --git a/app/actions.ts b/app/actions.ts index 32ebd5f..605b8af 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -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', diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 126fa1a..5d1f1ed 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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(null); const [editTitle, setEditTitle] = useState(''); const [editArtist, setEditArtist] = useState(''); + const [editReleaseYear, setEditReleaseYear] = useState(''); const [editGenreIds, setEditGenreIds] = useState([]); const [editSpecialIds, setEditSpecialIds] = useState([]); @@ -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' ? '↑' : '↓')} - - Genres + handleSort('releaseYear')} + > + Year {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')} + + Genres / Specials handleSort('createdAt')} @@ -1263,6 +1278,16 @@ export default function AdminPage() { placeholder="Artist" /> + + setEditReleaseYear(e.target.value === '' ? '' : Number(e.target.value))} + className="form-input" + style={{ padding: '0.25rem', width: '80px' }} + placeholder="Year" + /> +
{genres.map(genre => ( @@ -1369,6 +1394,9 @@ export default function AdminPage() { })}
+ + {song.releaseYear || '-'} +
{song.genres?.map(g => ( diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 2e21076..4ca9f30 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -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 })) diff --git a/components/AudioPlayer.tsx b/components/AudioPlayer.tsx index c9c98b5..32056ab 100644 --- a/components/AudioPlayer.tsx +++ b/components/AudioPlayer.tsx @@ -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(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); }; diff --git a/components/Game.tsx b/components/Game.tsx index f49280b..ed8ada8 100644 --- a/components/Game.tsx +++ b/components/Game.tsx @@ -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
Loading state...
; 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
-
Attempt {gameState.guesses.length + 1} / {maxAttempts} {unlockedSeconds}s unlocked
+ + +
@@ -253,7 +275,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max {!hasWon && !hasLost && ( <> - {gameState.guesses.length < 6 ? ( + {gameState.guesses.length < maxAttempts - 1 ? (
)} - - {hasLost && ( -
-

Game Over

-

The song was:

- - {/* Song Details */} -
- Album Cover -

{dailyPuzzle.title}

-

{dailyPuzzle.artist}

- -
- - {/* Rating Component */} -
- -
- - {statistics && } - -
- )} - + + {showYearModal && dailyPuzzle.releaseYear && ( + + )} + + ); +} + +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 ( +
+ {expression} = + {score} +
+ ); +} + +function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) { + const [options, setOptions] = useState([]); + + useEffect(() => { + const currentYear = new Date().getFullYear(); + const minYear = 1950; + + const closeOptions = new Set(); + 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 ( +
+
+

Bonus Round!

+

Guess the release year for +10 points!

+ +
+ {options.map(year => ( + + ))} +
+ + +
); } diff --git a/lib/dailyPuzzle.ts b/lib/dailyPuzzle.ts index aa3378b..f7b8a41 100644 --- a/lib/dailyPuzzle.ts +++ b/lib/dailyPuzzle.ts @@ -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), diff --git a/lib/gameState.ts b/lib/gameState.ts index 789ab6a..407c729 100644 --- a/lib/gameState.ts +++ b/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(null); const [statistics, setStatistics] = useState(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 }; } diff --git a/lib/musicbrainz.ts b/lib/musicbrainz.ts new file mode 100644 index 0000000..34cc8e5 --- /dev/null +++ b/lib/musicbrainz.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/prisma/migrations/20251123181922_add_release_year/migration.sql b/prisma/migrations/20251123181922_add_release_year/migration.sql new file mode 100644 index 0000000..dca4873 --- /dev/null +++ b/prisma/migrations/20251123181922_add_release_year/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Song" ADD COLUMN "releaseYear" INTEGER; diff --git a/prisma/migrations/20251123204000_fix_cascade_delete/migration.sql b/prisma/migrations/20251123204000_fix_cascade_delete/migration.sql new file mode 100644 index 0000000..795b1f7 --- /dev/null +++ b/prisma/migrations/20251123204000_fix_cascade_delete/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78b85f3..1caf7d1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index e2855e4..599e268 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -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 diff --git a/scripts/migrate-release-years.mjs b/scripts/migrate-release-years.mjs new file mode 100644 index 0000000..b9091e6 --- /dev/null +++ b/scripts/migrate-release-years.mjs @@ -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();