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:
2026-05-06 22:26:16 -04:00
parent 4607ce6db4
commit b650a121b5
7 changed files with 351 additions and 13 deletions
+5 -1
View File
@@ -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, coverSettings *coverart.SettingsService, 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, scheduler *library.Scheduler, dataDir string) {
rng := rand.New(rand.NewSource(rand.Int63()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -41,6 +41,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
coverSettings: coverSettings,
scanner: scanner,
scanCfg: scanCfg,
scheduler: scheduler,
dataDir: dataDir,
}
@@ -115,6 +116,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
admin.Get("/scan/status", h.handleGetScanStatus)
admin.Post("/scan/run", h.handleTriggerScan)
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
@@ -152,5 +155,6 @@ type handlers struct {
coverSettings *coverart.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
scheduler *library.Scheduler
dataDir string
}