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. missing, err := scanner.ScanAll(ctx, db, threshold) if err != nil { return 0, fmt.Errorf("notifier: scan missing releases: %w", err) } missingByRGID := make(map[string]scanner.MissingRelease, len(missing)) for _, m := range missing { missingByRGID[m.RGID] = 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 { if m, ok := missingByRGID[r.RGID]; 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) } for _, m := range toNotify { if err := database.MarkNotificationSent(db, m.RGID); err != nil { return 0, fmt.Errorf("notifier: mark sent for %s: %w", 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 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) } }() } } }() }