package server import ( "context" "errors" "fmt" "net/http" "time" "music-server/internal/backend" "music-server/internal/config" "music-server/internal/db" "music-server/internal/db/repository" "music-server/internal/logging" "music-server/internal/metrics" "github.com/prometheus/client_golang/prometheus" "go.uber.org/zap" ) // Database is the database connection used by the server. type Database interface { Health(ctx context.Context) map[string]string Close() } type Server struct { cfg config.Config db Database q repository.Querier music *backend.MusicService library *backend.LibraryService sync *backend.SyncService characters *backend.CharacterService releases *backend.ReleaseService stats *backend.StatisticsService search *backend.SearchCache // covers is nil when COVERS_PATH is not set. covers *backend.CoverService // stopCovers ends the background cover search; coversDone is closed when it has ended. stopCovers context.CancelFunc coversDone chan struct{} httpServer *http.Server // metrics serves /metrics on its own port; nil when metrics are off. metrics *metrics.Server } // New creates a Server with all services wired up. func New(cfg config.Config, q repository.Querier, database Database) (*Server, error) { characters, err := backend.NewCharacterService(cfg.CharactersPath) if err != nil { return nil, fmt.Errorf("characters path: %w", err) } s := &Server{ cfg: cfg, db: database, q: q, music: backend.NewMusicService(q, cfg.SoundCheckPath), library: backend.NewLibraryService(q), characters: characters, releases: backend.NewReleaseService(cfg.ReleasesURL), stats: backend.NewStatisticsService(q), } s.search = backend.NewSearchCache(s.music.SearchSoundtracks) s.sync = backend.NewSyncService(q, cfg.MusicPath, s.afterSync) if cfg.CoversPath != "" { s.covers, err = backend.NewCoverService(q, cfg.CoversPath, backend.CoverOptions{ SteamGridDBKey: cfg.SteamGridDBAPIKey, SteamGridDBURL: cfg.SteamGridDBURL, AniListURL: cfg.AniListURL, }) if err != nil { return nil, fmt.Errorf("covers path: %w", err) } } if cfg.MetricsPort > 0 { s.metrics = metrics.NewServer(cfg.MetricsPort) } s.httpServer = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.Port), Handler: s.RegisterRoutes(), IdleTimeout: time.Minute, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, } return s, nil } // startCoverSearch runs the background cover search until Shutdown. It does nothing when // covers are off or there is no SteamGridDB API key. func (s *Server) startCoverSearch() { if s.covers == nil || !s.covers.SearchesAutomatically() { logging.GetLogger().Info("Automatic cover search is off", zap.Bool("covers_path_set", s.covers != nil)) return } ctx, cancel := context.WithCancel(context.Background()) s.stopCovers, s.coversDone = cancel, make(chan struct{}) go func() { defer close(s.coversDone) s.covers.Run(ctx) }() } // soundtracksChanged reloads the cached soundtrack data after soundtracks were edited, and // lets the cover search try the new names. func (s *Server) soundtracksChanged(ctx context.Context) { if err := s.music.ReloadSoundtracks(ctx); err != nil { logging.GetLogger().Error("Failed to reload soundtracks", zap.Error(err)) } if err := s.search.Reload(ctx); err != nil { logging.GetLogger().Error("Failed to reload search cache", zap.Error(err)) } if s.covers != nil { s.covers.Trigger() } } // afterSync refreshes cached data once a sync has finished. func (s *Server) afterSync(ctx context.Context) { if err := s.music.EndAllQueues(ctx); err != nil { logging.GetLogger().Error("Failed to end the queues after sync", zap.Error(err)) } if err := s.music.ReloadSoundtracks(ctx); err != nil { logging.GetLogger().Error("Failed to reload soundtracks after sync", zap.Error(err)) } if err := s.search.Reload(ctx); err != nil { logging.GetLogger().Error("Failed to reload search cache after sync", zap.Error(err)) } if s.covers != nil { s.covers.Trigger() } } // NewServerInstance loads the configuration, connects to and migrates the database and creates the Server. // It exits the process if any of these steps fail. func NewServerInstance() *Server { cfg, err := config.Load() logging.Init(cfg.LogLevel, cfg.LogJSON) logger := logging.GetLogger() if err != nil { logger.Fatal("Invalid settings", zap.Error(err)) } logger.Info("Starting server", zap.String("host", cfg.DBHost), zap.String("dbPort", cfg.DBPort), zap.String("username", cfg.DBUser), zap.String("dbName", cfg.DBName), zap.String("musicPath", cfg.MusicPath), zap.String("charactersPath", cfg.CharactersPath), ) ctx := context.Background() database, err := db.NewDatabase(ctx, db.ConnString(cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName)) if err != nil { logger.Fatal("Failed to initialize database", zap.Error(err)) } if err := database.RunMigrations(); err != nil { logger.Fatal("Migration failed", zap.Error(err)) } s, err := New(cfg, repository.New(database.Pool), database) if err != nil { logger.Fatal("Failed to create server", zap.Error(err)) } prometheus.MustRegister(metrics.NewPoolCollector(database.Pool)) if err := s.sync.UpdateLibraryMetrics(ctx); err != nil { logger.Error("Failed to count the library", zap.Error(err)) } return s } // ListenAndServe starts the HTTP server and the metrics server. It returns nil after a // graceful shutdown. The server keeps running without metrics if their port cannot be opened. func (s *Server) ListenAndServe() error { if s.metrics != nil { if err := s.metrics.Start(); err != nil { logging.GetLogger().Error("Failed to start the metrics server", zap.Error(err)) } } s.startCoverSearch() logging.GetLogger().Info("Server starting", zap.String("address", s.httpServer.Addr)) if err := s.httpServer.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { return err } return nil } // Shutdown stops accepting requests, waits for running requests and then closes the database. func (s *Server) Shutdown(ctx context.Context) error { err := s.httpServer.Shutdown(ctx) if s.metrics != nil { err = errors.Join(err, s.metrics.Shutdown(ctx)) } if s.stopCovers != nil { s.stopCovers() <-s.coversDone } s.sync.Wait() s.characters.Close() s.db.Close() return err } // HTTPServer returns the underlying http.Server. func (s *Server) HTTPServer() *http.Server { return s.httpServer }