Files
minstrel/internal/coverart/provider.go
T

188 lines
6.8 KiB
Go

package coverart
import (
"context"
"errors"
"fmt"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
)
// 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
}
// ArtistRef is the lookup key passed to ArtistArtProvider.FetchArtistArt.
// MBID is preferred when present; Name is the always-populated fallback
// for providers that support name-based search (Deezer, Last.fm).
// MBID-only providers (TheAudioDB) return ErrNotFound when MBID is empty.
type ArtistRef struct {
MBID string
Name string
}
// AlbumRef is the lookup key passed to AlbumCoverProvider.FetchAlbumCover.
// Same MBID-preferred semantics as ArtistRef; ArtistName + AlbumTitle
// support name-based providers.
type AlbumRef struct {
MBID string
ArtistName string
AlbumTitle string
}
// AlbumCoverProvider is an opt-in capability: providers implementing
// it can fetch album cover art. MBID-only providers use ref.MBID and
// return ErrNotFound when it is empty; name-based providers (Deezer,
// Last.fm) fall back to ref.ArtistName + ref.AlbumTitle.
type AlbumCoverProvider interface {
Provider
// FetchAlbumCover returns the image bytes for the given album ref,
// or ErrNotFound (terminal — no art available) /
// ErrTransient (retry-eligible failure).
FetchAlbumCover(ctx context.Context, ref AlbumRef) ([]byte, error)
}
// ArtistArtProvider is an opt-in capability: providers implementing
// it can fetch artist images. 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, ref ArtistRef) (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'.
//
// Aliased to apierror.ErrNotFound so handler error mapping (errors.Is
// against the shared sentinel) and existing per-package callsites both
// resolve to the same pointer.
var ErrNotFound = apierror.ErrNotFound
// 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()
}