diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 840ef23d..cb7fffcb 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -144,9 +144,13 @@ func run() error { } } + scanCfg := library.RunScanConfig{ + BackfillCap: 5000, + EnrichCap: cfg.Library.CoverArtBackfillCap, + } 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) + }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, cfg.Library.CoverArtBackfillCap, 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_scan.go b/internal/api/admin_scan.go new file mode 100644 index 00000000..e4736825 --- /dev/null +++ b/internal/api/admin_scan.go @@ -0,0 +1,99 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" +) + +// scanStatusResp mirrors the scan_runs row on the wire. Stage tallies are +// surfaced as json.RawMessage so the frontend gets actual JSON objects +// rather than base64 byte arrays — the DB column is bytea-shaped jsonb in +// pgx but the data is already valid JSON. +type scanStatusResp struct { + ID string `json:"id"` + StartedAt string `json:"started_at"` + FinishedAt *string `json:"finished_at"` + Library json.RawMessage `json:"library,omitempty"` + MbidBackfill json.RawMessage `json:"mbid_backfill,omitempty"` + CoverEnrich json.RawMessage `json:"cover_enrich,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + InFlight bool `json:"in_flight"` +} + +// handleGetScanStatus implements GET /api/admin/scan/status. +// Returns 200 with an empty payload (no fields except in_flight=false) when +// no scan has ever run. +func (h *handlers) handleGetScanStatus(w http.ResponseWriter, r *http.Request) { + q := dbq.New(h.pool) + row, err := q.GetLatestScanRun(r.Context()) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeJSON(w, http.StatusOK, scanStatusResp{InFlight: false}) + return + } + h.logger.Error("admin: get scan status", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + + resp := scanStatusResp{ + ID: uuidToString(row.ID), + StartedAt: row.StartedAt.Time.Format("2006-01-02T15:04:05Z07:00"), + ErrorMessage: row.ErrorMessage, + InFlight: !row.FinishedAt.Valid, + } + if row.FinishedAt.Valid { + s := row.FinishedAt.Time.Format("2006-01-02T15:04:05Z07:00") + resp.FinishedAt = &s + } + if len(row.Library) > 0 { + resp.Library = row.Library + } + if len(row.MbidBackfill) > 0 { + resp.MbidBackfill = row.MbidBackfill + } + if len(row.CoverEnrich) > 0 { + resp.CoverEnrich = row.CoverEnrich + } + writeJSON(w, http.StatusOK, resp) +} + +type scanTriggerResp struct { + ID string `json:"id,omitempty"` +} + +// handleTriggerScan implements POST /api/admin/scan/run. +// Returns 202 on success, 409 if a scan is already in flight. +// The scan runs in a background goroutine detached from the request context. +func (h *handlers) handleTriggerScan(w http.ResponseWriter, _ *http.Request) { + bgCtx := context.Background() + q := dbq.New(h.pool) + + existing, err := q.GetInFlightScanRun(bgCtx) + if err == nil { + writeJSON(w, http.StatusConflict, scanTriggerResp{ID: uuidToString(existing.ID)}) + return + } + if !errors.Is(err, pgx.ErrNoRows) { + h.logger.Error("admin: in-flight check failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "in-flight check failed") + return + } + + // Detached goroutine — must outlive the HTTP request. + go func() { + if _, err := library.RunScan(bgCtx, h.pool, h.scanner, h.coverart, + h.logger.With("component", "scan_run"), h.scanCfg); err != nil { + h.logger.Warn("manual scan run failed", "err", err) + } + }() + + writeJSON(w, http.StatusAccepted, scanTriggerResp{}) +} diff --git a/internal/api/admin_scan_test.go b/internal/api/admin_scan_test.go new file mode 100644 index 00000000..db76b8d9 --- /dev/null +++ b/internal/api/admin_scan_test.go @@ -0,0 +1,102 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" +) + +func newAdminScanRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/scan/status", h.handleGetScanStatus) + admin.Post("/scan/run", h.handleTriggerScan) + }) + return r +} + +func TestAdminScanStatus_NoRowReturnsEmpty(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "rootscan", "pw", true) + req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/status", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminScanRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp scanStatusResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ID != "" || resp.InFlight { + t.Errorf("expected empty payload; got %+v", resp) + } +} + +func TestAdminScanStatus_RowReturnsValues(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "rootscan2", "pw", true) + if _, err := pool.Exec(context.Background(), ` + INSERT INTO scan_runs (started_at, finished_at, library) + VALUES (now() - interval '1 minute', now(), '{"scanned":10,"added":2}'::jsonb) + `); err != nil { + t.Fatalf("seed scan_runs: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/status", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminScanRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var resp scanStatusResp + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.InFlight { + t.Error("expected InFlight=false (finished_at set)") + } + if resp.FinishedAt == nil { + t.Error("expected FinishedAt to be non-nil") + } + if len(resp.Library) == 0 { + t.Error("expected Library jsonb to be non-empty") + } +} + +func TestAdminTriggerScan_InFlightReturns409(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + admin := seedUser(t, pool, "rootscan3", "pw", true) + if _, err := pool.Exec(context.Background(), ` + INSERT INTO scan_runs (started_at) VALUES (now()) + `); err != nil { + t.Fatalf("seed in-flight scan: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/admin/scan/run", nil) + req = withUser(req, admin) + rec := httptest.NewRecorder() + newAdminScanRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 659bcfea..bde9aaa9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -14,6 +14,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" @@ -25,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, 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, 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, @@ -37,6 +38,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev playlists: playlistsSvc, coverart: coverEnricher, coverArtBackfillCap: coverBackfillCap, + scanner: scanner, + scanCfg: scanCfg, dataDir: dataDir, } @@ -108,6 +111,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover) admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers) + + admin.Get("/scan/status", h.handleGetScanStatus) + admin.Post("/scan/run", h.handleTriggerScan) }) authed.Get("/playlists", h.handleListPlaylists) @@ -136,5 +142,7 @@ type handlers struct { playlists *playlists.Service coverart *coverart.Enricher coverArtBackfillCap int + scanner *library.Scanner + scanCfg library.RunScanConfig dataDir string } diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 39590574..d43d5327 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -22,6 +22,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" + "git.fabledsword.com/bvandeusen/minstrel/internal/library" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrconfig" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" @@ -68,7 +69,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { tracksSvc := tracks.NewService(pool, logger, nil) dataDir := t.TempDir() playlistsSvc := playlists.NewService(pool, logger, dataDir) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir} + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}} return h, pool } diff --git a/internal/server/server.go b/internal/server/server.go index 2fe4ea62..7c119033 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -72,10 +72,12 @@ type Server struct { BrandingCfg config.BrandingConfig CoverEnricher *coverart.Enricher CoverArtBackfillCap int + 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) *Server { - return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverArtBackfillCap: coverArtBackfillCap} +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 (s *Server) Router() http.Handler { @@ -109,7 +111,7 @@ func (s *Server) Router() http.Handler { lidarrQuar := lidarrquarantine.NewService(s.Pool, lidarrCfg, lidarrClientFn) tracksSvc := tracks.NewService(s.Pool, s.Logger, lidarrUnmonitorAdapter{fn: lidarrClientFn}) 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.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/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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index fa7a7420..97b14134 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -24,7 +24,7 @@ import ( ) func TestHealthz(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) { } func TestRouter_ServesSPAAtRoot(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) { } func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) { } func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) { } func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) { - s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close() @@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) { } s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool, - stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0) + stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, library.RunScanConfig{}) ts := httptest.NewServer(s.Router()) defer ts.Close()