fix: rate-limit MB url-rels fetches, serialize frontend image loads

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.
This commit is contained in:
2026-03-25 22:19:18 -04:00
parent 104e469774
commit 9b88b88523
3 changed files with 27 additions and 19 deletions
+14 -6
View File
@@ -40,21 +40,24 @@ const (
// URL. Designed to be extended with additional sources (fanart.tv, // URL. Designed to be extended with additional sources (fanart.tv,
// etc.) by adding to the resolve chain. // etc.) by adding to the resolve chain.
type ArtistImageProvider struct { type ArtistImageProvider struct {
cache *Cache cache *Cache
client *http.Client mbLimiter *RateLimiter
logger *slog.Logger client *http.Client
logger *slog.Logger
} }
// NewArtistImageProvider creates a provider that resolves artist // NewArtistImageProvider creates a provider that resolves artist
// images via MusicBrainz relationships and Wikidata. // images via MusicBrainz relationships and Wikidata.
func NewArtistImageProvider( func NewArtistImageProvider(
cache *Cache, cache *Cache,
mbLimiter *RateLimiter,
logger *slog.Logger, logger *slog.Logger,
) *ArtistImageProvider { ) *ArtistImageProvider {
return &ArtistImageProvider{ return &ArtistImageProvider{
cache: cache, cache: cache,
client: &http.Client{Timeout: artistImageTimeout}, mbLimiter: mbLimiter,
logger: logger, client: &http.Client{Timeout: artistImageTimeout},
logger: logger,
} }
} }
@@ -130,6 +133,11 @@ func (p *ArtistImageProvider) fetchMBRels(artistMBID string) []mbRelation {
artistMBID, artistMBID,
) )
// Rate-limit the MB API call.
if err := p.mbLimiter.Wait(context.Background()); err != nil {
return nil
}
body, err := p.fetchURL(url) body, err := p.fetchURL(url)
if err != nil { if err != nil {
p.logger.Debug("artist image: MB rels fetch failed", p.logger.Debug("artist image: MB rels fetch failed",
+1 -1
View File
@@ -38,7 +38,7 @@ func NewExploreService(logger *slog.Logger, db *database.DB) *Service {
lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz")) lb := NewListenBrainzClient(limiter, cache, logger.WithGroup("listenbrainz"))
index := NewSearchIndex(db, lb, logger.WithGroup("search-index")) index := NewSearchIndex(db, lb, logger.WithGroup("search-index"))
artProxy := NewCoverArtProxy(db, limiter) artProxy := NewCoverArtProxy(db, limiter)
artistImg := NewArtistImageProvider(cache, logger.WithGroup("artist-image")) artistImg := NewArtistImageProvider(cache, NewRateLimiter(), logger.WithGroup("artist-image"))
logger.Info("explore service created") logger.Info("explore service created")
@@ -666,25 +666,25 @@ export class ExploreView extends LitElement {
* Load artist images for all visible artist cards. Each call * Load artist images for all visible artist cards. Each call
* is async and updates the cache + re-renders on success. * is async and updates the cache + re-renders on success.
*/ */
private loadArtistImages() { private async loadArtistImages() {
if (!this.results?.artists?.length) return; if (!this.results?.artists?.length) return;
// Load sequentially to avoid hammering the MB rate limiter.
for (const a of this.results.artists) { for (const a of this.results.artists) {
if (this.artistImageCache.has(a.mbid)) continue; if (this.artistImageCache.has(a.mbid)) continue;
// Mark as loading.
this.artistImageCache.set(a.mbid, ''); this.artistImageCache.set(a.mbid, '');
GetArtistImageURL(a.mbid) try {
.then((url) => { const url = await GetArtistImageURL(a.mbid);
if (url) {
this.artistImageCache.set(a.mbid, url); if (url) {
this.requestUpdate(); this.artistImageCache.set(a.mbid, url);
} this.requestUpdate();
}) }
.catch(() => { } catch {
// No image — leave empty string. // No image — leave empty string.
}); }
} }
} }