diff --git a/docs/plans/2026-07-19-notifier-webui-sync.md b/docs/plans/2026-07-19-notifier-webui-sync.md index 22663ba..b49d853 100644 --- a/docs/plans/2026-07-19-notifier-webui-sync.md +++ b/docs/plans/2026-07-19-notifier-webui-sync.md @@ -113,10 +113,10 @@ name collisions.) - [x] run tests - must pass before task 6 ### Task 6: Notifier — scheduler + sent-tracking -- [ ] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid -- [ ] add cron-based scheduler goroutine honoring `TelegramConfig.CronSchedule` (use a lightweight cron lib or robfig/cron); no-op if `Enabled=false` -- [ ] write tests: `NotifyOnce` marks sent and skips already-sent; scheduler parses cron and fires (inject fixed time / use every-minute for test) -- [ ] run tests - must pass before task 7 +- [x] add `NotifyOnce(ctx, db, sender, cfg)` : query `GetUnnotifiedReleases`, build digest, send, `MarkNotificationSent` per rgid +- [x] add cron-based scheduler goroutine honoring `TelegramConfig.CronSchedule` (use a lightweight cron lib or robfig/cron); no-op if `Enabled=false` +- [x] write tests: `NotifyOnce` marks sent and skips already-sent; scheduler parses cron and fires (inject fixed time / use every-minute for test) +- [x] run tests - must pass before task 7 ### Task 7: Web UI — server + auth + dashboard - [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password` diff --git a/go.mod b/go.mod index f6cb38b..0d65fa5 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( require ( github.com/lithammer/fuzzysearch v1.1.8 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/time v0.15.0 ) diff --git a/go.sum b/go.sum index b7a8762..15caacb 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/internal/notifier/cron_schedule.go b/internal/notifier/cron_schedule.go new file mode 100644 index 0000000..ba8eee8 --- /dev/null +++ b/internal/notifier/cron_schedule.go @@ -0,0 +1,31 @@ +package notifier + +import ( + "fmt" + "time" + + "github.com/robfig/cron/v3" +) + +// CronSchedule wraps a robfig/cron schedule to satisfy the notifier.Schedule +// interface used by StartScheduler. The spec follows the standard 5-field cron +// syntax (e.g. "0 9 * * *" for daily at 09:00 in the process local time). +type CronSchedule struct { + spec string + c cron.Schedule +} + +// NewCronSchedule parses a cron spec and returns a Schedule. An error is +// returned if the spec is not a valid cron expression. +func NewCronSchedule(spec string) (*CronSchedule, error) { + c, err := cron.ParseStandard(spec) + if err != nil { + return nil, fmt.Errorf("parse cron schedule %q: %w", spec, err) + } + return &CronSchedule{spec: spec, c: c}, nil +} + +// Next returns the next time the schedule fires after t. +func (s *CronSchedule) Next(t time.Time) time.Time { + return s.c.Next(t) +} diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go new file mode 100644 index 0000000..c7dccc6 --- /dev/null +++ b/internal/notifier/scheduler.go @@ -0,0 +1,125 @@ +package notifier + +import ( + "context" + "fmt" + "log" + "time" + + "naviwatcher/internal/config" + "naviwatcher/internal/database" + "naviwatcher/internal/scanner" +) + +// NotifyOnce queries for releases that have not yet been notified, builds a +// digest, sends it through the given Sender, and marks each release as sent. +// Releases already present in notifications_sent are excluded upstream by +// GetUnnotifiedReleases, so this is idempotent across runs. +// +// If there are no unnotified releases the digest reports "no new missing +// releases" and nothing is marked sent (there is nothing to mark). +// +// uiBaseURL is the externally-reachable base URL of the Web UI, appended to the +// digest so operators can jump to the dashboard. +func NotifyOnce(ctx context.Context, db *database.DB, sender Sender, cfg config.TelegramConfig, uiBaseURL string) (int, error) { + if sender == nil { + return 0, fmt.Errorf("notifier: sender must not be nil") + } + + unnotified, err := database.GetUnnotifiedReleases(db) + if err != nil { + return 0, fmt.Errorf("notifier: query unnotified releases: %w", err) + } + + missing := make([]scanner.MissingRelease, 0, len(unnotified)) + for _, r := range unnotified { + missing = append(missing, scanner.MissingRelease{ + RGID: r.RGID, + ArtistID: r.ArtistID, + Title: r.Title, + Type: r.Type, + ReleaseDate: r.ReleaseDate, + }) + } + + message := FormatDigest(missing, uiBaseURL) + if err := sender.Send(ctx, message); err != nil { + return 0, fmt.Errorf("notifier: send digest: %w", err) + } + + for _, r := range unnotified { + if err := database.MarkNotificationSent(db, r.RGID); err != nil { + return 0, fmt.Errorf("notifier: mark sent for %s: %w", r.RGID, err) + } + } + return len(unnotified), nil +} + +// Schedule produces the next firing time strictly after the given time. It +// mirrors the robfig/cron Schedule interface so cron specs and simple +// interval-based schedules are interchangeable and testable. +type Schedule interface { + Next(time.Time) time.Time +} + +// notifyFunc is the unit of work the scheduler runs on each firing. It mirrors +// the signature of NotifyOnce so the scheduler can be tested with a stub. +type notifyFunc func(ctx context.Context) error + +// StartScheduler runs the notify function on a schedule until ctx is cancelled. +// It is no-op-safe: if enabled is false it returns immediately without starting +// a goroutine. Each firing runs in its own goroutine so a slow send does not +// delay the next scheduled tick; the scheduler still computes the next tick from +// the wall clock and does not drift. +// +// The schedule and notify function are injectable so tests can drive a fixed or +// frequent schedule without a real cron spec or Telegram server. +func StartScheduler(ctx context.Context, enabled bool, schedule Schedule, notify notifyFunc, now func() time.Time) { + if !enabled || schedule == nil || notify == nil { + log.Println("Notifier scheduler disabled or misconfigured; not starting.") + return + } + if now == nil { + now = time.Now + } + + go func() { + timer := time.NewTimer(0) + defer timer.Stop() + // Fire immediately on start (startup digest), then schedule subsequent runs. + first := true + for { + var wait time.Duration + if first { + first = false + wait = 0 + } else { + next := schedule.Next(now()) + if next.IsZero() { + log.Println("Notifier schedule has no next fire; stopping scheduler.") + return + } + wait = time.Until(next) + if wait < 0 { + wait = 0 + } + } + + timer.Reset(wait) + select { + case <-ctx.Done(): + log.Println("Notifier scheduler stopped.") + return + case <-timer.C: + go func() { + if err := notify(ctx); err != nil { + if ctx.Err() != nil { + return + } + log.Printf("Notifier run failed: %v", err) + } + }() + } + } + }() +} diff --git a/internal/notifier/scheduler_test.go b/internal/notifier/scheduler_test.go new file mode 100644 index 0000000..0764b4c --- /dev/null +++ b/internal/notifier/scheduler_test.go @@ -0,0 +1,238 @@ +package notifier + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "naviwatcher/internal/config" + "naviwatcher/internal/database" +) + +// fixedSchedule is a test Schedule that fires a fixed duration after every call +// to Next, so a scheduler test can run deterministically without a real cron. +type fixedSchedule struct { + interval time.Duration +} + +func (f fixedSchedule) Next(t time.Time) time.Time { + return t.Add(f.interval) +} + +// collectSender records messages and can be told to fail. +type collectSender struct { + mu sync.Mutex + messages []string + failErr error +} + +func (s *collectSender) Send(ctx context.Context, message string) error { + if s.failErr != nil { + return s.failErr + } + s.mu.Lock() + s.messages = append(s.messages, message) + s.mu.Unlock() + return nil +} + +func (s *collectSender) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.messages) +} + +// seedRelease inserts an external_release row (with an artist) and optionally +// marks it as already notified. Returns the rgid. +func seedRelease(t *testing.T, db *database.DB, rgid, artistID string, notified bool) { + t.Helper() + if _, err := db.Conn().Exec( + "INSERT OR IGNORE INTO artist_settings (id, name) VALUES (?, ?)", + artistID, "Test Artist "+artistID, + ); err != nil { + t.Fatalf("seed artist: %v", err) + } + if _, err := db.Conn().Exec( + "INSERT INTO external_releases (rgid, artist_id, title, type, release_date) VALUES (?, ?, ?, ?, ?)", + rgid, artistID, "Release "+rgid, "album", "", + ); err != nil { + t.Fatalf("seed release: %v", err) + } + if notified { + if err := database.MarkNotificationSent(db, rgid); err != nil { + t.Fatalf("mark sent: %v", err) + } + } +} + +func TestNotifyOnce_SendsAndMarksSent(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("New(): %v", err) + } + defer db.Close() + + seedRelease(t, db, "rgid-1", "artist-1", false) + seedRelease(t, db, "rgid-2", "artist-1", false) + + sender := &collectSender{} + cfg := config.TelegramConfig{Enabled: true} + n, err := NotifyOnce(context.Background(), db, sender, cfg, "http://ui:8080") + if err != nil { + t.Fatalf("NotifyOnce: %v", err) + } + if n != 2 { + t.Fatalf("expected 2 releases notified, got %d", n) + } + if sender.count() != 1 { + t.Fatalf("expected a single digest message, got %d", sender.count()) + } + + // After notifying, both should now be considered sent. + remaining, err := database.GetUnnotifiedReleases(db) + if err != nil { + t.Fatalf("GetUnnotifiedReleases: %v", err) + } + if len(remaining) != 0 { + t.Fatalf("expected 0 unnotified after NotifyOnce, got %d", len(remaining)) + } +} + +func TestNotifyOnce_SkipsAlreadySent(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("New(): %v", err) + } + defer db.Close() + + // One already-notified, one new. + seedRelease(t, db, "rgid-done", "artist-1", true) + seedRelease(t, db, "rgid-new", "artist-1", false) + + sender := &collectSender{} + n, err := NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui") + if err != nil { + t.Fatalf("NotifyOnce: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 newly notified release, got %d", n) + } + + // The already-sent one stays marked sent; the new one is now marked. + done, err := database.IsNotificationSent(db, "rgid-done") + if err != nil { + t.Fatalf("IsNotificationSent done: %v", err) + } + if !done { + t.Error("expected rgid-done to remain sent") + } + newsent, err := database.IsNotificationSent(db, "rgid-new") + if err != nil { + t.Fatalf("IsNotificationSent new: %v", err) + } + if !newsent { + t.Error("expected rgid-new to be marked sent") + } +} + +func TestNotifyOnce_SendErrorNotMarked(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("New(): %v", err) + } + defer db.Close() + + seedRelease(t, db, "rgid-1", "artist-1", false) + + want := errors.New("send boom") + sender := &collectSender{failErr: want} + _, err = NotifyOnce(context.Background(), db, sender, config.TelegramConfig{}, "http://ui") + if err == nil || !errors.Is(err, want) { + t.Fatalf("expected error %v, got %v", want, err) + } + // On send failure nothing should be marked sent. + sent, err := database.IsNotificationSent(db, "rgid-1") + if err != nil { + t.Fatalf("IsNotificationSent: %v", err) + } + if sent { + t.Error("release should NOT be marked sent when send fails") + } +} + +func TestNotifyOnce_NilSender(t *testing.T) { + db, err := database.New(":memory:") + if err != nil { + t.Fatalf("New(): %v", err) + } + defer db.Close() + + if _, err := NotifyOnce(context.Background(), db, nil, config.TelegramConfig{}, "http://ui"); err == nil { + t.Fatal("expected error for nil sender") + } +} + +func TestStartScheduler_FiresOnSchedule(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var mu sync.Mutex + var calls int + notify := func(ctx context.Context) error { + mu.Lock() + calls++ + mu.Unlock() + return nil + } + + // Fixed 10ms interval schedule; injected now func is unused by fixedSchedule. + StartScheduler(ctx, true, fixedSchedule{interval: 10 * time.Millisecond}, notify, time.Now) + + // Allow a few ticks (immediate fire + scheduled ones). + time.Sleep(60 * time.Millisecond) + cancel() + + mu.Lock() + got := calls + mu.Unlock() + if got < 2 { + t.Fatalf("expected scheduler to fire at least twice, got %d", got) + } +} + +func TestStartScheduler_DisabledNoOp(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + fired := false + notify := func(ctx context.Context) error { + fired = true + return nil + } + StartScheduler(ctx, false, fixedSchedule{interval: time.Millisecond}, notify, time.Now) + time.Sleep(20 * time.Millisecond) + if fired { + t.Fatal("scheduler should not fire when disabled") + } +} + +func TestCronSchedule_ParsesAndNext(t *testing.T) { + s, err := NewCronSchedule("0 9 * * *") + if err != nil { + t.Fatalf("NewCronSchedule: %v", err) + } + base := time.Date(2026, 7, 19, 10, 0, 0, 0, time.Local) + next := s.Next(base) + // After 10:00, the next 09:00 daily fire is the next day. + if next.Day() != 20 || next.Hour() != 9 { + t.Fatalf("expected next fire at 09:00 next day, got %v", next) + } +} + +func TestCronSchedule_InvalidSpec(t *testing.T) { + if _, err := NewCronSchedule("not a cron"); err == nil { + t.Fatal("expected error for invalid cron spec") + } +}