From cce928e58416b8da977ccac3dab9f7f662b89597 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Jul 2026 22:24:28 -0400 Subject: [PATCH] feat(api): admin tag-sources endpoints (#1490 Step 4 backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the tag-enrichment provider settings over the admin API, mirroring the cover-sources surface so a Last.fm key can be pasted and sources toggled from the web admin UI (rules #25/#27). - GET /api/admin/tag-sources — list providers + version - PATCH/api/admin/tag-sources/{id} — enable / set api_key - POST /api/admin/tag-sources/{id}/test — test connection - POST /api/admin/tag-sources/research — bump version, re-open settled rows for re-enrich - Thread tags.SettingsService through server.New (struct field, like RecSettings) → Router → api.Mount → handlers.tagSettings; main.go sets srv.TagSettings. Handler tests mirror admin_cover_sources_test (list / flip-bumps-version / key-only-no-bump / unknown-404 / non-admin-403 / not-testable-ok-false), integration-tier (skip without MINSTREL_TEST_DATABASE_URL). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/minstrel/main.go | 1 + internal/api/admin_tag_sources.go | 153 ++++++++++++++++ internal/api/admin_tag_sources_test.go | 243 +++++++++++++++++++++++++ internal/api/api.go | 10 +- internal/api/library_test.go | 2 +- internal/server/server.go | 4 +- 6 files changed, 410 insertions(+), 3 deletions(-) create mode 100644 internal/api/admin_tag_sources.go create mode 100644 internal/api/admin_tag_sources_test.go diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 4a647b07..826e2aed 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -326,6 +326,7 @@ func run() error { srv.Bus = bus srv.PlaylistScheduler = playlistScheduler srv.RecSettings = recSettings + srv.TagSettings = tagSettings srv.StreamSecret = cfg.StreamSecret httpServer := &http.Server{ Addr: cfg.Server.Address, diff --git a/internal/api/admin_tag_sources.go b/internal/api/admin_tag_sources.go new file mode 100644 index 00000000..cc9b534b --- /dev/null +++ b/internal/api/admin_tag_sources.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/tags" +) + +// tagSourceResp is the wire shape for one tag-enrichment provider in the +// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface +// (admin_cover_sources.go) — a separate, independent settings card so a new +// tag source is added without touching art settings (#1490). +type tagSourceResp 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"` +} + +func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp { + supports := info.Supports + if supports == nil { + supports = []string{} + } + return tagSourceResp{ + ID: info.ID, + DisplayName: info.DisplayName, + RequiresAPIKey: info.RequiresAPIKey, + Supports: supports, + Enabled: info.Enabled, + APIKeySet: info.APIKeySet, + DisplayOrder: info.DisplayOrder, + Testable: info.Testable, + } +} + +type tagSourcesListResp struct { + Providers []tagSourceResp `json:"providers"` + SourcesVersion int32 `json:"sources_version"` +} + +// handleListTagSources implements GET /api/admin/tag-sources. +func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) { + infos := h.tagSettings.ListProviderInfo() + out := tagSourcesListResp{ + Providers: make([]tagSourceResp, 0, len(infos)), + SourcesVersion: h.tagSettings.CurrentVersion(), + } + for _, info := range infos { + out.Providers = append(out.Providers, tagSourceRespFrom(info)) + } + writeJSON(w, http.StatusOK, out) +} + +// updateTagSourceReq is the PATCH body. nil pointers mean "leave +// unchanged"; non-nil sets (empty api_key clears). +type updateTagSourceReq struct { + Enabled *bool `json:"enabled,omitempty"` + APIKey *string `json:"api_key,omitempty"` +} + +type updateTagSourceResp struct { + tagSourceResp + VersionBumped bool `json:"version_bumped"` +} + +// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}. +func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "provider_id") + + var body updateTagSourceReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, apierror.BadRequest("bad_body", "invalid JSON")) + return + } + + bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{ + Enabled: body.Enabled, + APIKey: body.APIKey, + }) + if err != nil { + if errors.Is(err, tags.ErrProviderNotFound) { + writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"}) + return + } + h.logger.Error("admin: update tag source", "id", id, "err", err) + writeErr(w, apierror.InternalMsg("update failed", err)) + return + } + + var found tagSourceResp + for _, info := range h.tagSettings.ListProviderInfo() { + if info.ID == id { + found = tagSourceRespFrom(info) + break + } + } + writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped}) +} + +type testTagSourceResp struct { + OK bool `json:"ok"` + DurationMs int64 `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test. +func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "provider_id") + + start := time.Now() + err := h.tagSettings.TestProvider(r.Context(), id) + duration := time.Since(start).Milliseconds() + + if err == nil { + writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration}) + return + } + if errors.Is(err, tags.ErrProviderNotFound) { + writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"}) + return + } + // Not testable, or the test failed — both surface as ok=false with a + // 200 (the operation completed; the result is data, not an error). + writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()}) +} + +type researchTagsResp struct { + SourcesVersion int32 `json:"sources_version"` +} + +// handleResearchTags implements POST /api/admin/tag-sources/research: bump +// the sources version so every settled ('none') track becomes eligible for +// a re-enrichment pass, then the background worker re-processes them. +func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) { + newVer, err := h.tagSettings.BumpVersion(r.Context()) + if err != nil { + h.logger.Error("admin: research tags", "err", err) + writeErr(w, apierror.InternalMsg("research failed", err)) + return + } + writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer}) +} diff --git a/internal/api/admin_tag_sources_test.go b/internal/api/admin_tag_sources_test.go new file mode 100644 index 00000000..cb00a0a8 --- /dev/null +++ b/internal/api/admin_tag_sources_test.go @@ -0,0 +1,243 @@ +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/tags" +) + +// apiTestTagProvider is a minimal TrackTagProvider for api-package tests. +// Does not implement TestableProvider. +type apiTestTagProvider struct { + id string + display string +} + +func (p *apiTestTagProvider) ID() string { return p.id } +func (p *apiTestTagProvider) DisplayName() string { return p.display } +func (p *apiTestTagProvider) RequiresAPIKey() bool { return false } +func (p *apiTestTagProvider) DefaultEnabled() bool { return true } +func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil } +func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) { + return []tags.Tag{{Name: "x", Weight: 1}}, nil +} + +// apiTestTestableTagProvider also implements TestableProvider. +type apiTestTestableTagProvider struct { + apiTestTagProvider +} + +func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil } + +func newAdminTagSourcesRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/tag-sources", h.handleListTagSources) + admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource) + admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource) + }) + return r +} + +// testHandlersWithTagSettings installs a tags.SettingsService backed by the +// test DB. Register the needed fake providers BEFORE calling this, and pair +// with tags.ResetRegistryForTests in t.Cleanup. +func testHandlersWithTagSettings(t *testing.T) *handlers { + t.Helper() + h, pool := testHandlers(t) + s, err := tags.NewSettingsService(context.Background(), pool, h.logger) + if err != nil { + t.Fatalf("NewSettingsService: %v", err) + } + h.tagSettings = s + return h +} + +func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"}) + + h := testHandlersWithTagSettings(t) + admin := seedUser(t, h.pool, "tslist", "pw", true) + + req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp tagSourcesListResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" { + t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers) + } + found := false + for _, s := range resp.Providers[0].Supports { + if s == "track_tags" { + found = true + } + } + if !found { + t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports) + } + if resp.Providers[0].Testable { + t.Error("testable = true, want false (no TestConnection)") + } +} + +func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"}) + + h := testHandlersWithTagSettings(t) + admin := seedUser(t, h.pool, "tsflip", "pw", true) + + disabled := false + body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled}) + req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp updateTagSourceResp + 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 TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"}) + + h := testHandlersWithTagSettings(t) + admin := seedUser(t, h.pool, "tskey", "pw", true) + + newKey := "newkey" + body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey}) + req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp updateTagSourceResp + 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 TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + + h := testHandlersWithTagSettings(t) + admin := seedUser(t, h.pool, "ts404", "pw", true) + + body, _ := json.Marshal(updateTagSourceReq{}) + req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}}) + + h := testHandlersWithTagSettings(t) + nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil) + req = withUser(req, nonAdmin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + tags.ResetRegistryForTests() + t.Cleanup(tags.ResetRegistryForTests) + tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"}) + + h := testHandlersWithTagSettings(t) + admin := seedUser(t, h.pool, "tsntest", "pw", true) + + req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminTagSourcesRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp testTagSourceResp + 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 a message") + } +} diff --git a/internal/api/api.go b/internal/api/api.go index a8649fb7..a16f8f23 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -23,13 +23,14 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" + "git.fabledsword.com/bvandeusen/minstrel/internal/tags" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) // 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, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -42,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev playlists: playlistsSvc, coverart: coverEnricher, coverSettings: coverSettings, + tagSettings: tagSettings, scanner: scanner, scanCfg: scanCfg, dataDir: dataDir, @@ -200,6 +202,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource) admin.Post("/cover-sources/research", h.handleResearchMissingArt) + admin.Get("/tag-sources", h.handleListTagSources) + admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource) + admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource) + admin.Post("/tag-sources/research", h.handleResearchTags) + admin.Get("/smtp-config", h.handleGetSMTPConfig) admin.Put("/smtp-config", h.handleUpdateSMTPConfig) admin.Post("/smtp-config/test", h.handleTestSMTPConfig) @@ -242,6 +249,7 @@ type handlers struct { playlists *playlists.Service coverart *coverart.Enricher coverSettings *coverart.SettingsService + tagSettings *tags.SettingsService scanner *library.Scanner scanCfg library.RunScanConfig dataDir string diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 370da98e..97544d40 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) paths := []string{ "/api/artists", diff --git a/internal/server/server.go b/internal/server/server.go index 05b3cb65..4b75e7b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,6 +29,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" + "git.fabledsword.com/bvandeusen/minstrel/internal/tags" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/web" ) @@ -77,6 +78,7 @@ type Server struct { BrandingCfg config.BrandingConfig CoverEnricher *coverart.Enricher CoverSettings *coverart.SettingsService + TagSettings *tags.SettingsService LibraryScanner *library.Scanner ScanCfg library.RunScanConfig // Bus is the live-event bus shared with background workers (the @@ -162,7 +164,7 @@ func (s *Server) Router() http.Handler { s.Logger.Error("server: recsettings boot failed", "err", err) } } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) // /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