b6632136b7
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>
163 lines
5.8 KiB
Go
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()
|
|
}
|