diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 8a85e53..5a832fb 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -217,14 +217,6 @@ export async function POST(request: Request) { if (releaseYear) { console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`); - } else { - // Fallback to MusicBrainz - console.log(`iTunes yielded no year, falling back to MusicBrainz for "${title}" by "${artist}"`); - const { getReleaseYear } = await import('@/lib/musicbrainz'); - releaseYear = await getReleaseYear(artist, title); - if (releaseYear) { - console.log(`Fetched release year ${releaseYear} from MusicBrainz for "${title}" by "${artist}"`); - } } } catch (e) { console.error('Failed to fetch release year:', e); diff --git a/lib/musicbrainz.ts b/lib/musicbrainz.ts deleted file mode 100644 index 34cc8e5..0000000 --- a/lib/musicbrainz.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * 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/middleware.ts b/middleware.ts index 3442f70..cb704ee 100644 --- a/middleware.ts +++ b/middleware.ts @@ -29,7 +29,7 @@ export function middleware(request: NextRequest) { "style-src 'self' 'unsafe-inline'", // Allow inline styles "img-src 'self' data: blob:", "font-src 'self' data:", - "connect-src 'self' https://openrouter.ai https://gotify.example.com https://musicbrainz.org", + "connect-src 'self' https://openrouter.ai https://gotify.example.com", "media-src 'self' blob:", "frame-ancestors 'self'", ].join('; '); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1caf7d1..5f441e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,7 +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 + releaseYear Int? // Release year from iTunes createdAt DateTime @default(now()) puzzles DailyPuzzle[] genres Genre[] diff --git a/scripts/migrate-release-years.mjs b/scripts/migrate-release-years.mjs deleted file mode 100644 index c1f7ca2..0000000 --- a/scripts/migrate-release-years.mjs +++ /dev/null @@ -1,220 +0,0 @@ -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 = 250; // 250ms between requests (very conservative) - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -async function fetchWithRetry(url, maxRetries = 10) { - 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) { - const search = async (query, type) => { - const url = `${MUSICBRAINZ_API_BASE}/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`; - await sleep(RATE_LIMIT_DELAY); - const response = await fetchWithRetry(url); - if (!response.ok) throw new Error(`API Error ${response.status}: ${response.statusText}`); - return response.json(); - }; - - try { - // 1. Strict Search - let data = await search(`artist:"${artist}" AND recording:"${title}"`, 'strict'); - - // 2. Fallback: Fuzzy Search if no recordings found - if (!data.recordings || data.recordings.length === 0) { - // Remove special chars and quotes for fuzzy search - const cleanArtist = artist.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); - const cleanTitle = title.replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); - - // Only try fuzzy if the cleaned strings are valid - if (cleanArtist && cleanTitle) { - console.log(` Trying fuzzy search for: ${cleanTitle} by ${cleanArtist}`); - data = await search(`artist:${cleanArtist} AND recording:${cleanTitle}`, 'fuzzy'); - } - } - - 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) { - // Check releases linked to recording - 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; - } - } - } - } - - // Check first-release-date on 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 year: ${earliestYear}`); - } else { - console.log(` ⚠️ Recordings found but NO YEAR for "${title}" by "${artist}"`); - } - - return earliestYear; - } catch (error) { - console.error(` ❌ Error fetching release year for "${title}" by "${artist}":`, error.message); - return null; - } -} - -// --- Migration Logic --- - -async function migrate() { - // Check if migration already ran - const flagPath = path.join(process.cwd(), '.release-years-migrated'); - try { - const { access } = await import('fs/promises'); - await access(flagPath); - console.log('✅ Release year migration already completed (flag file exists). Skipping...'); - await prisma.$disconnect(); - return; - } catch { - // Flag file doesn't exist, proceed with migration - } - - 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 for "${song.title}" by "${song.artist}"`); - } - } 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();