- notifier: show artist display names (not internal IDs) in digest; resolve names from artist_settings and fall back to ID when unavailable - notifier: skip sending an empty digest to avoid daily spam - config: require telegram token/chat_id when enabled - web: warn loudly when auth is disabled on a non-loopback bind; add HTTP server timeouts - web: treat SetReleaseIgnored "release not found" as benign redirect (0 rows) - musicbrainz: reject low-score/name-mismatched MBID resolutions instead of silently caching the wrong artist - database: remove dead duplicate err check; harden DSN param appending - musicbrainz: check rows.Err() after iterating existing releases
149 lines
4.6 KiB
Go
149 lines
4.6 KiB
Go
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 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) (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,
|
|
})
|
|
}
|
|
|
|
// 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(missing))
|
|
for _, r := range unnotified {
|
|
if _, ok := names[r.ArtistID]; ok {
|
|
continue
|
|
}
|
|
settings, err := database.GetArtistSettings(db, r.ArtistID)
|
|
if err == nil && settings.Name != "" {
|
|
names[r.ArtistID] = settings.Name
|
|
}
|
|
}
|
|
|
|
message := FormatDigest(missing, uiBaseURL, names)
|
|
// 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(unnotified) == 0 {
|
|
return 0, nil
|
|
}
|
|
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)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}()
|
|
}
|