Files
NaviWatcher/cmd/naviwatcher/main.go
Vladimir Zagainov aee0241bb7 fix: address code review findings
- Start Web UI before the blocking initial sync so the dashboard is
  reachable during the (rate-limited, potentially multi-minute) first
  sync; fold the immediate sync into startPeriodicSync's overlap guard
  so it can never race a concurrent tick over the shared DB / MB client.
- Make MarkNotificationSent idempotent: INSERT OR IGNORE for same-second
  PK collisions, and explicitly swallow FK violations when a release was
  pruned by a concurrent re-sync. Prevents a single vanished/duplicate
  release from aborting the digest mark-sent loop and re-sending.
- Do not abort NotifyOnce's mark-sent loop on a single failure; log and
  continue so every release in the batch is marked.
- NULL-safe reads: COALESCE(type,''), COALESCE(release_date,'') in the
  external_releases and unnotified readers to match the cache reader.
- Update/extend tests for the new idempotency and startup contracts.
2026-07-20 06:27:42 +03:00

278 lines
8.7 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
"naviwatcher/internal/musicbrainz"
"naviwatcher/internal/navidrome"
"naviwatcher/internal/notifier"
"naviwatcher/internal/scanner"
"naviwatcher/internal/web"
)
// App holds all application dependencies for clean shutdown and testability.
type App struct {
cfg *config.Config
db *database.DB
mbClient *musicbrainz.MusicBrainzClient
ndClient *navidrome.NavidromeClient
web *web.Server
sender notifier.Sender
// syncFn, when non-nil, replaces the real syncAndScan call in
// startPeriodicSync so tests can observe the loop without live clients.
syncFn func(ctx context.Context) error
}
// navidromeClientFactory constructs the Navidrome client. It is a package-level
// variable (not a direct call to navidrome.NewClient) so tests can inject a stub
// without requiring a live Navidrome server for authentication.
var navidromeClientFactory = navidrome.NewClient
const defaultConfigPath = "config.yaml"
func main() {
configPath := flag.String("config", defaultConfigPath, "Path to config file")
flag.Parse()
log.Println("NaviWatcher starting...")
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
log.Printf("Config loaded from %s (server: %s:%d)", *configPath, cfg.Server.Host, cfg.Server.Port)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
log.Printf("Received signal %v, shutting down...", sig)
cancel()
}()
app, err := NewApp(ctx, cfg, "naviwatcher.db")
if err != nil {
log.Fatalf("Failed to initialize application: %v", err)
}
defer app.Close()
if err := app.run(ctx); err != nil {
log.Fatalf("Application error: %v", err)
}
log.Println("NaviWatcher stopped.")
}
// NewApp initializes all application components: config, database, and MusicBrainz client.
// dbPath is the SQLite database path (use ":memory:" for tests).
func NewApp(ctx context.Context, cfg *config.Config, dbPath string) (*App, error) {
// Initialize database.
db, err := database.New(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize MusicBrainz client with rate limiting.
mbClient := musicbrainz.NewClient(cfg.MusicBrainz)
log.Printf("MusicBrainz client initialized (user-agent: %s)", cfg.MusicBrainz.UserAgent)
// Initialize Navidrome client (authenticates immediately; error if auth fails).
ndClient, err := navidromeClientFactory(cfg.Navidrome)
if err != nil {
return nil, fmt.Errorf("failed to initialize navidrome client: %w", err)
}
// Build the Web UI dashboard server (not started until run).
webServer := web.NewServerWithConfig(cfg, db)
// Build the notifier sender. A nil sender is fine when Telegram is disabled;
// the scheduler is no-op-safe and the web UI needs no sender.
var sender notifier.Sender
if cfg.Telegram.Enabled {
sender = notifier.NewTelegramSender(cfg.Telegram)
}
return &App{
cfg: cfg,
db: db,
mbClient: mbClient,
ndClient: ndClient,
web: webServer,
sender: sender,
}, nil
}
// Close cleans up all application resources in reverse order of initialization.
func (a *App) Close() {
if a.mbClient != nil {
a.mbClient.Close()
}
if a.db != nil {
if err := a.db.Close(); err != nil {
log.Printf("Error closing database: %v", err)
}
}
}
func (a *App) run(ctx context.Context) error {
// Start the Web UI dashboard FIRST, in its own goroutine, so the dashboard
// accepts connections immediately. The initial sync below is throttled by
// the MusicBrainz 1 req/s limit and can take many minutes on a large
// library (worst case: a fresh DB where every artist needs MBID
// resolution) — exactly when an operator is most likely watching. Starting
// the server first means the dashboard is reachable (serving cached data)
// during that window instead of refusing connections. It serves until ctx
// is cancelled, then shuts down gracefully.
if a.web != nil {
go func() {
if err := a.web.Start(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("Web UI server stopped with error: %v", err)
}
}()
}
// Start the Telegram notifier scheduler. It is no-op-safe when Telegram is
// disabled (sender nil / enabled false), so always calling it is safe.
a.startNotifier(ctx)
// Kick off the periodic sync+scan loop. It runs an immediate first sync
// (governed by the same overlap guard as periodic ticks) so the service
// produces results without waiting a full interval, without racing a
// concurrent tick over the shared DB and rate-limited MusicBrainz client.
a.startPeriodicSync(ctx)
<-ctx.Done()
return nil
}
// doSync runs the sync pipeline, using the injected syncFn when present (tests)
// or the real syncAndScan otherwise.
func (a *App) doSync(ctx context.Context) error {
if a.syncFn != nil {
return a.syncFn(ctx)
}
return a.syncAndScan(ctx)
}
// syncAndScan runs the full data pipeline once: Navidrome artist sync, the
// MusicBrainz discography pipeline (SyncAll), then the scanner over the
// now-populated DB. It logs results and observes ctx cancellation.
func (a *App) syncAndScan(ctx context.Context) error {
if err := navidrome.SyncArtists(ctx, a.ndClient, a.db); err != nil {
return fmt.Errorf("sync artists: %w", err)
}
discography := musicbrainz.NewDiscographySyncer(a.mbClient)
albums := musicbrainz.NewAlbumSyncer(func(ctx context.Context, db *database.DB) error {
return navidrome.SyncAlbums(ctx, a.ndClient, db)
})
if err := musicbrainz.SyncAll(ctx, a.db, a.mbClient, discography, albums, a.cfg.MusicBrainz.CacheTTL); err != nil {
return fmt.Errorf("sync all: %w", err)
}
missing, err := scanner.ScanAll(ctx, a.db, a.cfg.Scanner.FuzzyThreshold)
if err != nil {
return fmt.Errorf("scan all: %w", err)
}
log.Printf("Scan complete: %d missing release(s) across monitored artists", len(missing))
for _, m := range missing {
log.Printf(" missing: artist=%s rgid=%s title=%q", m.ArtistID, m.RGID, m.Title)
}
return nil
}
// startNotifier wires the Telegram digest scheduler. The scheduler is
// no-op-safe (returns without starting when disabled or sender is nil), so it
// is always safe to call. The base URL for dashboard links comes from the
// server's configured public_url.
func (a *App) startNotifier(ctx context.Context) {
if !a.cfg.Telegram.Enabled {
return
}
schedule, err := notifier.NewCronSchedule(a.cfg.Telegram.CronSchedule)
if err != nil {
log.Printf("Notifier schedule invalid (%q): %v; notifier disabled", a.cfg.Telegram.CronSchedule, err)
return
}
uiBaseURL := web.ResolveUIBaseURL(&a.cfg.Server)
notifier.StartScheduler(ctx, true, schedule, func(ctx context.Context) error {
_, err := notifier.NotifyOnce(ctx, a.db, a.sender, a.cfg.Telegram, uiBaseURL, a.cfg.Scanner.FuzzyThreshold)
return err
}, nil)
}
// startPeriodicSync runs syncAndScan on a ticker at cfg.Sync.Interval. It
// blocks until ctx is cancelled, then returns cleanly. Each tick spawns a
// goroutine so a slow sync does not block the ticker, but a new sync is
// skipped while the previous one is still running (guarded by a done channel)
// so syncs never overlap and contend for the shared DB and rate-limited
// MusicBrainz client.
func (a *App) startPeriodicSync(ctx context.Context) {
ticker := time.NewTicker(a.cfg.Sync.Interval)
defer ticker.Stop()
// free is a buffered token (capacity 1). A sync is in flight while the token
// is drained; the in-flight goroutine returns it when done so the next tick
// can start a new sync. While the token is held, ticks are skipped.
var free = make(chan struct{}, 1)
free <- struct{}{}
// launch starts a guarded sync if the slot is free, returning true when a
// sync was started and false when one is already in progress. The in-flight
// goroutine returns the token when done.
launch := func(label string) bool {
select {
case <-free:
go func() {
defer func() { free <- struct{}{} }()
if err := a.doSync(ctx); err != nil {
if ctx.Err() != nil {
return
}
log.Printf("%s sync+scan failed: %v", label, err)
}
}()
return true
default:
return false
}
}
// Immediate first sync (guarded), so the service produces results without
// waiting a full interval and without racing the first ticker fire.
launch("Initial")
for {
select {
case <-ctx.Done():
log.Println("Periodic sync stopped.")
return
case <-ticker.C:
if !launch("Periodic") {
// Previous sync still running; skip this tick.
log.Println("Skipping periodic sync: previous sync still in progress.")
}
}
}
}