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() }