Files
minstrel/internal/coverart/provider_mbcaa.go
T
bvandeusen 385eb3b163 feat(server/m7-cover-sources): album enricher uses provider chain
Rewires EnrichAlbum to iterate EnabledAlbumProviders from the
SettingsService instead of a single hardcoded MBCAA fetcher. Sidecar
layer is unchanged (always tried first). Per-row eligibility check
honors cover_art_sources_version: terminal 'found' values skip;
'none' flips to NULL via ClearAlbumCoverNone and proceeds (the DB-
level ListAlbumsMissingCover query guards the version check for batch
callers; RetryAlbum clears via ClearAlbumCover first); NULL is eligible.

The provider chain uses an inline allWere404 accumulator to settle
the row to 'none' only when every provider returned ErrNotFound.
Any provider returning ErrTransient leaves the row NULL for next-
pass retry — even if other providers returned ErrNotFound — since
the transient call's MBID might succeed on a future attempt.

Boot wiring (cmd/minstrel/main.go) replaces the
NewFetcher+NewEnricher(fetcher, mbcaaOn) shape with
NewMBCAAProviderFromConfig (swaps in production User-Agent on the
registered provider) + NewSettingsService + NewEnricher(settings).
The old cfg.Library.CoverArtFromMBCAA flag is no-op'd; DB-backed
settings replace it. Field stays in the config struct for backward
compat with deployed config files.

Consolidates fetcher.go's HTTP logic into provider_mbcaa.go (no more
wrapper indirection): mbcaaProvider now holds cfg/mu/lastCall/client
directly. Deletes fetcher.go and fetcher_test.go. ErrNotFound and
ErrTransient move into provider.go alongside the other sentinels.

Updates admin_covers_test.go and provider_mbcaa_test.go to the new
constructor shapes. Adds multi-provider-chain, transient-leaves-null,
and stale-none-flips-and-retries test cases in enricher_test.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 12:52:57 -04:00

159 lines
4.2 KiB
Go

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/<version> (<contact>)"
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. Returns ErrNotFound on 404 or ErrTransient otherwise.
func (p *mbcaaProvider) FetchAlbumCover(ctx context.Context, mbid string) ([]byte, error) {
if !p.enabled.Load() {
return nil, ErrNotFound // defensive; enricher already filters on enabled
}
if mbid == "" {
return nil, fmt.Errorf("coverart: empty mbid")
}
// 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, 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, mbcaaTestSampleMBID)
if err == nil {
return nil
}
if errors.Is(err, ErrNotFound) {
return nil
}
return err
}