Files
NaviWatcher/internal/notifier/scheduler.go

180 lines
6.5 KiB
Go

package notifier
import (
"context"
"fmt"
"log"
"time"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
"naviwatcher/internal/scanner"
)
// NotifyOnce computes the releases that are genuinely missing for the user,
// intersects that set with the releases not yet notified, builds a digest of
// the result, sends it through the given Sender, and marks each release as
// sent.
//
// "Missing" is the authoritative definition produced by the scanner: an
// external release with no sufficiently similar local album (see
// scanner.ScanAll). This intersection is what keeps the digest honest: a
// release the user already owns in Navidrome must never be reported as a new
// missing release, even though it still counts as "unnotified" on a fresh
// database. Releases already in notifications_sent are excluded by
// GetUnnotifiedReleases, so the call is idempotent across runs.
//
// threshold is the fuzzy-similarity cutoff passed through to the scanner; 0
// selects the scanner's default. It must match config.Scanner.FuzzyThreshold
// so the digest honors the operator's configured tolerance.
//
// If there are no newly-missing releases nothing is sent (the caller's
// scheduler is responsible for not spamming the operator with an empty
// digest). When releases are present, each is marked sent so a subsequent run
// will not re-notify it.
//
// 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, threshold float64) (int, error) {
if sender == nil {
return 0, fmt.Errorf("notifier: sender must not be nil")
}
// Authoritative missing set: external releases with no matching local album.
// This is what the Web UI dashboard also shows, so the digest stays
// consistent with what the operator sees in the UI.
// Using composite key ArtistID|RGID to be defensive - while MusicBrainz RGIDs are
// globally unique (UUIDs), this protects against potential data inconsistencies.
missing, err := scanner.ScanAll(ctx, db, threshold)
if err != nil {
return 0, fmt.Errorf("notifier: scan missing releases: %w", err)
}
missingByArtistRGID := make(map[string]scanner.MissingRelease, len(missing))
for _, m := range missing {
key := m.ArtistID + "|" + m.RGID
missingByArtistRGID[key] = m
}
// Restrict to releases not yet notified. A release that is genuinely missing
// but was already announced is dropped here so it is never re-sent.
unnotified, err := database.GetUnnotifiedReleases(db)
if err != nil {
return 0, fmt.Errorf("notifier: query unnotified releases: %w", err)
}
toNotify := make([]scanner.MissingRelease, 0, len(unnotified))
for _, r := range unnotified {
key := r.ArtistID + "|" + r.RGID
if m, ok := missingByArtistRGID[key]; ok {
toNotify = append(toNotify, m)
}
}
// Resolve human-readable artist names so the digest shows recognizable
// labels instead of opaque internal artist IDs. A lookup failure for a
// single artist must not abort the whole digest, so errors are ignored and
// that artist falls back to its ID via artistLabel.
names := make(map[string]string, len(toNotify))
for _, m := range toNotify {
if _, ok := names[m.ArtistID]; ok {
continue
}
settings, err := database.GetArtistSettings(db, m.ArtistID)
if err == nil && settings.Name != "" {
names[m.ArtistID] = settings.Name
}
}
// Nothing to report: skip sending so the operator is not spammed with an
// empty digest on every cron fire. The startup fire likewise stays quiet
// until the first genuinely missing release appears.
if len(toNotify) == 0 {
return 0, nil
}
message := FormatDigest(toNotify, uiBaseURL, names)
if err := sender.Send(ctx, message); err != nil {
return 0, fmt.Errorf("notifier: send digest: %w", err)
}
// The digest has already been delivered at this point. A failure to mark a
// single release must NOT abort the loop: doing so would leave later
// releases unmarked and cause them to be re-notified (duplicate digest) on
// the next run. Log and continue so every release in this batch is marked.
for _, m := range toNotify {
if err := database.MarkNotificationSent(db, m.RGID); err != nil {
log.Printf("notifier: mark sent for %s failed: %v", m.RGID, err)
}
}
return len(toNotify), 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 synchronously (in the scheduler's own
// goroutine): NotifyOnce reads the unnotified set and marks releases sent
// non-atomically, so overlapping runs would double-send the digest. Running one
// fire at a time keeps the read-send-mark sequence safe; the next tick is still
// computed 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:
if err := notify(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Notifier run failed: %v", err)
}
}
}
}()
}