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..."