c53bcb6c99
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>
381 lines
11 KiB
Go
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
|
|
}
|