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>
This commit is contained in:
@@ -3,56 +3,79 @@ package coverart
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mbcaaProvider implements Provider, AlbumCoverProvider, and
|
||||
// TestableProvider. It wraps an *Fetcher (the existing
|
||||
// MBCAA-specific HTTP client) for the actual fetch logic, adapting
|
||||
// the Fetcher's Fetch method to the AlbumCoverProvider interface.
|
||||
//
|
||||
// The wrapping (rather than reimplementing) is temporary: A-T8 will
|
||||
// consolidate fetcher.go's logic into this file and delete fetcher.go.
|
||||
// During the T4–T8 window, this struct lets the new provider chain
|
||||
// coexist with the legacy Fetcher used by enricher.go and main.go.
|
||||
type mbcaaProvider struct {
|
||||
fetcher *Fetcher
|
||||
enabled atomic.Bool
|
||||
// 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. The Beatles' "Abbey Road" UK
|
||||
// release; verified present on MBCAA at design time. Both 200 (image
|
||||
// returned) and 404 (no art for the sample) are treated as "connection
|
||||
// works" since MBCAA needs no auth — only network failures surface.
|
||||
// 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 with a default-config Fetcher. main.go (in A-T8) will
|
||||
// call NewMBCAAProviderFromConfig to swap in the production
|
||||
// User-Agent + MinPeriod. Default config values come from
|
||||
// FetcherConfig's zero-value handling in NewFetcher.
|
||||
Register(&mbcaaProvider{
|
||||
fetcher: NewFetcher(FetcherConfig{}),
|
||||
})
|
||||
Register(newMBCAAProvider(FetcherConfig{}))
|
||||
}
|
||||
|
||||
// NewMBCAAProviderFromConfig replaces the registered MBCAA provider's
|
||||
// internal Fetcher with one built from the supplied config. main.go
|
||||
// calls this at boot to set the production User-Agent. Idempotent;
|
||||
// safe to call repeatedly.
|
||||
// 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 {
|
||||
// init() should have registered it; if not, register fresh.
|
||||
Register(&mbcaaProvider{fetcher: NewFetcher(cfg)})
|
||||
Register(newMBCAAProvider(cfg))
|
||||
return
|
||||
}
|
||||
mp, ok := p.(*mbcaaProvider)
|
||||
if !ok {
|
||||
// Some other provider claimed the "mbcaa" ID — registry bug.
|
||||
panic("coverart: provider with ID 'mbcaa' is not *mbcaaProvider")
|
||||
}
|
||||
mp.fetcher = NewFetcher(cfg)
|
||||
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" }
|
||||
@@ -62,25 +85,69 @@ func (p *mbcaaProvider) DefaultEnabled() bool { return true }
|
||||
|
||||
func (p *mbcaaProvider) Configure(s ProviderSettings) error {
|
||||
p.enabled.Store(s.Enabled)
|
||||
return nil // MBCAA has no per-operator config beyond enabled
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchAlbumCover delegates to the wrapped Fetcher. The Fetcher's
|
||||
// existing Fetch method already returns ErrNotFound / ErrTransient
|
||||
// per the contract, so no error mapping is needed.
|
||||
// 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
|
||||
}
|
||||
return p.fetcher.Fetch(ctx, mbid)
|
||||
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
|
||||
}
|
||||
|
||||
// TestConnection hits the sample MBID's front-500 endpoint. A 200
|
||||
// (image bytes) or 404 (no art for sample) both indicate the
|
||||
// connection works; 5xx / network errors surface as TestConnection
|
||||
// failures.
|
||||
func (p *mbcaaProvider) TestConnection(ctx context.Context) error {
|
||||
_, err := p.fetcher.Fetch(ctx, mbcaaTestSampleMBID)
|
||||
_, err := p.FetchAlbumCover(ctx, mbcaaTestSampleMBID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user