feat(server/coverart): auto-bump version on registered-provider change

On boot, the SettingsService computes a SHA-256 hash of the sorted
registered-provider IDs and compares to the hash stored on
cover_art_sources_meta. Mismatch → atomic bump of current_version +
update of stored hash. The next enrichment pass picks up the new
version, all 'none' rows become eligible, and they get retried
against the new chain (now including Deezer / Last.fm).

One-shot: subsequent boots without a provider-set change don't bump
again. The check is best-effort — failures are logged at Warn but
don't refuse startup; a temporarily unreachable DB at boot just
means 'none' rows stay parked until a manual re-search (next task).

Tests cover first-boot (empty stored hash → bump), repeat-boot
(no change → no bump), and the registered-providers-hash
determinism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 07:55:07 -04:00
parent 238920540f
commit 8f5ec4c304
3 changed files with 137 additions and 0 deletions
+50
View File
@@ -2,6 +2,8 @@ package coverart
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"sort"
@@ -261,6 +263,54 @@ func enabledSetSignature(enabledIDs map[string]bool) string {
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[:])
}
// TestProvider invokes TestConnection on the named provider if it
// implements TestableProvider. Returns ErrNotTestable if the
// provider doesn't, ErrProviderNotFound if not registered.