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
+22
View File
@@ -0,0 +1,22 @@
package api
import "net/http"
// handleResearchMissingArt bumps the cover-art version unconditionally
// so every 'none' row becomes eligible at the next enrichment pass.
// Operator's escape hatch when adding a Last.fm key, when an upstream
// provider's catalog has updated, or when verifying a fix without
// waiting for the next scheduled scan.
//
// Existing positively-sourced rows (sidecar/embedded/mbcaa/theaudiodb/
// deezer/lastfm) are not affected — the version-mismatch eligibility
// rule only kicks in for 'none'.
func (h *handlers) handleResearchMissingArt(w http.ResponseWriter, r *http.Request) {
newVer, err := h.coverSettings.BumpVersion(r.Context())
if err != nil {
h.logger.Error("admin: research missing art bump failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "bump failed")
return
}
writeJSON(w, http.StatusOK, map[string]any{"version": newVer})
}
@@ -0,0 +1,74 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
func newResearchRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
})
return r
}
func TestAdminResearchMissingArt_BumpsAndReturnsVersion(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
coverart.ResetRegistryForTests()
t.Cleanup(coverart.ResetRegistryForTests)
coverart.Register(&apiTestAlbumProvider{id: "research-prov", display: "Research Provider"})
h, _ := testHandlersWithCoverSettings(t)
admin := seedUser(t, h.pool, "researchadmin", "pw", true)
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/research", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newResearchRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := resp["version"]; !ok {
t.Errorf("response missing 'version' field; got %v", resp)
}
}
func TestAdminResearchMissingArt_NonAdmin_403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
coverart.ResetRegistryForTests()
t.Cleanup(coverart.ResetRegistryForTests)
coverart.Register(&apiTestAlbumProvider{id: "research-prov2", display: "Research Provider 2"})
h, _ := testHandlersWithCoverSettings(t)
user := seedUser(t, h.pool, "researchnonadmin", "pw", false)
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/research", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newResearchRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
}
+1
View File
@@ -124,6 +124,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/cover-sources", h.handleListCoverSources)
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
})
authed.Get("/playlists", h.handleListPlaylists)