// iTunes Search API client (read-only). // // The iTunes Search API is free, requires no API key, and has excellent // coverage — nearly every released album is present. We use the album search // to resolve cover art URLs. The default artwork URL returned is 100×100, but // can be upgraded to 600×600 by swapping the size suffix in the URL string. // // Rate limit: Apple asks for "moderate" use (~20 req/min). We enforce a polite // per-host interval via the http base. // // Best-effort: on any error, methods log a warning and return null rather than // throwing, so enrichment never breaks the worker. import { buildUserAgent, requestJson } from './http.js'; /** A normalised iTunes album search result (only the fields we read). */ export interface ITunesAlbum { /** Artist canonical name. */ artistName: string; /** Album / collection title. */ collectionName: string; /** Cover art URL (100×100 — caller upscales). */ artworkUrl100: string; } interface ITunesSearchResult { artistName?: string; collectionName?: string; artworkUrl100?: string; } interface ITunesSearchResponse { results?: ITunesSearchResult[]; } export class ITunesClient { private readonly baseUrl = 'https://itunes.apple.com'; private readonly userAgent: string; private readonly minIntervalMs: number; constructor(contact = '', minIntervalMs = 3000) { this.userAgent = buildUserAgent(contact); this.minIntervalMs = minIntervalMs; } /** * Search the iTunes catalog for an album by artist + title and return the * best match, or null if no match / error. The returned `artworkUrl100` is * the 100×100 thumbnail; callers upscale it to 600×600 via `upscaleArtwork`. */ async searchAlbum(artist: string, album: string): Promise { if (artist.trim() === '' || album.trim() === '') return null; // The iTunes search `term` is a free-text query; we quote both values to // narrow the match. entity=album restricts to album collections. const term = `${artist} ${album}`; const qs = new URLSearchParams({ term, entity: 'album', limit: '5' }); const url = `${this.baseUrl}/search?${qs.toString()}`; try { const data = await requestJson(url, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs, }); const results = data.results ?? []; if (results.length === 0) return null; // Prefer an exact title match (case-insensitive) to avoid grabbing a // "Greatest Hits" when searching for a studio album, then fall back to // the first result. const lowerAlbum = album.toLowerCase(); const exact = results.find( (r) => r.collectionName?.toLowerCase() === lowerAlbum ); const best = exact ?? results[0]; if (!best.artworkUrl100) return null; return { artistName: best.artistName ?? artist, collectionName: best.collectionName ?? album, artworkUrl100: best.artworkUrl100, }; } catch (err) { console.warn('[iTunes] searchAlbum failed:', (err as Error).message); return null; } } } /** * Upscale an iTunes 100×100 artwork URL to a larger size. iTunes serves * artwork from a CDN that supports arbitrary sizes via the `NNNxNNN` path * segment (e.g. 600x600). Returns the input unchanged if it doesn't match the * expected shape. */ export function upscaleITunesArtwork(url: string, size = 600): string { return url.replace(/\/\d+x\d+(bb)?\.(jpg|png)$/, `/${size}x${size}bb.$2`); }