package main import ( "context" "flag" "fmt" "log" "os" "os/signal" "syscall" "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 } // 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 { // Compute-only scanner hook: scan all monitored artists for missing // releases and log the count. Notifier/Web UI are out of scope for this // plan, so results are only logged. ScanAll is a blocking DB walk over // every monitored artist; it observes ctx cancellation and returns early. missing, err := scanner.ScanAll(ctx, a.db, a.cfg.Scanner.FuzzyThreshold) if err != nil { if ctx.Err() != nil { // Context cancelled (e.g. shutdown) — exit cleanly. return 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) } // Main application loop — blocks until context is cancelled. // Business logic (notifier, web server) will be wired into separate // goroutines here in future tasks. <-ctx.Done() return nil }