Files
minstrel/internal/coverart/provider.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

156 lines
5.6 KiB
Go

package coverart
import (
"context"
"errors"
"fmt"
"sync"
)
// Provider is the base shape every cover-art source implements.
// Providers register themselves at init() time via Register(). The
// enricher reads the current per-provider settings and dispatches to
// the capability methods below.
type Provider interface {
// ID is a stable lowercase token persisted in DB columns
// (cover_art_source / artist_art_source) and the
// cover_art_provider_settings.provider_id key, plus referenced
// in the operator-facing UI.
ID() string
// DisplayName is the human-readable label shown in the admin
// Settings card.
DisplayName() string
// RequiresAPIKey reports whether the provider needs a key to
// function. True for theaudiodb / fanart.tv; false for mbcaa.
// The admin UI uses this to show or hide the api_key text field;
// it does NOT gate the enabled toggle (per the no-coercive-
// settings principle, providers with usable defaults — such as
// TheAudioDB's public test key — ship working).
RequiresAPIKey() bool
// DefaultEnabled is whether a freshly-installed Minstrel ships
// with this provider on. Both v1 providers return true.
DefaultEnabled() bool
// Configure receives the operator's settings (api_key, enabled
// flag) and applies them. Called at boot during reconciliation
// and on every admin PATCH. Implementations must be idempotent
// and goroutine-safe (the enricher may be reading concurrently).
Configure(ProviderSettings) error
}
// ProviderSettings is the runtime config a provider receives when
// Configure() is called. Providers may interpret an empty APIKey
// however they wish — TheAudioDB falls back to its public test key,
// MBCAA ignores the field entirely.
type ProviderSettings struct {
Enabled bool
APIKey string
}
// AlbumCoverProvider is an opt-in capability: providers implementing
// it can fetch album cover art by MusicBrainz release MBID.
type AlbumCoverProvider interface {
Provider
// FetchAlbumCover returns the image bytes for the given release
// MBID, or ErrNotFound (terminal — no art available) /
// ErrTransient (retry-eligible failure).
FetchAlbumCover(ctx context.Context, mbid string) ([]byte, error)
}
// ArtistArtProvider is an opt-in capability: providers implementing
// it can fetch artist images by MusicBrainz artist MBID. Returns
// thumb + fanart atomically at the JSON metadata step (one round-
// trip yields both URLs); the two image GETs are independent —
// partial success returns whatever bytes landed and the source is
// still stamped, so the enricher persists asymmetric results
// (thumb-only or fanart-only) gracefully.
type ArtistArtProvider interface {
Provider
FetchArtistArt(ctx context.Context, mbid string) (thumb, fanart []byte, err error)
}
// TestableProvider is an opt-in capability: providers that can answer
// "is my config working?" without performing a full enrichment cycle.
// Used by the admin Test-Connection button. v1: both MBCAA and
// TheAudioDB implement this.
type TestableProvider interface {
Provider
TestConnection(ctx context.Context) error
}
// ErrNotTestable is returned by the SettingsService.TestProvider
// method when the named provider doesn't implement TestableProvider.
var ErrNotTestable = errors.New("coverart: provider does not support test connection")
// ErrProviderNotFound is returned when the named provider is not
// registered.
var ErrProviderNotFound = errors.New("coverart: provider not registered")
// ErrNotFound indicates the upstream confirmed there is no art for
// this MBID — a terminal-this-pass result. The enricher treats this
// as "try the next provider in the chain"; if all providers return
// ErrNotFound, the row settles to cover_art_source='none'.
var ErrNotFound = errors.New("coverart: not found")
// ErrTransient indicates a temporary failure (5xx, network, timeout,
// auth issue). The enricher leaves the row's source NULL so the next
// scan pass can retry.
var ErrTransient = errors.New("coverart: transient error")
// registry is the package-private list of all compiled-in providers.
// Providers register at init() via Register(). The enricher iterates
// over this list filtered by the current per-provider Settings.
var registry = struct {
mu sync.Mutex
providers []Provider
}{}
// Register adds a provider to the registry. Must be called from
// init() of a provider file (provider_mbcaa.go, provider_theaudiodb.go,
// etc.). Calling twice with the same ID panics — providers are
// compiled-in, duplicate registration is a build-time bug.
func Register(p Provider) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, existing := range registry.providers {
if existing.ID() == p.ID() {
panic(fmt.Sprintf("coverart: duplicate provider ID %q", p.ID()))
}
}
registry.providers = append(registry.providers, p)
}
// AllProviders returns a snapshot of all registered providers in
// registration order. Caller must not mutate the returned slice.
func AllProviders() []Provider {
registry.mu.Lock()
defer registry.mu.Unlock()
out := make([]Provider, len(registry.providers))
copy(out, registry.providers)
return out
}
// ProviderByID looks up a registered provider by ID. Returns
// ErrProviderNotFound if no such provider has registered.
func ProviderByID(id string) (Provider, error) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, p := range registry.providers {
if p.ID() == id {
return p, nil
}
}
return nil, ErrProviderNotFound
}
// resetRegistryForTests clears the registry. Test-only helper
// (provider_test.go); never called from production code.
func resetRegistryForTests() {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.providers = nil
}