From b6632136b7aa3558b31d140881fe99f453564bd4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 6 May 2026 13:08:45 -0400 Subject: [PATCH] feat(server/m7-cover-sources): admin cover-sources HTTP handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/minstrel/main.go | 2 +- internal/api/admin_cover_sources.go | 156 ++++++++++++++ internal/api/admin_cover_sources_test.go | 264 +++++++++++++++++++++++ internal/api/api.go | 8 +- internal/coverart/provider.go | 7 + internal/server/server.go | 7 +- 6 files changed, 439 insertions(+), 5 deletions(-) create mode 100644 internal/api/admin_cover_sources.go create mode 100644 internal/api/admin_cover_sources_test.go diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 767d1bac..ea83344f 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -165,7 +165,7 @@ func run() error { } srv := server.New(logger, pool, scanner, subsonic.Config{ 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")) httpServer := &http.Server{ Addr: cfg.Server.Address, diff --git a/internal/api/admin_cover_sources.go b/internal/api/admin_cover_sources.go new file mode 100644 index 00000000..80a7d988 --- /dev/null +++ b/internal/api/admin_cover_sources.go @@ -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(), + }) +} diff --git a/internal/api/admin_cover_sources_test.go b/internal/api/admin_cover_sources_test.go new file mode 100644 index 00000000..029ace76 --- /dev/null +++ b/internal/api/admin_cover_sources_test.go @@ -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") + } +} diff --git a/internal/api/api.go b/internal/api/api.go index fecb7520..aab56661 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -26,7 +26,7 @@ import ( // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // 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())) h := &handlers{ 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, coverart: coverEnricher, coverArtBackfillCap: coverBackfillCap, + coverSettings: coverSettings, scanner: scanner, scanCfg: scanCfg, 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.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) @@ -144,6 +149,7 @@ type handlers struct { playlists *playlists.Service coverart *coverart.Enricher coverArtBackfillCap int + coverSettings *coverart.SettingsService scanner *library.Scanner scanCfg library.RunScanConfig dataDir string diff --git a/internal/coverart/provider.go b/internal/coverart/provider.go index d81dfe8a..bebf5658 100644 --- a/internal/coverart/provider.go +++ b/internal/coverart/provider.go @@ -153,3 +153,10 @@ func resetRegistryForTests() { defer registry.mu.Unlock() 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() +} diff --git a/internal/server/server.go b/internal/server/server.go index 3120fdd7..73d9ff8e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -72,12 +72,13 @@ type Server struct { BrandingCfg config.BrandingConfig CoverEnricher *coverart.Enricher CoverArtBackfillCap int + CoverSettings *coverart.SettingsService LibraryScanner *library.Scanner 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 { - 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} +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, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg} } func (s *Server) Router() http.Handler { @@ -111,7 +112,7 @@ func (s *Server) Router() http.Handler { lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}, 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 // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second