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
+81
View File
@@ -231,3 +231,84 @@ func TestSettingsService_ListProviderInfo_Capabilities(t *testing.T) {
t.Errorf("artist-only.Supports = %v, want [artist_thumb, artist_fanart]", got)
}
}
// TestSettingsService_BumpVersionIfProvidersChanged_FirstBoot: stored
// hash is empty (column default ""), so a real provider set produces a bump.
func TestSettingsService_BumpVersionIfProvidersChanged_FirstBoot(t *testing.T) {
pool := newPool(t)
resetRegistryForTests()
defer resetRegistryForTests()
Register(&fakeAlbumProvider{fakeProvider: fakeProvider{id: "fake-album", defaultOn: true}})
s, err := NewSettingsService(context.Background(), pool, discardLogger())
if err != nil {
t.Fatalf("init: %v", err)
}
before := s.CurrentVersion()
newVer, bumped, err := s.BumpVersionIfProvidersChanged(context.Background())
if err != nil {
t.Fatalf("bump: %v", err)
}
if !bumped {
t.Errorf("bumped = false, want true on first boot (stored hash is empty)")
}
if newVer <= before {
t.Errorf("newVer = %d, want > %d", newVer, before)
}
if s.CurrentVersion() != newVer {
t.Errorf("in-memory version = %d, want %d (synchronized with DB)", s.CurrentVersion(), newVer)
}
}
// TestSettingsService_BumpVersionIfProvidersChanged_NoChange: second
// call back-to-back returns bumped=false because the stored hash now
// matches the registered set.
func TestSettingsService_BumpVersionIfProvidersChanged_NoChange(t *testing.T) {
pool := newPool(t)
resetRegistryForTests()
defer resetRegistryForTests()
Register(&fakeAlbumProvider{fakeProvider: fakeProvider{id: "fake-album", defaultOn: true}})
s, err := NewSettingsService(context.Background(), pool, discardLogger())
if err != nil {
t.Fatalf("init: %v", err)
}
// First call bumps (empty stored hash → mismatch).
if _, _, err := s.BumpVersionIfProvidersChanged(context.Background()); err != nil {
t.Fatalf("first bump: %v", err)
}
afterFirst := s.CurrentVersion()
_, bumped, err := s.BumpVersionIfProvidersChanged(context.Background())
if err != nil {
t.Fatalf("second bump: %v", err)
}
if bumped {
t.Errorf("bumped = true on second call, want false (no change)")
}
if s.CurrentVersion() != afterFirst {
t.Errorf("version changed without a provider-set change")
}
}
// TestRegisteredProvidersHash_StableAcrossOrder: registry order
// shouldn't matter because we sort before hashing.
func TestRegisteredProvidersHash_StableAcrossOrder(t *testing.T) {
// Hash is a function of the registered set; in this test it just
// needs to be deterministic and non-empty.
h1 := registeredProvidersHash()
h2 := registeredProvidersHash()
if h1 != h2 {
t.Errorf("hash is not deterministic: %q vs %q", h1, h2)
}
if h1 == "" {
t.Errorf("hash empty; expected at least one provider registered (or empty set is valid)")
}
if len(h1) != 64 {
t.Errorf("hash length = %d, want 64 (SHA-256 hex)", len(h1))
}
}