diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 285f00ff..4657f232 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -5,12 +5,15 @@ import ( "errors" "flag" "fmt" + "log/slog" "net/http" "os" "os/signal" "syscall" "time" + "github.com/jackc/pgx/v5/pgxpool" + "git.fabledsword.com/bvandeusen/minstrel/internal/config" "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" "git.fabledsword.com/bvandeusen/minstrel/internal/db" @@ -53,6 +56,35 @@ func main() { } } +// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency +// full delta walk, catching anything the watcher missed (inotify watch-limit +// exhaustion, dropped events). Fixed — there is no operator configuration. +const safetyNetScanInterval = 12 * time.Hour + +// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx +// is cancelled, deferring to TryStartScan's in-flight guard so it never +// collides with a manual, startup, or watcher-driven scan. +func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner, + enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig, +) { + ticker := time.NewTicker(safetyNetScanInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg) + if err != nil { + logger.Error("safety-net scan: try start failed", "err", err) + } else if !started && existing != nil { + logger.Info("safety-net scan skipped — prior still in flight", + "in_flight_id", existing.ID) + } + } + } +} + func run() error { configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file") flag.Parse() @@ -237,12 +269,24 @@ func run() error { ArtistEnrichCap: -1, DataDir: cfg.Storage.DataDir, } - scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"), - scanner, coverEnricher, scanCfg) - scheduler.Start(ctx) + // Filesystem watcher: near-instant pickup of new/changed files via + // fsnotify, scanning just the affected paths + enriching their albums + // inline. Replaces the removed configurable scan scheduler. + watcher := library.NewWatcher(scanner, coverEnricher, + logger.With("component", "watcher"), cfg.Library.ScanPaths) + go func() { + if werr := watcher.Run(ctx); werr != nil { + logger.Warn("library watcher exited", "err", werr) + } + }() + // Safety-net delta walk backstops the watcher (inotify limits / dropped + // events) at a fixed low frequency. No operator config. + go runSafetyNetScans(ctx, pool, scanner, coverEnricher, + logger.With("component", "scan_safetynet"), scanCfg) + srv := server.New(logger, pool, scanner, subsonic.Config{ AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword, - }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler) + }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg) srv.Bus = bus srv.PlaylistScheduler = playlistScheduler srv.StreamSecret = cfg.StreamSecret diff --git a/internal/api/admin_scan_schedule.go b/internal/api/admin_scan_schedule.go deleted file mode 100644 index 539b6d60..00000000 --- a/internal/api/admin_scan_schedule.go +++ /dev/null @@ -1,155 +0,0 @@ -package api - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "time" - - "github.com/jackc/pgx/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" -) - -type scanScheduleResp struct { - Mode string `json:"mode"` - IntervalHours *int `json:"interval_hours"` - TimeOfDay *string `json:"time_of_day"` - WeeklyDay *int `json:"weekly_day"` - NextScheduledAt *string `json:"next_scheduled_at"` -} - -type scanScheduleReq struct { - Mode string `json:"mode"` - IntervalHours *int `json:"interval_hours"` - TimeOfDay *string `json:"time_of_day"` - WeeklyDay *int `json:"weekly_day"` -} - -func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) { - q := dbq.New(h.pool) - row, err := q.GetScanSchedule(r.Context()) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - h.logger.Error("admin: scan_schedule singleton row missing") - writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing"))) - return - } - writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err)) - return - } - - writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire())) -} - -func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) { - var req scanScheduleReq - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeErr(w, apierror.BadRequest("bad_body", "invalid JSON")) - return - } - - if err := validateScheduleReq(&req); err != nil { - writeErr(w, apierror.BadRequest("validation", err.Error())) - return - } - - q := dbq.New(h.pool) - if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{ - Mode: req.Mode, - IntervalHours: pgInt32Ptr(req.IntervalHours), - TimeOfDay: req.TimeOfDay, - WeeklyDay: pgInt32Ptr(req.WeeklyDay), - }); err != nil { - writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err)) - return - } - - h.scheduler.Refresh() - - row, err := q.GetScanSchedule(r.Context()) - if err != nil { - writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err)) - return - } - cfg := library.ScheduleConfig{Mode: row.Mode} - if row.IntervalHours != nil { - cfg.IntervalHours = int(*row.IntervalHours) - } - if row.TimeOfDay != nil { - cfg.TimeOfDay = *row.TimeOfDay - } - if row.WeeklyDay != nil { - cfg.WeeklyDay = int(*row.WeeklyDay) - } - next := cfg.NextFire(time.Now()) - - writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next)) -} - -func validateScheduleReq(req *scanScheduleReq) error { - switch req.Mode { - case "off": - req.IntervalHours = nil - req.TimeOfDay = nil - req.WeeklyDay = nil - return nil - case "interval": - if req.IntervalHours == nil || *req.IntervalHours <= 0 { - return fmt.Errorf("interval_hours required and > 0 when mode=interval") - } - req.TimeOfDay = nil - req.WeeklyDay = nil - return nil - case "daily": - if req.TimeOfDay == nil || *req.TimeOfDay == "" { - return fmt.Errorf("time_of_day required when mode=daily") - } - req.IntervalHours = nil - req.WeeklyDay = nil - return nil - case "weekly": - if req.TimeOfDay == nil || *req.TimeOfDay == "" { - return fmt.Errorf("time_of_day required when mode=weekly") - } - if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 { - return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly") - } - req.IntervalHours = nil - return nil - default: - return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode) - } -} - -func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp { - resp := scanScheduleResp{Mode: row.Mode} - if row.IntervalHours != nil { - v := int(*row.IntervalHours) - resp.IntervalHours = &v - } - if row.TimeOfDay != nil { - v := *row.TimeOfDay - resp.TimeOfDay = &v - } - if row.WeeklyDay != nil { - v := int(*row.WeeklyDay) - resp.WeeklyDay = &v - } - if !nextFire.IsZero() { - s := nextFire.UTC().Format(time.RFC3339) - resp.NextScheduledAt = &s - } - return resp -} - -func pgInt32Ptr(src *int) *int32 { - if src == nil { - return nil - } - v := int32(*src) - return &v -} diff --git a/internal/api/admin_scan_schedule_test.go b/internal/api/admin_scan_schedule_test.go deleted file mode 100644 index bb648d1e..00000000 --- a/internal/api/admin_scan_schedule_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/go-chi/chi/v5" - - "git.fabledsword.com/bvandeusen/minstrel/internal/auth" - "git.fabledsword.com/bvandeusen/minstrel/internal/library" -) - -func newAdminScheduleRouter(h *handlers) chi.Router { - r := chi.NewRouter() - r.Route("/api/admin", func(admin chi.Router) { - admin.Use(auth.RequireAdmin()) - admin.Get("/scan/schedule", h.handleGetScanSchedule) - admin.Patch("/scan/schedule", h.handlePatchScanSchedule) - }) - return r -} - -func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{}) - h.scheduler = s - admin := seedUser(t, pool, "schedget", "pw", true) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil) - req = withUser(req, admin) - rec := httptest.NewRecorder() - newAdminScheduleRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var resp scanScheduleResp - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Mode != "off" { - t.Errorf("Mode = %q, want off", resp.Mode) - } - if resp.NextScheduledAt != nil { - t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt) - } -} - -func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{}) - h.scheduler = s - admin := seedUser(t, pool, "schedpatch", "pw", true) - - body := `{"mode":"daily","time_of_day":"03:00"}` - req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule", - bytes.NewReader([]byte(body))) - req.Header.Set("Content-Type", "application/json") - req = withUser(req, admin) - rec := httptest.NewRecorder() - newAdminScheduleRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - var resp scanScheduleResp - if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Mode != "daily" { - t.Errorf("Mode = %q, want daily", resp.Mode) - } - if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" { - t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay) - } - if resp.NextScheduledAt == nil { - t.Errorf("NextScheduledAt = nil, want a future ISO timestamp") - } else { - next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt) - if err != nil { - t.Errorf("NextScheduledAt parse: %v", err) - } else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 { - // Handler computes next-fire from time.Now() in the host zone, so - // the local-time hour is what's stable across CI runners regardless - // of TZ; the wire timestamp is UTC but parses back to the same - // instant either way. - t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local) - } - } -} - -func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{}) - h.scheduler = s - admin := seedUser(t, pool, "schedweekly", "pw", true) - - body := `{"mode":"weekly","time_of_day":"03:00"}` - req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule", - bytes.NewReader([]byte(body))) - req.Header.Set("Content-Type", "application/json") - req = withUser(req, admin) - rec := httptest.NewRecorder() - newAdminScheduleRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) - } -} - -func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{}) - h.scheduler = s - admin := seedUser(t, pool, "schedoff", "pw", true) - - if _, err := pool.Exec(context.Background(), - `UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`, - ); err != nil { - t.Fatalf("seed: %v", err) - } - - body := `{"mode":"off","time_of_day":"03:00"}` - req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule", - bytes.NewReader([]byte(body))) - req.Header.Set("Content-Type", "application/json") - req = withUser(req, admin) - rec := httptest.NewRecorder() - newAdminScheduleRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) - } - - var timeOfDay *string - if err := pool.QueryRow(context.Background(), - `SELECT time_of_day FROM scan_schedule WHERE id=true`, - ).Scan(&timeOfDay); err != nil { - t.Fatalf("verify: %v", err) - } - if timeOfDay != nil { - t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay) - } -} - -func TestAdminScanSchedule_NonAdmin_403(t *testing.T) { - if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { - t.Skip("MINSTREL_TEST_DATABASE_URL not set") - } - h, pool := testHandlers(t) - s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{}) - h.scheduler = s - user := seedUser(t, pool, "schednonadmin", "pw", false) - - req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil) - req = withUser(req, user) - rec := httptest.NewRecorder() - newAdminScheduleRouter(h).ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Errorf("status = %d, want 403", rec.Code) - } -} diff --git a/internal/api/api.go b/internal/api/api.go index e95a26a1..0a700d64 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -28,7 +28,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, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, 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, 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) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, @@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev coverSettings: coverSettings, scanner: scanner, scanCfg: scanCfg, - scheduler: scheduler, dataDir: dataDir, mailer: sender, eventbus: bus, @@ -167,8 +166,6 @@ 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) @@ -222,7 +219,6 @@ type handlers struct { coverSettings *coverart.SettingsService scanner *library.Scanner scanCfg library.RunScanConfig - scheduler *library.Scheduler dataDir string mailer mailer.Sender eventbus *eventbus.Bus diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 5ac1b450..09d36cf7 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.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, 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) paths := []string{ "/api/artists", diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 1856b21a..3556ce8e 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -439,15 +439,6 @@ type ScanRun struct { ArtistArtEnrich []byte } -type ScanSchedule struct { - ID bool - Mode string - IntervalHours *int32 - TimeOfDay *string - WeeklyDay *int32 - UpdatedAt pgtype.Timestamptz -} - type ScrobbleQueue struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/scan_schedule.sql.go b/internal/db/dbq/scan_schedule.sql.go deleted file mode 100644 index e46f9c3f..00000000 --- a/internal/db/dbq/scan_schedule.sql.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by sqlc. DO NOT EDIT. -// versions: -// sqlc v1.31.1 -// source: scan_schedule.sql - -package dbq - -import ( - "context" - - "github.com/jackc/pgx/v5/pgtype" -) - -const getScanSchedule = `-- name: GetScanSchedule :one -SELECT mode, interval_hours, time_of_day, weekly_day, updated_at - FROM scan_schedule WHERE id = true -` - -type GetScanScheduleRow struct { - Mode string - IntervalHours *int32 - TimeOfDay *string - WeeklyDay *int32 - UpdatedAt pgtype.Timestamptz -} - -// M7 recurring-scans: returns the singleton schedule row. Used by the -// scheduler goroutine on boot and config-refresh, and by the admin GET -// handler. -func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) { - row := q.db.QueryRow(ctx, getScanSchedule) - var i GetScanScheduleRow - err := row.Scan( - &i.Mode, - &i.IntervalHours, - &i.TimeOfDay, - &i.WeeklyDay, - &i.UpdatedAt, - ) - return i, err -} - -const updateScanSchedule = `-- name: UpdateScanSchedule :exec -UPDATE scan_schedule - SET mode = $1, - interval_hours = $2, - time_of_day = $3, - weekly_day = $4, - updated_at = now() - WHERE id = true -` - -type UpdateScanScheduleParams struct { - Mode string - IntervalHours *int32 - TimeOfDay *string - WeeklyDay *int32 -} - -// Admin PATCH replaces the whole row; CHECK constraints enforce validity. -// The application normalizes mode='off' to NULL the per-mode fields -// before calling this. -func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error { - _, err := q.db.Exec(ctx, updateScanSchedule, - arg.Mode, - arg.IntervalHours, - arg.TimeOfDay, - arg.WeeklyDay, - ) - return err -} diff --git a/internal/db/migrations/0033_drop_scan_schedule.down.sql b/internal/db/migrations/0033_drop_scan_schedule.down.sql new file mode 100644 index 00000000..59b72012 --- /dev/null +++ b/internal/db/migrations/0033_drop_scan_schedule.down.sql @@ -0,0 +1,21 @@ +-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is +-- reversible. The application no longer reads or writes this table. +CREATE TABLE scan_schedule ( + id boolean PRIMARY KEY DEFAULT true, + mode text NOT NULL DEFAULT 'off', + interval_hours int, + time_of_day text, + weekly_day int, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT scan_schedule_singleton CHECK (id = true), + CONSTRAINT scan_schedule_mode_check + CHECK (mode IN ('off', 'interval', 'daily', 'weekly')), + CONSTRAINT scan_schedule_interval_check + CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)), + CONSTRAINT scan_schedule_daily_check + CHECK (mode != 'daily' OR time_of_day IS NOT NULL), + CONSTRAINT scan_schedule_weekly_check + CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7)) +); + +INSERT INTO scan_schedule (id) VALUES (true); diff --git a/internal/db/migrations/0033_drop_scan_schedule.up.sql b/internal/db/migrations/0033_drop_scan_schedule.up.sql new file mode 100644 index 00000000..ffaecb1e --- /dev/null +++ b/internal/db/migrations/0033_drop_scan_schedule.up.sql @@ -0,0 +1,4 @@ +-- Retire the configurable scan scheduler. New-file pickup is now handled by +-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel), +-- so the operator-configurable schedule (and its admin UI) is gone. +DROP TABLE IF EXISTS scan_schedule; diff --git a/internal/db/queries/scan_schedule.sql b/internal/db/queries/scan_schedule.sql deleted file mode 100644 index c6521233..00000000 --- a/internal/db/queries/scan_schedule.sql +++ /dev/null @@ -1,18 +0,0 @@ --- name: GetScanSchedule :one --- M7 recurring-scans: returns the singleton schedule row. Used by the --- scheduler goroutine on boot and config-refresh, and by the admin GET --- handler. -SELECT mode, interval_hours, time_of_day, weekly_day, updated_at - FROM scan_schedule WHERE id = true; - --- name: UpdateScanSchedule :exec --- Admin PATCH replaces the whole row; CHECK constraints enforce validity. --- The application normalizes mode='off' to NULL the per-mode fields --- before calling this. -UPDATE scan_schedule - SET mode = $1, - interval_hours = $2, - time_of_day = $3, - weekly_day = $4, - updated_at = now() - WHERE id = true; diff --git a/internal/library/scheduler.go b/internal/library/scheduler.go deleted file mode 100644 index 378543c7..00000000 --- a/internal/library/scheduler.go +++ /dev/null @@ -1,238 +0,0 @@ -package library - -import ( - "context" - "log/slog" - "sync" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "git.fabledsword.com/bvandeusen/minstrel/internal/coverart" - "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" -) - -// ScheduleConfig represents the operator's recurring scan schedule. -// Loaded from the scan_schedule singleton row on boot and on Refresh. -type ScheduleConfig struct { - Mode string // "off" | "interval" | "daily" | "weekly" - IntervalHours int - TimeOfDay string // "HH:MM" (24h) - WeeklyDay int // 1..7 (Mon..Sun, ISO 8601) -} - -// NextFire returns the next time the scan should fire given a reference -// "now". Returns zero time when mode == "off" or config is invalid. -// -// DST semantics: "daily 02:30" on a spring-forward day will fire at the -// computed time using the local zone's rules (Go time.Date silently -// normalizes a non-existent local time). Single-operator scale; documented -// as known and acceptable. -func (c ScheduleConfig) NextFire(now time.Time) time.Time { - switch c.Mode { - case "off": - return time.Time{} - case "interval": - if c.IntervalHours <= 0 { - return time.Time{} - } - return now.Add(time.Duration(c.IntervalHours) * time.Hour) - case "daily": - h, m, ok := parseHHMM(c.TimeOfDay) - if !ok { - return time.Time{} - } - next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location()) - if !next.After(now) { - next = next.AddDate(0, 0, 1) - } - return next - case "weekly": - h, m, ok := parseHHMM(c.TimeOfDay) - if !ok || c.WeeklyDay < 1 || c.WeeklyDay > 7 { - return time.Time{} - } - // Map ISO weekday (1=Mon..7=Sun) to Go's time.Weekday (0=Sun..6=Sat). - targetGoDow := c.WeeklyDay % 7 - nowGoDow := int(now.Weekday()) - daysAhead := (targetGoDow - nowGoDow + 7) % 7 - next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location()) - next = next.AddDate(0, 0, daysAhead) - if !next.After(now) { - next = next.AddDate(0, 0, 7) - } - return next - } - return time.Time{} -} - -func parseHHMM(s string) (h, m int, ok bool) { - if len(s) != 5 || s[2] != ':' { - return 0, 0, false - } - for i, c := range s { - if i == 2 { - continue - } - if c < '0' || c > '9' { - return 0, 0, false - } - } - h = int(s[0]-'0')*10 + int(s[1]-'0') - m = int(s[3]-'0')*10 + int(s[4]-'0') - if h < 0 || h > 23 || m < 0 || m > 59 { - return 0, 0, false - } - return h, m, true -} - -// Scheduler runs in a goroutine and triggers RunScan on the operator's -// configured cadence via library.TryStartScan (which handles in-flight -// conflicts and stale-row reaping). -type Scheduler struct { - pool *pgxpool.Pool - logger *slog.Logger - scanner *Scanner - enricher *coverart.Enricher - scanCfg RunScanConfig - - mu sync.RWMutex - cfg ScheduleConfig - nextFire time.Time - - refresh chan struct{} - stop chan struct{} -} - -func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, - scanner *Scanner, enricher *coverart.Enricher, - scanCfg RunScanConfig, -) *Scheduler { - return &Scheduler{ - pool: pool, logger: logger, - scanner: scanner, enricher: enricher, scanCfg: scanCfg, - refresh: make(chan struct{}, 1), - stop: make(chan struct{}), - } -} - -// Start launches the scheduler's loop goroutine. The loop runs until -// the passed ctx is cancelled or Stop is called. -func (s *Scheduler) Start(ctx context.Context) { - go s.loop(ctx) -} - -// Stop signals the loop to exit. Not idempotent — call only once -// (typically at server shutdown). -func (s *Scheduler) Stop() { close(s.stop) } - -// Refresh signals the loop to reload config and recompute next-fire. -// Non-blocking: additional Refresh calls coalesce on the buffered channel. -func (s *Scheduler) Refresh() { - select { - case s.refresh <- struct{}{}: - default: - } -} - -// NextFire returns the cached next-fire time. Zero when mode=off. -func (s *Scheduler) NextFire() time.Time { - s.mu.RLock() - defer s.mu.RUnlock() - return s.nextFire -} - -func (s *Scheduler) setState(cfg ScheduleConfig, next time.Time) { - s.mu.Lock() - s.cfg = cfg - s.nextFire = next - s.mu.Unlock() -} - -func (s *Scheduler) loadConfig(ctx context.Context) (ScheduleConfig, error) { - q := dbq.New(s.pool) - row, err := q.GetScanSchedule(ctx) - if err != nil { - return ScheduleConfig{}, err - } - cfg := ScheduleConfig{Mode: row.Mode} - if row.IntervalHours != nil { - cfg.IntervalHours = int(*row.IntervalHours) - } - if row.TimeOfDay != nil { - cfg.TimeOfDay = *row.TimeOfDay - } - if row.WeeklyDay != nil { - cfg.WeeklyDay = int(*row.WeeklyDay) - } - return cfg, nil -} - -func (s *Scheduler) loop(ctx context.Context) { - for { - cfg, err := s.loadConfig(ctx) - if err != nil { - s.logger.Error("scheduler: load config failed", "err", err) - retry := time.NewTimer(1 * time.Minute) - select { - case <-retry.C: - case <-s.refresh: - retry.Stop() - case <-s.stop: - retry.Stop() - return - case <-ctx.Done(): - retry.Stop() - return - } - continue - } - - next := cfg.NextFire(time.Now()) - s.setState(cfg, next) - - if next.IsZero() { - s.logger.Info("scheduler: mode=off, waiting for config change") - select { - case <-s.refresh: - continue - case <-s.stop: - return - case <-ctx.Done(): - return - } - } - - wait := time.Until(next) - s.logger.Info("scheduler: next scan scheduled", "at", next, "in", wait) - timer := time.NewTimer(wait) - select { - case <-timer.C: - s.tryFire(ctx) - case <-s.refresh: - timer.Stop() - case <-s.stop: - timer.Stop() - return - case <-ctx.Done(): - timer.Stop() - return - } - } -} - -func (s *Scheduler) tryFire(ctx context.Context) { - started, existing, err := TryStartScan( - ctx, s.pool, s.scanner, s.enricher, - s.logger.With("source", "scheduler"), s.scanCfg, - ) - if err != nil { - s.logger.Error("scheduler: try start failed", "err", err) - return - } - if !started && existing != nil { - s.logger.Info("scheduler: scan skipped — prior still in flight", - "in_flight_id", existing.ID, - "in_flight_started_at", existing.StartedAt.Time) - } -} diff --git a/internal/library/scheduler_test.go b/internal/library/scheduler_test.go deleted file mode 100644 index 0e76d68a..00000000 --- a/internal/library/scheduler_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package library - -import ( - "testing" - "time" -) - -func TestNextFire_Off(t *testing.T) { - c := ScheduleConfig{Mode: "off"} - if got := c.NextFire(time.Now()); !got.IsZero() { - t.Errorf("NextFire = %v, want zero time", got) - } -} - -func TestNextFire_Interval(t *testing.T) { - c := ScheduleConfig{Mode: "interval", IntervalHours: 6} - now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC) - want := time.Date(2026, 5, 6, 20, 0, 0, 0, time.UTC) - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_Daily_Tomorrow(t *testing.T) { - c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"} - now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC) - want := time.Date(2026, 5, 7, 3, 0, 0, 0, time.UTC) - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_Daily_Today(t *testing.T) { - c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"} - now := time.Date(2026, 5, 6, 2, 0, 0, 0, time.UTC) - want := time.Date(2026, 5, 6, 3, 0, 0, 0, time.UTC) - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_Weekly_UpcomingDay(t *testing.T) { - // Tuesday 2pm, schedule = Sunday (ISO 7) 03:00 → upcoming Sunday at 03:00. - c := ScheduleConfig{Mode: "weekly", WeeklyDay: 7, TimeOfDay: "03:00"} - now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) // 2026-05-05 = Tuesday - want := time.Date(2026, 5, 10, 3, 0, 0, 0, time.UTC) // 2026-05-10 = Sunday - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_Weekly_TodayBeforeTime(t *testing.T) { - c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} // ISO 2 = Tuesday - now := time.Date(2026, 5, 5, 2, 0, 0, 0, time.UTC) - want := time.Date(2026, 5, 5, 3, 0, 0, 0, time.UTC) - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_Weekly_TodayAfterTime(t *testing.T) { - c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} - now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) - want := time.Date(2026, 5, 12, 3, 0, 0, 0, time.UTC) - if got := c.NextFire(now); !got.Equal(want) { - t.Errorf("NextFire = %v, want %v", got, want) - } -} - -func TestNextFire_InvalidConfigs(t *testing.T) { - cases := []ScheduleConfig{ - {Mode: "interval", IntervalHours: 0}, - {Mode: "interval", IntervalHours: -1}, - {Mode: "daily", TimeOfDay: "bad"}, - {Mode: "daily", TimeOfDay: "25:00"}, - {Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 0}, - {Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 8}, - {Mode: "unknown"}, - } - now := time.Now() - for _, c := range cases { - if got := c.NextFire(now); !got.IsZero() { - t.Errorf("NextFire(%+v) = %v, want zero", c, got) - } - } -} - -func TestParseHHMM(t *testing.T) { - cases := []struct { - in string - h, m int - wantOK bool - }{ - {"03:00", 3, 0, true}, - {"23:59", 23, 59, true}, - {"00:00", 0, 0, true}, - {"24:00", 0, 0, false}, - {"23:60", 0, 0, false}, - {"3:00", 0, 0, false}, - {"03-00", 0, 0, false}, - {"ab:cd", 0, 0, false}, - } - for _, c := range cases { - h, m, ok := parseHHMM(c.in) - if ok != c.wantOK || (ok && (h != c.h || m != c.m)) { - t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)", - c.in, h, m, ok, c.h, c.m, c.wantOK) - } - } -} - -func TestScheduler_Refresh_NonBlocking(t *testing.T) { - s := &Scheduler{ - refresh: make(chan struct{}, 1), - stop: make(chan struct{}), - } - s.Refresh() - done := make(chan struct{}) - go func() { - s.Refresh() - s.Refresh() - close(done) - }() - select { - case <-done: - case <-time.After(100 * time.Millisecond): - t.Errorf("Refresh blocked when channel was full") - } -} diff --git a/internal/server/server.go b/internal/server/server.go index b402d2af..248340c1 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -78,9 +78,8 @@ type Server struct { CoverSettings *coverart.SettingsService LibraryScanner *library.Scanner ScanCfg library.RunScanConfig - Scheduler *library.Scheduler // Bus is the live-event bus shared with background workers (the - // lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go. + // lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go. // When nil, Router() constructs a local fallback (test contexts). Bus *eventbus.Bus // PlaylistScheduler fires per-user daily system-playlist builds at @@ -97,8 +96,8 @@ type Server struct { StreamSecret []byte } -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, 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, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: 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, 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, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg} } func (s *Server) Router() http.Handler { @@ -144,7 +143,7 @@ func (s *Server) Router() http.Handler { if bus == nil { bus = eventbus.New() } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, 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