package coverart import ( "context" "errors" "fmt" "io" "net/http" "sync" "sync/atomic" "time" ) // FetcherConfig assembles the operator-tunable knobs for the MBCAA // HTTP client. Kept exported because main.go's boot wiring constructs // one to pass into NewMBCAAProviderFromConfig. type FetcherConfig struct { BaseURL string // default "https://coverartarchive.org" UserAgent string // e.g. "Minstrel/ ()" MinPeriod time.Duration // 1s default per MBCAA etiquette HTTPClient *http.Client // optional override for tests } // Sample MBID used by TestConnection. Beatles' "Abbey Road" UK release. const mbcaaTestSampleMBID = "f4b3df80-8a6c-3e87-b51e-5c1ee70a78ee" // mbcaaProvider implements Provider, AlbumCoverProvider, and // TestableProvider against the MusicBrainz Cover Art Archive. // Safe for concurrent use; the internal mutex serialises HTTP // requests so the rate limit is respected. type mbcaaProvider struct { cfg FetcherConfig enabled atomic.Bool mu sync.Mutex lastCall time.Time client *http.Client } func init() { Register(newMBCAAProvider(FetcherConfig{})) } // NewMBCAAProviderFromConfig replaces the registered MBCAA provider's // internal config with the supplied one. main.go calls this at boot // to set the production User-Agent. func NewMBCAAProviderFromConfig(cfg FetcherConfig) { p, err := ProviderByID("mbcaa") if err != nil { Register(newMBCAAProvider(cfg)) return } mp, ok := p.(*mbcaaProvider) if !ok { panic("coverart: provider with ID 'mbcaa' is not *mbcaaProvider") } cfg = applyMBCAADefaults(cfg) mp.mu.Lock() mp.cfg = cfg mp.client = cfg.HTTPClient mp.mu.Unlock() } func newMBCAAProvider(cfg FetcherConfig) *mbcaaProvider { cfg = applyMBCAADefaults(cfg) return &mbcaaProvider{cfg: cfg, client: cfg.HTTPClient} } func applyMBCAADefaults(cfg FetcherConfig) FetcherConfig { if cfg.BaseURL == "" { cfg.BaseURL = "https://coverartarchive.org" } if cfg.MinPeriod == 0 { cfg.MinPeriod = time.Second } if cfg.HTTPClient == nil { cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second} } return cfg } func (p *mbcaaProvider) ID() string { return "mbcaa" } func (p *mbcaaProvider) DisplayName() string { return "Cover Art Archive" } func (p *mbcaaProvider) RequiresAPIKey() bool { return false } func (p *mbcaaProvider) DefaultEnabled() bool { return true } func (p *mbcaaProvider) Configure(s ProviderSettings) error { p.enabled.Store(s.Enabled) return nil } // FetchAlbumCover retrieves the 500px front cover for the given // release MBID. MBCAA is MBID-only; returns ErrNotFound when // ref.MBID is empty. Returns ErrNotFound on 404 or ErrTransient otherwise. func (p *mbcaaProvider) FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error) { if !p.enabled.Load() { return nil, ErrNotFound // defensive; enricher already filters on enabled } if ref.MBID == "" { return nil, ErrNotFound // MBCAA is MBID-only } // Rate limit guard. p.mu.Lock() cfg := p.cfg if cfg.MinPeriod > 0 { wait := time.Until(p.lastCall.Add(cfg.MinPeriod)) if wait > 0 { timer := time.NewTimer(wait) p.mu.Unlock() select { case <-ctx.Done(): timer.Stop() return nil, ctx.Err() case <-timer.C: } p.mu.Lock() } } p.lastCall = time.Now() p.mu.Unlock() url := fmt.Sprintf("%s/release/%s/front-500", cfg.BaseURL, ref.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", cfg.UserAgent) req.Header.Set("Accept", "image/*") resp, err := p.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)) if err != nil { return nil, fmt.Errorf("%w: read body: %v", ErrTransient, err) } return body, nil } func (p *mbcaaProvider) TestConnection(ctx context.Context) error { _, err := p.FetchAlbumCover(ctx, AlbumRef{MBID: mbcaaTestSampleMBID}) if err == nil { return nil } if errors.Is(err, ErrNotFound) { return nil } return err }