180 lines
6.5 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|