392b8aa12a
Reconciles the compile-time provider registry with the cover_art_provider_settings table at boot, and on every admin update. Calls Configure() on each provider with the row data so runtime config (enabled, api_key) propagates immediately. Owns the cover_art_sources_meta.current_version stamp. UpdateProvider returns versionBumped=true only when the change altered the *enabled set* of providers — key rotations don't bump version (the recheck trigger is for "we have a new source to ask," not config rotations). Capability filtering: EnabledAlbumProviders / EnabledArtistProviders return only registered providers that (a) are enabled per DB and (b) implement the requested capability interface. Snapshots — callers don't mutate. ListProviderInfo builds the wire-shaped slice the admin GET handler returns. APIKeySet bool replaces echoing the key (credential never leaves the server). TestProvider dispatches to TestableProvider.TestConnection on the named provider, ErrNotTestable if the provider doesn't implement the capability, ErrProviderNotFound if not registered. UpdateProviderSettingsParams uses the sqlc-generated field names: Enabled bool (not *bool — the COALESCE is handled by passing the cached current value when patch.Enabled is nil), Column3 bool (apiKeyChanged flag), ApiKey *string (the api_key value — sqlc named this from the column, not Column4 as the task spec assumed). Tests gated on MINSTREL_TEST_DATABASE_URL: boot-inserts-defaults, flip-enabled-bumps-version, key-only-doesn't-bump, clear-key, TestProvider routing for testable / non-testable / missing, ListProviderInfo capability badges. Reuses newPool / discardLogger and fakeProvider / fakeAlbumProvider from enricher_test.go and provider_test.go (same package).
308 lines
8.7 KiB
Go
308 lines
8.7 KiB
Go
package coverart
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// SettingsService reconciles the registry of compiled-in providers
|
|
// with the cover_art_provider_settings table at boot, and on every
|
|
// admin-initiated update. It owns the cover_art_sources_meta
|
|
// singleton's current_version stamp, bumping it whenever the
|
|
// *enabled set* of providers changes.
|
|
type SettingsService struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
|
|
mu sync.RWMutex
|
|
enabledIDs map[string]bool // provider_id → enabled
|
|
apiKeys map[string]string // provider_id → api_key (empty if NULL)
|
|
currentVersion int32
|
|
}
|
|
|
|
// ProviderUpdatePatch is the admin-PATCH body shape. nil pointers
|
|
// mean "leave unchanged"; non-nil means "set to this value (which
|
|
// may be empty string for api_key clearing)".
|
|
type ProviderUpdatePatch struct {
|
|
Enabled *bool
|
|
APIKey *string
|
|
}
|
|
|
|
// ProviderInfo is the SettingsService's view of a provider for the
|
|
// admin GET handler. Combines registry metadata with DB settings.
|
|
type ProviderInfo struct {
|
|
ID string
|
|
DisplayName string
|
|
RequiresAPIKey bool
|
|
Supports []string // "album_cover", "artist_thumb", "artist_fanart"
|
|
Enabled bool
|
|
APIKeySet bool
|
|
DisplayOrder int32
|
|
Testable bool
|
|
}
|
|
|
|
// NewSettingsService boots the service: reads the DB rows, INSERTs
|
|
// defaults for any registered provider missing a row, calls
|
|
// Configure() on every registered provider with the row data, and
|
|
// loads the current_version. Returns the populated service.
|
|
func NewSettingsService(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*SettingsService, error) {
|
|
s := &SettingsService{
|
|
pool: pool,
|
|
logger: logger,
|
|
enabledIDs: map[string]bool{},
|
|
apiKeys: map[string]string{},
|
|
}
|
|
if err := s.reconcile(ctx); err != nil {
|
|
return nil, fmt.Errorf("settings service boot: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// reconcile reads the DB, INSERTs defaults for missing providers,
|
|
// calls Configure() on each registered provider, and loads the
|
|
// current_version into the cache.
|
|
func (s *SettingsService) reconcile(ctx context.Context) error {
|
|
q := dbq.New(s.pool)
|
|
|
|
// Insert defaults for any registered provider missing a row.
|
|
registered := AllProviders()
|
|
for i, p := range registered {
|
|
if err := q.UpsertProviderSettings(ctx, dbq.UpsertProviderSettingsParams{
|
|
ProviderID: p.ID(),
|
|
Enabled: p.DefaultEnabled(),
|
|
DisplayOrder: int32(i),
|
|
}); err != nil {
|
|
return fmt.Errorf("upsert provider %q: %w", p.ID(), err)
|
|
}
|
|
}
|
|
|
|
// Read all rows back.
|
|
rows, err := q.ListProviderSettings(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list provider settings: %w", err)
|
|
}
|
|
|
|
// Build the lookup maps and call Configure on each registered
|
|
// provider with the corresponding row data.
|
|
settingsByID := map[string]dbq.CoverArtProviderSetting{}
|
|
for _, r := range rows {
|
|
settingsByID[r.ProviderID] = r
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.enabledIDs = map[string]bool{}
|
|
s.apiKeys = map[string]string{}
|
|
for _, p := range registered {
|
|
row, ok := settingsByID[p.ID()]
|
|
if !ok {
|
|
s.logger.Warn("settings: missing row after upsert", "provider", p.ID())
|
|
continue
|
|
}
|
|
key := ""
|
|
if row.ApiKey != nil {
|
|
key = *row.ApiKey
|
|
}
|
|
s.enabledIDs[p.ID()] = row.Enabled
|
|
s.apiKeys[p.ID()] = key
|
|
if err := p.Configure(ProviderSettings{Enabled: row.Enabled, APIKey: key}); err != nil {
|
|
s.logger.Warn("settings: provider Configure failed",
|
|
"provider", p.ID(), "err", err)
|
|
}
|
|
}
|
|
|
|
v, err := q.GetCurrentSourcesVersion(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("get current sources version: %w", err)
|
|
}
|
|
s.currentVersion = v
|
|
return nil
|
|
}
|
|
|
|
// EnabledAlbumProviders returns the enabled providers that implement
|
|
// AlbumCoverProvider, in registration order. Snapshot — caller must
|
|
// not mutate.
|
|
func (s *SettingsService) EnabledAlbumProviders() []AlbumCoverProvider {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var out []AlbumCoverProvider
|
|
for _, p := range AllProviders() {
|
|
if !s.enabledIDs[p.ID()] {
|
|
continue
|
|
}
|
|
if ap, ok := p.(AlbumCoverProvider); ok {
|
|
out = append(out, ap)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// EnabledArtistProviders is the parallel for ArtistArtProvider.
|
|
func (s *SettingsService) EnabledArtistProviders() []ArtistArtProvider {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
var out []ArtistArtProvider
|
|
for _, p := range AllProviders() {
|
|
if !s.enabledIDs[p.ID()] {
|
|
continue
|
|
}
|
|
if ap, ok := p.(ArtistArtProvider); ok {
|
|
out = append(out, ap)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CurrentVersion returns the current sources-set version (the stamp
|
|
// the enricher writes onto rows it processes).
|
|
func (s *SettingsService) CurrentVersion() int32 {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.currentVersion
|
|
}
|
|
|
|
// UpdateProvider applies an admin-UI patch. Returns versionBumped=true
|
|
// if the change altered the *enabled set* (not on key changes).
|
|
// Re-runs the provider's Configure() with the new settings.
|
|
func (s *SettingsService) UpdateProvider(ctx context.Context, providerID string, patch ProviderUpdatePatch) (bool, error) {
|
|
p, err := ProviderByID(providerID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
q := dbq.New(s.pool)
|
|
|
|
// Compute current enabled-set signature before applying the patch.
|
|
s.mu.RLock()
|
|
beforeSig := enabledSetSignature(s.enabledIDs)
|
|
// Use the cached enabled value when the patch doesn't specify it,
|
|
// since the sqlc-generated Enabled field is bool (not *bool) and
|
|
// COALESCE($2, enabled) only works with a nullable param. Passing
|
|
// the current value when nil achieves the same "leave unchanged"
|
|
// semantics.
|
|
currentEnabled := s.enabledIDs[providerID]
|
|
s.mu.RUnlock()
|
|
|
|
enabledVal := currentEnabled
|
|
if patch.Enabled != nil {
|
|
enabledVal = *patch.Enabled
|
|
}
|
|
|
|
// Apply DB update. The sqlc-generated UpdateProviderSettingsParams
|
|
// uses Column3/ApiKey because the underlying SQL has $3::boolean
|
|
// (apiKeyChanged flag) and $4 (api_key value). The actual generated
|
|
// field for $4 is ApiKey *string.
|
|
apiKeyChanged := patch.APIKey != nil
|
|
if err := q.UpdateProviderSettings(ctx, dbq.UpdateProviderSettingsParams{
|
|
ProviderID: providerID,
|
|
Enabled: enabledVal,
|
|
Column3: apiKeyChanged,
|
|
ApiKey: patch.APIKey,
|
|
}); err != nil {
|
|
return false, fmt.Errorf("update provider settings: %w", err)
|
|
}
|
|
|
|
// Re-read rows + re-cache + Configure the changed provider.
|
|
rows, err := q.ListProviderSettings(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("re-list provider settings: %w", err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for _, r := range rows {
|
|
key := ""
|
|
if r.ApiKey != nil {
|
|
key = *r.ApiKey
|
|
}
|
|
s.enabledIDs[r.ProviderID] = r.Enabled
|
|
s.apiKeys[r.ProviderID] = key
|
|
if r.ProviderID == providerID {
|
|
if err := p.Configure(ProviderSettings{Enabled: r.Enabled, APIKey: key}); err != nil {
|
|
s.logger.Warn("provider Configure failed after update",
|
|
"provider", providerID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
afterSig := enabledSetSignature(s.enabledIDs)
|
|
if beforeSig == afterSig {
|
|
return false, nil
|
|
}
|
|
|
|
newVersion, err := q.BumpSourcesVersion(ctx)
|
|
if err != nil {
|
|
return false, fmt.Errorf("bump sources version: %w", err)
|
|
}
|
|
s.currentVersion = newVersion
|
|
return true, nil
|
|
}
|
|
|
|
// enabledSetSignature returns a deterministic string of the
|
|
// currently-enabled provider IDs.
|
|
func enabledSetSignature(enabledIDs map[string]bool) string {
|
|
ids := make([]string, 0, len(enabledIDs))
|
|
for id, on := range enabledIDs {
|
|
if on {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Strings(ids)
|
|
return strings.Join(ids, ",")
|
|
}
|
|
|
|
// TestProvider invokes TestConnection on the named provider if it
|
|
// implements TestableProvider. Returns ErrNotTestable if the
|
|
// provider doesn't, ErrProviderNotFound if not registered.
|
|
func (s *SettingsService) TestProvider(ctx context.Context, providerID string) error {
|
|
p, err := ProviderByID(providerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tp, ok := p.(TestableProvider)
|
|
if !ok {
|
|
return ErrNotTestable
|
|
}
|
|
return tp.TestConnection(ctx)
|
|
}
|
|
|
|
// ListProviderInfo builds the wire-shaped slice for GET
|
|
// /api/admin/cover-sources. Returns providers in registration order.
|
|
func (s *SettingsService) ListProviderInfo() []ProviderInfo {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
var out []ProviderInfo
|
|
for i, p := range AllProviders() {
|
|
info := ProviderInfo{
|
|
ID: p.ID(),
|
|
DisplayName: p.DisplayName(),
|
|
RequiresAPIKey: p.RequiresAPIKey(),
|
|
Enabled: s.enabledIDs[p.ID()],
|
|
APIKeySet: s.apiKeys[p.ID()] != "",
|
|
DisplayOrder: int32(i),
|
|
}
|
|
if _, ok := p.(AlbumCoverProvider); ok {
|
|
info.Supports = append(info.Supports, "album_cover")
|
|
}
|
|
if _, ok := p.(ArtistArtProvider); ok {
|
|
info.Supports = append(info.Supports, "artist_thumb", "artist_fanart")
|
|
}
|
|
if _, ok := p.(TestableProvider); ok {
|
|
info.Testable = true
|
|
}
|
|
out = append(out, info)
|
|
}
|
|
return out
|
|
}
|