Files
minstrel/internal/coverart/provider.go
T
bvandeusen b6632136b7 feat(server/m7-cover-sources): admin cover-sources HTTP handlers
Three endpoints for the admin Settings UI:
- GET /api/admin/cover-sources — lists registered providers with
  current settings + capability badges + sources_version
- PATCH /api/admin/cover-sources/{provider_id} — updates enabled
  and/or api_key; returns version_bumped: true only when the
  enabled set changed
- POST /api/admin/cover-sources/{provider_id}/test — invokes
  TestableProvider.TestConnection; returns ok:bool + duration_ms +
  error string. Returns 200 in both success and failure cases (the
  operation completed; the test result is data, not an error).

api_key is never echoed in GET — replaced with api_key_set: bool
to follow the standard credential-display convention. PATCH
api_key: "" clears; omitting the field leaves it unchanged. PATCH
on an unknown provider_id returns 404.

All under existing RequireAdmin middleware. Routes registered
alongside the library/coverage endpoint in the /api/admin/ tree.

handlers struct gains a coverSettings *coverart.SettingsService
field; server.New + cmd/minstrel/main.go plumb the existing
coverSettings instance through.

coverart.ResetRegistryForTests() exported wrapper added so api
package tests can reset the registry without importing internals.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 13:08:45 -04:00

163 lines
5.8 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
}
// ResetRegistryForTests is an exported wrapper around resetRegistryForTests
// for use by tests in other packages (e.g. internal/api). Never call from
// production code.
func ResetRegistryForTests() {
resetRegistryForTests()
}