package coverart import ( "context" "crypto/sha256" "encoding/hex" "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, ",") } // BumpVersionIfProvidersChanged compares the current registered-provider // set (by ID) to the last set seen on a prior successful boot. If // different, it atomically increments cover_art_sources_meta.current_version // and stores the new hash, returning (newVersion, true, nil). If // unchanged, returns (currentVersion, false, nil) without touching the DB. // // Used at boot to ensure rows previously settled to 'none' get re-tried // once after a provider chain change (e.g. operator updates the binary // and a new provider is registered, or one is removed). The version // bump triggers eligibility for 'none' rows on the next enrichment pass // without requiring a manual operator action. func (s *SettingsService) BumpVersionIfProvidersChanged(ctx context.Context) (version int32, bumped bool, err error) { hash := registeredProvidersHash() q := dbq.New(s.pool) stored, qerr := q.GetCoverArtProvidersHash(ctx) if qerr != nil { return 0, false, fmt.Errorf("get providers hash: %w", qerr) } if stored == hash { return s.CurrentVersion(), false, nil } newVer, berr := q.BumpVersionAndSetProvidersHash(ctx, hash) if berr != nil { return 0, false, fmt.Errorf("bump version: %w", berr) } // Update the in-memory version so the next enrichment pass sees the new value. s.mu.Lock() s.currentVersion = newVer s.mu.Unlock() return newVer, true, nil } // registeredProvidersHash returns the SHA-256 hex of the sorted, // colon-joined registered provider IDs. func registeredProvidersHash() string { provs := AllProviders() ids := make([]string, 0, len(provs)) for _, p := range provs { ids = append(ids, p.ID()) } sort.Strings(ids) joined := strings.Join(ids, ":") sum := sha256.Sum256([]byte(joined)) return hex.EncodeToString(sum[:]) } // BumpVersion unconditionally increments cover_art_sources_meta.current_version // and returns the new value. Used by the manual "Re-search missing art" // admin endpoint when the operator wants every 'none' row re-attempted // on the next enrichment pass. // // Distinct from BumpVersionIfProvidersChanged: that method is the boot- // time auto-bump and skips the DB write when the registered-provider // hash matches. This method always writes. func (s *SettingsService) BumpVersion(ctx context.Context) (int32, error) { q := dbq.New(s.pool) // Reuse BumpVersionAndSetProvidersHash with the current registered- // providers hash; this keeps the stored hash consistent (a manual // bump is conceptually equivalent to "force the auto-bump path"). newVer, err := q.BumpVersionAndSetProvidersHash(ctx, registeredProvidersHash()) if err != nil { return 0, fmt.Errorf("bump version: %w", err) } s.mu.Lock() s.currentVersion = newVer s.mu.Unlock() return newVer, nil } // 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 }