package coverart import ( "context" "errors" "fmt" "io" "net/http" "sync" "time" ) // ErrNotFound indicates the upstream responded with 404 — the album has no // front cover on MBCAA. Caller should record cover_art_source = 'none'. var ErrNotFound = errors.New("coverart: not found") // ErrTransient indicates a temporary failure (5xx, network, timeout). Caller // should leave cover_art_source NULL so the next pass can retry. var ErrTransient = errors.New("coverart: transient error") // FetcherConfig assembles the operator-tunable knobs for the MBCAA client. type FetcherConfig struct { BaseURL string // e.g. "https://coverartarchive.org" UserAgent string // e.g. "Minstrel/ ()" MinPeriod time.Duration // minimum delay between calls (1s default per MBCAA etiquette) HTTPClient *http.Client // optional override for tests; default below } // Fetcher pulls album front covers from MBCAA. Safe for concurrent use; the // internal mutex serialises requests so the rate limit is respected. type Fetcher struct { cfg FetcherConfig mu sync.Mutex lastCall time.Time client *http.Client } // NewFetcher builds a Fetcher with sensible defaults. func NewFetcher(cfg FetcherConfig) *Fetcher { if cfg.BaseURL == "" { cfg.BaseURL = "https://coverartarchive.org" } client := cfg.HTTPClient if client == nil { client = &http.Client{Timeout: 30 * time.Second} } return &Fetcher{cfg: cfg, client: client} } // Fetch retrieves the 500px front cover for the given release MBID. Returns // ErrNotFound on 404 (record as 'none') or ErrTransient otherwise (retry later). func (f *Fetcher) Fetch(ctx context.Context, mbid string) ([]byte, error) { if mbid == "" { return nil, fmt.Errorf("coverart: empty mbid") } // Rate limit: serialise + wait until MinPeriod has elapsed since the last call. f.mu.Lock() if f.cfg.MinPeriod > 0 { wait := time.Until(f.lastCall.Add(f.cfg.MinPeriod)) if wait > 0 { timer := time.NewTimer(wait) f.mu.Unlock() select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } f.mu.Lock() } } f.lastCall = time.Now() f.mu.Unlock() url := fmt.Sprintf("%s/release/%s/front-500", f.cfg.BaseURL, mbid) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("coverart: build request: %w", err) } req.Header.Set("User-Agent", f.cfg.UserAgent) req.Header.Set("Accept", "image/*") resp, err := f.client.Do(req) if err != nil { return nil, fmt.Errorf("%w: %v", ErrTransient, err) } defer func() { _ = resp.Body.Close() }() switch { case resp.StatusCode == http.StatusNotFound: return nil, ErrNotFound case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) // 5MB cap if err != nil { return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err) } return body, nil }