// Deezer API client (read-only). // // Deezer provides a free, no-auth search API with high-quality cover art // (up to 1000×1000 via the `cover_xl` field). Coverage is very good for // European and mainstream releases. // // Rate limit: Deezer doesn't document a hard limit for the public search API, // but ~50 req/min is a safe polite default, enforced 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 Deezer album search result (only the fields we read). */ export interface DeezerAlbum { artistName: string; title: string; /** 1000×1000 cover URL. */ coverXl: string; /** 500×500 cover URL (fallback). */ coverBig: string; } /** An album in an artist's discography (only the fields we read). */ export interface DeezerAlbumRelease { id: number; title: string; /** ISO date, YYYY-MM-DD. */ releaseDate: string; /** 'album' | 'single' | 'ep' | 'compilation' as reported by Deezer. */ recordType: string; } interface DeezerSearchResult { artist?: { name?: string }; title?: string; cover_xl?: string; cover_big?: string; } interface DeezerSearchResponse { data?: DeezerSearchResult[]; } /** Lowercase and strip diacritics, so a query for "Bjork" matches "Björk". */ const foldName = (name: string) => name.trim().toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, ''); export class DeezerClient { private readonly baseUrl = 'https://api.deezer.com'; private readonly userAgent: string; private readonly minIntervalMs: number; constructor(contact = '', minIntervalMs = 1200) { this.userAgent = buildUserAgent(contact); this.minIntervalMs = minIntervalMs; } /** * Search the Deezer catalog for an album by artist + title and return the * best match, or null if no match / error. * * Deezer's search `q` supports the fielded syntax `artist:"…" album:"…"` * which gives precise matches. */ async searchAlbum(artist: string, album: string): Promise { if (artist.trim() === '' || album.trim() === '') return null; const q = `artist:"${artist}" album:"${album}"`; const qs = new URLSearchParams({ q }); const url = `${this.baseUrl}/search/album?${qs.toString()}`; try { const data = await requestJson(url, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs, }); const results = data.data ?? []; if (results.length === 0) return null; const best = results[0]; const coverXl = best.cover_xl ?? best.cover_big ?? null; const coverBig = best.cover_big ?? best.cover_xl ?? null; if (!coverXl) return null; return { artistName: best.artist?.name ?? artist, title: best.title ?? album, coverXl, coverBig: coverBig ?? coverXl, }; } catch (err) { console.warn('[Deezer] searchAlbum failed:', (err as Error).message); return null; } } /** * Artist photo (1000×1000 `picture_xl`) by name, or null. * * ponytail: exact case-insensitive name match only. Deezer's artist search is * fuzzy and happily returns a tribute band for a near miss, so a wrong photo * is worse than the placeholder. Loosen it only if real artists get skipped. */ async searchArtistImage(artist: string): Promise { if (artist.trim() === '') return null; const qs = new URLSearchParams({ q: artist }); try { const data = await requestJson<{ data?: { name?: string; picture_xl?: string; picture_big?: string }[] }>( `${this.baseUrl}/search/artist?${qs.toString()}`, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs } ); const want = foldName(artist); for (const a of data.data ?? []) { if (foldName(a.name ?? '') !== want) continue; // Deezer serves a blank grey tile for artists it has no photo for: the // path is either empty or the md5 of the empty string. Both are useless, // and the same name can appear twice with only the second one real. const url = a.picture_xl ?? a.picture_big ?? ''; if (url && !url.includes('d41d8cd98f00b204e9800998ecf8427e') && !url.includes('/artist//')) return url; } return null; } catch (err) { console.warn('[Deezer] searchArtistImage failed:', (err as Error).message); return null; } } /** Resolve an artist name to a Deezer artist id via exact fold-matched search. */ private async resolveArtistId(artist: string): Promise { if (artist.trim() === '') return null; const qs = new URLSearchParams({ q: artist }); try { const data = await requestJson<{ data?: { id?: number; name?: string }[] }>( `${this.baseUrl}/search/artist?${qs.toString()}`, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs } ); const want = foldName(artist); for (const a of data.data ?? []) { if (foldName(a.name ?? '') === want && typeof a.id === 'number') return a.id; } return null; } catch (err) { console.warn('[Deezer] resolveArtistId failed:', (err as Error).message); return null; } } /** * Albums by `artist` released on/after `sinceIso` (YYYY-MM-DD), newest first. * * ponytail: reads only the first page (25 albums). Deezer returns albums * newest-first, so a release-date cutoff never needs page two unless an * artist dropped 25 albums inside the window. Paginate if that ever happens. */ async getArtistAlbumsSince(artist: string, sinceIso: string): Promise { const artistId = await this.resolveArtistId(artist); if (artistId === null) return []; try { const data = await requestJson<{ data?: { id?: number; title?: string; release_date?: string; record_type?: string }[]; }>(`${this.baseUrl}/artist/${artistId}/albums?limit=25`, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs, }); return (data.data ?? []) .filter((a) => typeof a.id === 'number' && a.title && a.release_date && a.release_date >= sinceIso) .map((a) => ({ id: a.id as number, title: a.title as string, releaseDate: a.release_date as string, recordType: a.record_type ?? 'album', })); } catch (err) { console.warn('[Deezer] getArtistAlbumsSince failed:', (err as Error).message); return []; } } /** Track titles on a Deezer album, in tracklist order. */ async getAlbumTracks(albumId: number): Promise { try { const data = await requestJson<{ data?: { title?: string }[] }>( `${this.baseUrl}/album/${albumId}/tracks?limit=50`, { userAgent: this.userAgent, minIntervalMs: this.minIntervalMs } ); return (data.data ?? []).map((t) => t.title ?? '').filter((t) => t !== ''); } catch (err) { console.warn('[Deezer] getAlbumTracks failed:', (err as Error).message); return []; } } }