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 { // Run an immediate sync+scan so the service produces results without // waiting a full interval. if err := a.doSync(ctx); err != nil { if ctx.Err() != nil { return nil } log.Printf("Initial sync+scan failed: %v", err) } // Start the Web UI dashboard in its own goroutine; 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 goroutine. 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 := a.cfg.Server.PublicURL notifier.StartScheduler(ctx, true, schedule, func(ctx context.Context) error { _, err := notifier.NotifyOnce(ctx, a.db, a.sender, a.cfg.Telegram, uiBaseURL) 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 sentinel channel: nil means a sync is currently in flight. var free = make(chan struct{}, 1) free <- struct{}{} for { select { case <-ctx.Done(): log.Println("Periodic sync stopped.") return case <-ticker.C: select { case <-free: // Slot was free; start a sync and release the slot when done. go func() { defer func() { free <- struct{}{} }() if err := a.doSync(ctx); err != nil { if ctx.Err() != nil { return } log.Printf("Periodic sync+scan failed: %v", err) } }() default: // Previous sync still running; skip this tick. log.Println("Skipping periodic sync: previous sync still in progress.") } } } }