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() { 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();