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>
This commit is contained in:
2026-05-07 07:59:40 -04:00
parent 8f5ec4c304
commit c53bcb6c99
9 changed files with 224 additions and 1 deletions
+23
View File
@@ -311,6 +311,29 @@ func registeredProvidersHash() string {
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.
+38
View File
@@ -295,6 +295,44 @@ func TestSettingsService_BumpVersionIfProvidersChanged_NoChange(t *testing.T) {
}
}
// TestSettingsService_BumpVersion_Increments verifies the manual bump
// path. Distinct from BumpVersionIfProvidersChanged, this always writes
// regardless of the stored provider-set hash.
func TestSettingsService_BumpVersion_Increments(t *testing.T) {
pool := newPool(t)
resetRegistryForTests()
defer resetRegistryForTests()
Register(&fakeAlbumProvider{fakeProvider: fakeProvider{id: "fake-album-bump", defaultOn: true}})
s, err := NewSettingsService(context.Background(), pool, discardLogger())
if err != nil {
t.Fatalf("init: %v", err)
}
before := s.CurrentVersion()
newVer, err := s.BumpVersion(context.Background())
if err != nil {
t.Fatalf("bump: %v", err)
}
if newVer <= before {
t.Errorf("newVer = %d, want > %d", newVer, before)
}
if s.CurrentVersion() != newVer {
t.Errorf("in-memory version = %d, want %d", s.CurrentVersion(), newVer)
}
// Calling again increments by another 1 (always writes).
afterFirst := newVer
newVer2, err := s.BumpVersion(context.Background())
if err != nil {
t.Fatalf("second bump: %v", err)
}
if newVer2 != afterFirst+1 {
t.Errorf("second newVer = %d, want %d", newVer2, afterFirst+1)
}
}
// TestRegisteredProvidersHash_StableAcrossOrder: registry order
// shouldn't matter because we sort before hashing.
func TestRegisteredProvidersHash_StableAcrossOrder(t *testing.T) {