feat: remove MusicBrainz integration and exclusively use iTunes for song release years
This commit is contained in:
@@ -217,14 +217,6 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
if (releaseYear) {
|
if (releaseYear) {
|
||||||
console.log(`Fetched release year ${releaseYear} from iTunes for "${title}" by "${artist}"`);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to fetch release year:', e);
|
console.error('Failed to fetch release year:', e);
|
||||||
|
|||||||
@@ -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<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,7 +29,7 @@ export function middleware(request: NextRequest) {
|
|||||||
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
"style-src 'self' 'unsafe-inline'", // Allow inline styles
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self' data:",
|
"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:",
|
"media-src 'self' blob:",
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
].join('; ');
|
].join('; ');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ model Song {
|
|||||||
artist String
|
artist String
|
||||||
filename String // Filename in public/uploads
|
filename String // Filename in public/uploads
|
||||||
coverImage String? // Filename in public/uploads/covers
|
coverImage String? // Filename in public/uploads/covers
|
||||||
releaseYear Int? // Release year from MusicBrainz
|
releaseYear Int? // Release year from iTunes
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
puzzles DailyPuzzle[]
|
puzzles DailyPuzzle[]
|
||||||
genres Genre[]
|
genres Genre[]
|
||||||
|
|||||||
@@ -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();
|
|
||||||
Reference in New Issue
Block a user