Files
NaviWatcher/cmd/naviwatcher/main.go
Vladimir Zagainov 389d177d85 fix: address code review findings
- Fix duplicate Telegram notifications: SyncArtistDiscography no longer wipes
  notifications_sent for the whole artist on every cache-miss re-sync; only
  markers for releases that disappear are pruned (FK-safe via INSERT OR REPLACE
  + rgid NOT IN (...)).
- Cache empty MusicBrainz discographies via a new artist_settings.last_synced
  column (migration 009) so zero-release artists honor the TTL instead of being
  re-fetched every cycle.
- Wire the Web UI server and Telegram notifier scheduler into main.run/NewApp.
- Guard startPeriodicSync against overlapping syncs with a done-channel slot.
- Add server.public_url config; NewServerWithConfig derives reachable links
  and no longer advertises the 0.0.0.0 bind address.
- Web handlers: use scanner.ScanArtist per artist, drop always-false
  releaseIgnored lookup and dead endsWith, thread configured threshold.
- Limit :memory: DB pool to one connection so migrations and queries share the
  same in-memory store.
2026-07-19 23:38:33 +03:00

263 lines
7.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 {
// 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.")
}
}
}
}