Files
bvandeusen c53bcb6c99 feat(server/web/coverart): manual Re-search missing art button
POST /api/admin/cover-sources/research bumps cover_art_sources_meta.
current_version unconditionally; the next enrichment pass re-eligibles
every 'none' row. Existing positively-sourced rows are not affected —
the version-mismatch eligibility rule only kicks in for 'none'.

Admin Cover Art panel gains a "Re-search missing art" button that
calls the endpoint and shows a confirmation toast. Operator's escape
hatch when:

- They've added a Last.fm key and want to retry failed rows
  immediately rather than waiting for the next scheduled scan.
- An upstream provider's catalog has updated (e.g. Deezer added a
  back-catalog import).
- They want to verify a fix end-to-end before the next scheduled
  scan fires.

SettingsService.BumpVersion (the unconditional version of T5's
BumpVersionIfProvidersChanged) is the single-call DB writer; both
endpoints route through it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 07:59:40 -04:00

381 lines
11 KiB
Go

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
}