99 lines
3.5 KiB
TypeScript
99 lines
3.5 KiB
TypeScript
// 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<ITunesAlbum | null> {
|
||
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<ITunesSearchResponse>(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`);
|
||
}
|