feat(server/m7-recurring-scans): admin schedule HTTP handlers
Two new endpoints under existing RequireAdmin middleware: - GET /api/admin/scan/schedule — returns config + computed next_scheduled_at (ISO 8601; null when mode='off') - PATCH /api/admin/scan/schedule — validates per-mode required fields, normalizes mode='off' to NULL the per-mode fields, writes via UpdateScanSchedule, calls scheduler.Refresh(), returns the updated record. server.New + api.Mount gain a *library.Scheduler parameter; handlers struct + Server struct gain the scheduler field. Test stubs (server_test.go, library_test.go's Mount call) updated to pass nil. Tests gated on MINSTREL_TEST_DATABASE_URL: GET default is mode=off with null next; PATCH daily produces a 03:00 next-fire; PATCH weekly without weekly_day → 400; PATCH off normalizes lingering per-mode fields to NULL; non-admin → 403. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -77,10 +77,11 @@ type Server struct {
|
||||
CoverSettings *coverart.SettingsService
|
||||
LibraryScanner *library.Scanner
|
||||
ScanCfg library.RunScanConfig
|
||||
Scheduler *library.Scheduler
|
||||
}
|
||||
|
||||
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 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, scheduler *library.Scheduler) *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, Scheduler: scheduler}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -114,7 +115,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.CoverSettings, 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.Scheduler, 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
|
||||
|
||||
@@ -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, nil, nil, library.RunScanConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
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, nil, nil, library.RunScanConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
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, nil, nil, library.RunScanConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
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, nil, nil, library.RunScanConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
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, nil, nil, library.RunScanConfig{})
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
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, nil, nil, library.RunScanConfig{})
|
||||
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, 0, nil, nil, library.RunScanConfig{}, nil)
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user