feat(server/m7-cover-sources): admin cover-sources HTTP handlers
Three endpoints for the admin Settings UI:
- GET /api/admin/cover-sources — lists registered providers with
current settings + capability badges + sources_version
- PATCH /api/admin/cover-sources/{provider_id} — updates enabled
and/or api_key; returns version_bumped: true only when the
enabled set changed
- POST /api/admin/cover-sources/{provider_id}/test — invokes
TestableProvider.TestConnection; returns ok:bool + duration_ms +
error string. Returns 200 in both success and failure cases (the
operation completed; the test result is data, not an error).
api_key is never echoed in GET — replaced with api_key_set: bool
to follow the standard credential-display convention. PATCH
api_key: "" clears; omitting the field leaves it unchanged. PATCH
on an unknown provider_id returns 404.
All under existing RequireAdmin middleware. Routes registered
alongside the library/coverage endpoint in the /api/admin/ tree.
handlers struct gains a coverSettings *coverart.SettingsService
field; server.New + cmd/minstrel/main.go plumb the existing
coverSettings instance through.
coverart.ResetRegistryForTests() exported wrapper added so api
package tests can reset the registry without importing internals.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -165,7 +165,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, coverSettings, scanner, scanCfg)
|
||||||
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"))
|
playlists.StartSystemPlaylistCron(ctx, pool, logger.With("component", "system_playlist_cron"))
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
|
)
|
||||||
|
|
||||||
|
// coverProviderResp is the wire shape for one provider in the
|
||||||
|
// GET /api/admin/cover-sources list. Mirrors coverart.ProviderInfo
|
||||||
|
// but uses snake_case JSON tags.
|
||||||
|
type coverProviderResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RequiresAPIKey bool `json:"requires_api_key"`
|
||||||
|
Supports []string `json:"supports"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverProvidersListResp struct {
|
||||||
|
Providers []coverProviderResp `json:"providers"`
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListCoverSources implements GET /api/admin/cover-sources.
|
||||||
|
func (h *handlers) handleListCoverSources(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
infos := h.coverSettings.ListProviderInfo()
|
||||||
|
out := coverProvidersListResp{
|
||||||
|
Providers: make([]coverProviderResp, 0, len(infos)),
|
||||||
|
SourcesVersion: h.coverSettings.CurrentVersion(),
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
out.Providers = append(out.Providers, coverProviderResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCoverSourceReq is the PATCH body. nil pointers mean
|
||||||
|
// "leave unchanged"; non-nil means "set to this value (which may be
|
||||||
|
// empty string for api_key clearing)".
|
||||||
|
type updateCoverSourceReq struct {
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateCoverSourceResp struct {
|
||||||
|
coverProviderResp
|
||||||
|
VersionBumped bool `json:"version_bumped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateCoverSource implements PATCH /api/admin/cover-sources/{provider_id}.
|
||||||
|
func (h *handlers) handleUpdateCoverSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
var body updateCoverSourceReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, http.StatusBadRequest, "bad_body", "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := h.coverSettings.UpdateProvider(r.Context(), id, coverart.ProviderUpdatePatch{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, coverart.ErrProviderNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "no such provider")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin: update cover source", "id", id, "err", err)
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server_error", "update failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch the provider info to return.
|
||||||
|
infos := h.coverSettings.ListProviderInfo()
|
||||||
|
var found coverProviderResp
|
||||||
|
for _, info := range infos {
|
||||||
|
if info.ID == id {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
found = coverProviderResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updateCoverSourceResp{
|
||||||
|
coverProviderResp: found,
|
||||||
|
VersionBumped: bumped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testCoverSourceResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestCoverSource implements POST /api/admin/cover-sources/{provider_id}/test.
|
||||||
|
func (h *handlers) handleTestCoverSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := h.coverSettings.TestProvider(r.Context(), id)
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, testCoverSourceResp{OK: true, DurationMs: duration})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, coverart.ErrProviderNotFound) {
|
||||||
|
writeErr(w, http.StatusNotFound, "not_found", "no such provider")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not testable, or test failed — both surface as ok=false.
|
||||||
|
// 200 status because the operation completed; the test result is
|
||||||
|
// data, not an error.
|
||||||
|
writeJSON(w, http.StatusOK, testCoverSourceResp{
|
||||||
|
OK: false,
|
||||||
|
DurationMs: duration,
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiTestAlbumProvider is a minimal AlbumCoverProvider for use in api
|
||||||
|
// package tests. Does not implement TestableProvider.
|
||||||
|
type apiTestAlbumProvider struct {
|
||||||
|
id string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestAlbumProvider) ID() string { return p.id }
|
||||||
|
func (p *apiTestAlbumProvider) DisplayName() string { return p.display }
|
||||||
|
func (p *apiTestAlbumProvider) RequiresAPIKey() bool { return false }
|
||||||
|
func (p *apiTestAlbumProvider) DefaultEnabled() bool { return true }
|
||||||
|
func (p *apiTestAlbumProvider) Configure(_ coverart.ProviderSettings) error { return nil }
|
||||||
|
func (p *apiTestAlbumProvider) FetchAlbumCover(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
return []byte("img"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTestTestableProvider is an AlbumCoverProvider that also implements
|
||||||
|
// TestableProvider (so the test connection button exercises the success path).
|
||||||
|
type apiTestTestableProvider struct {
|
||||||
|
apiTestAlbumProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTestableProvider) TestConnection(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newAdminCoverSourcesRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||||
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||||
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testHandlersWithCoverSettings extends testHandlers by installing a
|
||||||
|
// SettingsService backed by the test DB. Caller is responsible for
|
||||||
|
// registering whichever fake providers the test needs BEFORE calling
|
||||||
|
// this helper, via coverart.Register. Use coverart.ResetRegistryForTests
|
||||||
|
// with t.Cleanup to restore registry state after the test.
|
||||||
|
func testHandlersWithCoverSettings(t *testing.T) (*handlers, *coverart.SettingsService) {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
s, err := coverart.NewSettingsService(context.Background(), pool, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService: %v", err)
|
||||||
|
}
|
||||||
|
h.coverSettings = s
|
||||||
|
return h, s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminListCoverSources_ReturnsRegisteredProviders(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: "test-album-prov", display: "Test Album Provider"})
|
||||||
|
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "cslist", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/cover-sources", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp coverProvidersListResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Providers) != 1 {
|
||||||
|
t.Fatalf("providers len = %d, want 1", len(resp.Providers))
|
||||||
|
}
|
||||||
|
p := resp.Providers[0]
|
||||||
|
if p.ID != "test-album-prov" {
|
||||||
|
t.Errorf("ID = %q, want test-album-prov", p.ID)
|
||||||
|
}
|
||||||
|
if p.DisplayName != "Test Album Provider" {
|
||||||
|
t.Errorf("DisplayName = %q, want Test Album Provider", p.DisplayName)
|
||||||
|
}
|
||||||
|
if p.APIKeySet {
|
||||||
|
t.Error("api_key_set = true, want false (no key set)")
|
||||||
|
}
|
||||||
|
// apiTestAlbumProvider implements AlbumCoverProvider but not TestableProvider.
|
||||||
|
if p.Testable {
|
||||||
|
t.Error("testable = true, want false")
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range p.Supports {
|
||||||
|
if s == "album_cover" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("supports = %v, want to include album_cover", p.Supports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateCoverSource_FlippingEnabledBumpsVersion(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: "flip-prov", display: "Flip Provider"})
|
||||||
|
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "csflip", "pw", true)
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
body, _ := json.Marshal(updateCoverSourceReq{Enabled: &disabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/flip-prov", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateCoverSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = false, want true after flipping enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateCoverSource_KeyOnlyDoesNotBump(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: "key-prov", display: "Key Provider"})
|
||||||
|
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "cskey", "pw", true)
|
||||||
|
|
||||||
|
newKey := "newkey"
|
||||||
|
body, _ := json.Marshal(updateCoverSourceReq{APIKey: &newKey})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/key-prov", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateCoverSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = true, want false for key-only change")
|
||||||
|
}
|
||||||
|
if !resp.APIKeySet {
|
||||||
|
t.Error("api_key_set = false, want true after setting key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateCoverSource_UnknownProvider404(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
coverart.ResetRegistryForTests()
|
||||||
|
t.Cleanup(coverart.ResetRegistryForTests)
|
||||||
|
|
||||||
|
// No providers registered — anything we patch will be not-found.
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "cs404", "pw", true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(updateCoverSourceReq{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/cover-sources/missing", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestCoverSource_NonAdminReturns403(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(&apiTestTestableProvider{apiTestAlbumProvider{id: "testable-prov", display: "Testable"}})
|
||||||
|
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
nonAdmin := seedUser(t, h.pool, "csnoadmin", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/testable-prov/test", nil)
|
||||||
|
req = withUser(req, nonAdmin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestCoverSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
coverart.ResetRegistryForTests()
|
||||||
|
t.Cleanup(coverart.ResetRegistryForTests)
|
||||||
|
|
||||||
|
// apiTestAlbumProvider does NOT implement TestableProvider.
|
||||||
|
coverart.Register(&apiTestAlbumProvider{id: "nontestable-prov", display: "Non-Testable"})
|
||||||
|
|
||||||
|
h, _ := testHandlersWithCoverSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "csntest", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/cover-sources/nontestable-prov/test", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminCoverSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp testCoverSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OK {
|
||||||
|
t.Error("ok = true, want false for non-testable provider")
|
||||||
|
}
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Error("error string empty, want message mentioning test connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-1
@@ -26,7 +26,7 @@ import (
|
|||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverBackfillCap int, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
@@ -38,6 +38,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverArtBackfillCap: coverBackfillCap,
|
coverArtBackfillCap: coverBackfillCap,
|
||||||
|
coverSettings: coverSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -116,6 +117,10 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
|
|
||||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||||
|
|
||||||
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||||
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||||
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -144,6 +149,7 @@ type handlers struct {
|
|||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverArtBackfillCap int
|
coverArtBackfillCap int
|
||||||
|
coverSettings *coverart.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
|
|||||||
@@ -153,3 +153,10 @@ func resetRegistryForTests() {
|
|||||||
defer registry.mu.Unlock()
|
defer registry.mu.Unlock()
|
||||||
registry.providers = nil
|
registry.providers = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResetRegistryForTests is an exported wrapper around resetRegistryForTests
|
||||||
|
// for use by tests in other packages (e.g. internal/api). Never call from
|
||||||
|
// production code.
|
||||||
|
func ResetRegistryForTests() {
|
||||||
|
resetRegistryForTests()
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,12 +72,13 @@ type Server struct {
|
|||||||
BrandingCfg config.BrandingConfig
|
BrandingCfg config.BrandingConfig
|
||||||
CoverEnricher *coverart.Enricher
|
CoverEnricher *coverart.Enricher
|
||||||
CoverArtBackfillCap int
|
CoverArtBackfillCap int
|
||||||
|
CoverSettings *coverart.SettingsService
|
||||||
LibraryScanner *library.Scanner
|
LibraryScanner *library.Scanner
|
||||||
ScanCfg library.RunScanConfig
|
ScanCfg library.RunScanConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
|
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverArtBackfillCap int, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
|
||||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverArtBackfillCap: coverArtBackfillCap, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
|
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverArtBackfillCap: coverArtBackfillCap, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) Router() http.Handler {
|
func (s *Server) Router() http.Handler {
|
||||||
@@ -111,7 +112,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn)
|
||||||
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, s.DataDir)
|
||||||
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
|
||||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.LibraryScanner, s.ScanCfg, s.DataDir)
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverArtBackfillCap, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir)
|
||||||
// /api/admin/scan is the only admin route owned by the server package
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
Reference in New Issue
Block a user