feat(server): retire scan scheduler for watcher + safety-net walk

Wire the fsnotify watcher and a fixed 12h safety-net delta walk in main;
remove the configurable scan scheduler (scheduler.go, scan_schedule table via
migration 0033, GET/PATCH /api/admin/scan/schedule, and the server/api
plumbing). Manual scan + scan status are unchanged.
This commit is contained in:
2026-06-06 21:50:08 -04:00
parent c39a9ca18f
commit e994aae613
13 changed files with 79 additions and 816 deletions
+48 -4
View File
@@ -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
-155
View File
@@ -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
}
-181
View File
@@ -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)
}
}
+1 -5
View File
@@ -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
+1 -1
View File
@@ -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",
-9
View File
@@ -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
-71
View File
@@ -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
}
@@ -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);
@@ -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;
-18
View File
@@ -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;
-238
View File
@@ -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)
}
}
-129
View File
@@ -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")
}
}
+4 -5
View File
@@ -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