diff --git a/backend/src/routes/images.routes.ts b/backend/src/routes/images.routes.ts index 5021335..4fca521 100644 --- a/backend/src/routes/images.routes.ts +++ b/backend/src/routes/images.routes.ts @@ -10,12 +10,24 @@ const ALLOWED_HOSTS = new Set([ 'images.genius.com', 'commons.wikimedia.org', 'e.snmc.io', + // Cover Art Archive 302s to the apex host, which then redirects on to an + // ia*.us.archive.org node. The suffix below covers the node, not the apex. + 'archive.org', ]); // Wildcard suffixes — any subdomain of these is allowed. +// +// The CDNs below are where enrichment actually stores artwork: of 572 albums +// with a cover, 126 sit on mzstatic (iTunes), 115 on dzcdn (Deezer) and 26 on +// discogs, and every coverartarchive.org URL 302s to an ia*.us.archive.org +// node. Without these the proxy answered 403 for every cover in the library. const ALLOWED_SUFFIXES = [ '.coverartarchive.org', '.musicbrainz.org', + '.archive.org', + '.mzstatic.com', + '.dzcdn.net', + '.discogs.com', ]; function isAllowed(hostname: string): boolean { diff --git a/workers/src/enrichment.service.ts b/workers/src/enrichment.service.ts index fc9741c..b2947f8 100644 --- a/workers/src/enrichment.service.ts +++ b/workers/src/enrichment.service.ts @@ -519,6 +519,20 @@ export class EnrichmentService { console.warn('[Enrich] Discogs image failed:', (err as Error).message); } + // 6. Deezer (by name, no auth). The five sources above cover almost nothing + // in this library: Fanart needs a key the worker does not have, TheAudioDB + // and Discogs 404 on most names, Wikidata needs an MBID that 638 of 734 + // artists lack, and Last.fm stopped serving real artist photos. + try { + const deezerImage = await this.deezer.searchArtistImage(artistName); + if (deezerImage) { + await this.updateArtistImage(artistId, deezerImage); + return deezerImage; + } + } catch (err) { + console.warn('[Enrich] Deezer image failed:', (err as Error).message); + } + // NOTE: Wikimedia Commons "by name" was previously the last fallback, but a // blind File-namespace text search (" artist") routinely returns the // wrong image entirely (e.g. an unrelated person who shares the name). It has diff --git a/workers/src/integrations/deezer.client.ts b/workers/src/integrations/deezer.client.ts index 102de29..89059b6 100644 --- a/workers/src/integrations/deezer.client.ts +++ b/workers/src/integrations/deezer.client.ts @@ -33,6 +33,10 @@ 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; @@ -81,4 +85,35 @@ export class DeezerClient { 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; + } + } }