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/scanner" ) // 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 // 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) } return &App{ cfg: cfg, db: db, mbClient: mbClient, ndClient: ndClient, }, 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.ndClient != nil { // NavidromeClient holds a stateless subsonic client; nothing to close // beyond releasing idle connections tracked by the MusicBrainz client. } 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, then kick off the periodic loop goroutine. // Business logic added in later tasks (notifier, web server) will be // wired as additional goroutines below. if err := a.doSync(ctx); err != nil { if ctx.Err() != nil { return nil } log.Printf("Initial sync+scan failed: %v", err) } 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 } // startPeriodicSync runs syncAndScan on a ticker at cfg.Sync.Interval. It // blocks until ctx is cancelled, then returns cleanly. Each tick runs in its // own goroutine so a slow sync does not block the ticker; a fresh interval is // still scheduled regardless. func (a *App) startPeriodicSync(ctx context.Context) { ticker := time.NewTicker(a.cfg.Sync.Interval) defer ticker.Stop() for { select { case <-ctx.Done(): log.Println("Periodic sync stopped.") return case <-ticker.C: go func() { if err := a.doSync(ctx); err != nil { if ctx.Err() != nil { return } log.Printf("Periodic sync+scan failed: %v", err) } }() } } }