From 9b88b885238fbd85b7b6c71d5310f4132da7e2df Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 25 Mar 2026 22:19:18 -0400 Subject: [PATCH] fix: rate-limit MB url-rels fetches, serialize frontend image loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artist image resolution was hitting musicbrainz.org with 10 concurrent unthrottled requests per search — enough to trigger MB's rate limit rejection. Two fixes: Backend: add dedicated 1 req/s RateLimiter for MB url-rels fetches in ArtistImageProvider. Each fetch waits on the limiter before the HTTP call. Results are cached 30 days so repeat lookups are instant. Frontend: switch loadArtistImages from concurrent fire-all to sequential await loop. Each artist image loads one at a time, images appear progressively as they resolve instead of all failing from rate limit rejection. --- backend/explore/artistimage.go | 20 +++++++++++----- backend/explore/explore.go | 2 +- .../components/explore-view/explore-view.ts | 24 +++++++++---------- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/backend/explore/artistimage.go b/backend/explore/artistimage.go index 6922c0b..d440205 100644 --- a/backend/explore/artistimage.go +++ b/backend/explore/artistimage.go @@ -40,21 +40,24 @@ const ( // URL. Designed to be extended with additional sources (fanart.tv, // etc.) by adding to the resolve chain. type ArtistImageProvider struct { - cache *Cache - client *http.Client - logger *slog.Logger + cache *Cache + mbLimiter *RateLimiter + client *http.Client + logger *slog.Logger } // NewArtistImageProvider creates a provider that resolves artist // images via MusicBrainz relationships and Wikidata. func NewArtistImageProvider( cache *Cache, + mbLimiter *RateLimiter, logger *slog.Logger, ) *ArtistImageProvider { return &ArtistImageProvider{ - cache: cache, - client: &http.Client{Timeout: artistImageTimeout}, - logger: logger, + cache: cache, + mbLimiter: mbLimiter, + client: &http.Client{Timeout: artistImageTimeout}, + logger: logger, } } @@ -130,6 +133,11 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation { artistMBID, ) + // Rate-limit the MB API call. + if err := p.mbLimiter.Wait(context.Background()); err != nil { + return nil + } + body, err := p.fetchURL(url) if err != nil { p.logger.Debug("artist image: MB rels fetch failed", diff --git a/backend/explore/explore.go b/backend/explore/explore.go index cdc015c..4924b3c 100644 --- a/backend/explore/explore.go +++ b/backend/explore/explore.go @@ -38,7 +38,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service { lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz")) index := NewSearchIndex(db, lb, logger.WithGroup("search-index")) artProxy := NewCoverArtProxy(db, limiter) - artistImg := NewArtistImageProvider(cache, logger.WithGroup("artist-image")) + artistImg := NewArtistImageProvider(cache, NewRateLimiter(), logger.WithGroup("artist-image")) logger.Info("explore service created") diff --git a/frontend/src/components/explore-view/explore-view.ts b/frontend/src/components/explore-view/explore-view.ts index eab26ef..349fa42 100644 --- a/frontend/src/components/explore-view/explore-view.ts +++ b/frontend/src/components/explore-view/explore-view.ts @@ -666,25 +666,25 @@ export class ExploreView extends LitElement { * Load artist images for all visible artist cards. Each call * is async and updates the cache + re-renders on success. */ - private loadArtistImages() { + private async loadArtistImages() { if (!this.results?.artists?.length) return; + // Load sequentially to avoid hammering the MB rate limiter. for (const a of this.results.artists) { if (this.artistImageCache.has(a.mbid)) continue; - // Mark as loading. this.artistImageCache.set(a.mbid, ''); - GetArtistImageURL(a.mbid) - .then((url) => { - if (url) { - this.artistImageCache.set(a.mbid, url); - this.requestUpdate(); - } - }) - .catch(() => { - // No image — leave empty string. - }); + try { + const url = await GetArtistImageURL(a.mbid); + + if (url) { + this.artistImageCache.set(a.mbid, url); + this.requestUpdate(); + } + } catch { + // No image — leave empty string. + } } }