From 3309b5c5eefefc3004c33cdf7abd8fde892bf58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=B6rdle=20Bot?= Date: Mon, 24 Nov 2025 14:23:07 +0100 Subject: [PATCH] feat: implement iTunes API for release year detection and bulk refresh --- app/admin/page.tsx | 68 ++++++++++++++++++++++++ app/api/admin/refresh-years/route.ts | 78 +++++++++++++++++++++++++++ app/api/songs/route.ts | 20 +++++-- docker-compose.example.yml | 2 +- lib/itunes.ts | 79 ++++++++++++++++++++++++++++ scripts/docker-entrypoint.sh | 5 +- 6 files changed, 242 insertions(+), 10 deletions(-) create mode 100644 app/api/admin/refresh-years/route.ts create mode 100644 lib/itunes.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 4d212a5..a68cdf5 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1604,6 +1604,74 @@ export default function AdminPage() { > ☢️ Rebuild Database + +
+

+ Update release years for all songs using the iTunes API. This will overwrite existing years. +

+ +
); diff --git a/app/api/admin/refresh-years/route.ts b/app/api/admin/refresh-years/route.ts new file mode 100644 index 0000000..871ac01 --- /dev/null +++ b/app/api/admin/refresh-years/route.ts @@ -0,0 +1,78 @@ + +import { NextResponse } from 'next/server'; +import { PrismaClient } from '@prisma/client'; +import { requireAdminAuth } from '@/lib/auth'; +import { getReleaseYearFromItunes } from '@/lib/itunes'; + +const prisma = new PrismaClient(); + +// Helper to delay execution to avoid rate limits +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export async function POST(request: Request) { + // Check authentication + const authError = await requireAdminAuth(request as any); + if (authError) return authError; + + try { + const { offset = 0, limit = 20 } = await request.json(); + + // Fetch batch of songs + const songs = await prisma.song.findMany({ + select: { id: true, title: true, artist: true }, + orderBy: { id: 'asc' }, + skip: offset, + take: limit + }); + + const totalSongs = await prisma.song.count(); + + console.log(`Processing batch: offset=${offset}, limit=${limit}, found=${songs.length}`); + + let updatedCount = 0; + let failedCount = 0; + let skippedCount = 0; + const results = []; + + for (const song of songs) { + try { + // Rate limiting: wait 500ms between requests to be safe + await sleep(500); + + const year = await getReleaseYearFromItunes(song.artist, song.title); + + if (year) { + await prisma.song.update({ + where: { id: song.id }, + data: { releaseYear: year } + }); + updatedCount++; + results.push({ id: song.id, title: song.title, artist: song.artist, year, status: 'updated' }); + } else { + skippedCount++; + results.push({ id: song.id, title: song.title, artist: song.artist, status: 'not_found' }); + } + } catch (error) { + console.error(`Failed to update year for ${song.title} - ${song.artist}:`, error); + failedCount++; + results.push({ id: song.id, title: song.title, artist: song.artist, status: 'error' }); + } + } + + return NextResponse.json({ + success: true, + processed: songs.length, + total: totalSongs, + hasMore: offset + songs.length < totalSongs, + nextOffset: offset + songs.length, + updated: updatedCount, + failed: failedCount, + skipped: skippedCount, + results + }); + + } catch (error) { + console.error('Error refreshing release years:', error); + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/songs/route.ts b/app/api/songs/route.ts index 1173e78..8a85e53 100644 --- a/app/api/songs/route.ts +++ b/app/api/songs/route.ts @@ -208,16 +208,26 @@ export async function POST(request: Request) { console.error('Failed to extract cover image:', e); } - // Fetch release year from MusicBrainz + // Fetch release year (iTunes first, then MusicBrainz) let releaseYear = null; try { - const { getReleaseYear } = await import('@/lib/musicbrainz'); - releaseYear = await getReleaseYear(artist, title); + // Try iTunes first + const { getReleaseYearFromItunes } = await import('@/lib/itunes'); + releaseYear = await getReleaseYearFromItunes(artist, title); + if (releaseYear) { - console.log(`Fetched release year ${releaseYear} 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) { - console.error('Failed to fetch release year from MusicBrainz:', e); + console.error('Failed to fetch release year:', e); } const song = await prisma.song.create({ diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 3736545..28286d5 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -26,4 +26,4 @@ services: start_period: 40s # Run migrations and start server (auto-baseline on first run if needed) command: > - sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node scripts/migrate-release-years.mjs && node scripts/migrate-covers.mjs && node server.js" + sh -c "npx prisma migrate deploy || (echo 'Baselining existing database...' && sh scripts/baseline-migrations.sh && npx prisma migrate deploy) && node scripts/migrate-covers.mjs && node server.js" diff --git a/lib/itunes.ts b/lib/itunes.ts new file mode 100644 index 0000000..0c696ba --- /dev/null +++ b/lib/itunes.ts @@ -0,0 +1,79 @@ + +/** + * iTunes Search API integration for fetching release years + * API Documentation: https://performance-partners.apple.com/search-api + */ + +interface ItunesResult { + wrapperType: string; + kind: string; + artistName: string; + collectionName: string; + trackName: string; + releaseDate: string; + primaryGenreName: string; +} + +interface ItunesResponse { + resultCount: number; + results: ItunesResult[]; +} + +/** + * Get the earliest release year for a song from iTunes + * @param artist Artist name + * @param title Song title + * @returns Release year or null if not found + */ +export async function getReleaseYearFromItunes(artist: string, title: string): Promise { + try { + // Construct search URL + // entity=song ensures we get individual tracks + // limit=10 to get a good range of potential matches (originals, remasters, best ofs) + const term = encodeURIComponent(`${artist} ${title}`); + const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=10`; + + const response = await fetch(url); + + if (!response.ok) { + console.error(`iTunes API error: ${response.status} ${response.statusText}`); + return null; + } + + const data: ItunesResponse = await response.json(); + + if (data.resultCount === 0) { + return null; + } + + // Filter for exact(ish) matches to avoid wrong songs + // and find the earliest release date + let earliestYear: number | null = null; + const normalizedTitle = title.toLowerCase().replace(/[^\w\s]/g, ''); + const normalizedArtist = artist.toLowerCase().replace(/[^\w\s]/g, ''); + + for (const result of data.results) { + // Basic validation that it's the right song + const resTitle = result.trackName.toLowerCase().replace(/[^\w\s]/g, ''); + const resArtist = result.artistName.toLowerCase().replace(/[^\w\s]/g, ''); + + // Check if title and artist are contained in the result (fuzzy match) + if (resTitle.includes(normalizedTitle) && resArtist.includes(normalizedArtist)) { + if (result.releaseDate) { + const year = new Date(result.releaseDate).getFullYear(); + if (!isNaN(year)) { + if (earliestYear === null || year < earliestYear) { + earliestYear = year; + } + } + } + } + } + + return earliestYear; + + } catch (error) { + console.error(`Error fetching release year from iTunes for "${title}" by "${artist}":`, error); + return null; + } +} diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index ecb2a88..6c45d9f 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -7,10 +7,7 @@ echo "Starting deployment..." echo "Running database migrations..." npx prisma migrate deploy -# Run release year migration (only if not already done) -# Run release year migration (idempotent, skips if all done) -echo "Running release year migration check..." -node scripts/migrate-release-years.mjs + # Start the application echo "Starting application..."