diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-20 14:18:26 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-20 14:18:26 +0300 |
| commit | 30e95cd41fc26cc2cbef658c47c690eb5e388b04 (patch) | |
| tree | 6750d3fa31670c6b3c1f2efade0e1348de4ef2d5 /player-server/cmd | |
| parent | 0e5cefc30a4f6bc9d47069d6a3844027afc8bc55 (diff) | |
Extract app wiring from main.go into internal/app package (9a)
Move all dependency wiring, background worker startup, server lifecycle
(Wire, StartBackgroundWorkers, RunServer, RunWithSignal, BuildLogger) into
internal/app so cmd/player/main.go becomes thin: parse flags, load config,
delegate to app.RunWithSignal. Updated main_test.go to call app.Wire and
app.StartBackgroundWorkers directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Diffstat (limited to 'player-server/cmd')
| -rw-r--r-- | player-server/cmd/player/main.go | 246 | ||||
| -rw-r--r-- | player-server/cmd/player/main_test.go | 26 |
2 files changed, 22 insertions, 250 deletions
diff --git a/player-server/cmd/player/main.go b/player-server/cmd/player/main.go index d69f3a9..7c605de 100644 --- a/player-server/cmd/player/main.go +++ b/player-server/cmd/player/main.go @@ -1,47 +1,15 @@ package main import ( - "context" "flag" "fmt" "log/slog" - "net/http" "os" - "os/signal" - "syscall" - "time" "codeberg.org/snonux/player/internal" - "codeberg.org/snonux/player/internal/api" - "codeberg.org/snonux/player/internal/auth" - "codeberg.org/snonux/player/internal/clock" - "codeberg.org/snonux/player/internal/probe" - "codeberg.org/snonux/player/internal/repository" - "codeberg.org/snonux/player/internal/scanner" - "codeberg.org/snonux/player/internal/service" - "codeberg.org/snonux/player/internal/thumb" + "codeberg.org/snonux/player/internal/app" ) -// appDeps bundles all wired service-layer dependencies. -type appDeps struct { - store repository.Store - hasher auth.Hasher - sm auth.SessionManager - cfg *internal.Config - clk clock.Clock - mediaSvc service.MediaService - adminSvc service.AdminService - progressSvc service.ProgressService - authSvc service.AuthService - podcastSvc service.PodcastEpisodeService - playbackHintSvc service.PlaybackHintsService - scanner scanner.Scanner - gcWorker *service.GCWorker - logger *slog.Logger - appCtx context.Context - workersStarted chan<- struct{} -} - // parseVersionFlag parses CLI flags and returns whether --version was requested. func parseVersionFlag(args []string) (bool, error) { fs := flag.NewFlagSet("player", flag.ContinueOnError) @@ -63,160 +31,11 @@ func run(args []string) error { return runWithSignal(args, nil) } -// buildLogger creates a slog.Logger aligned with the named log level. -func buildLogger(logLevel string) *slog.Logger { - var level slog.Level - switch logLevel { - case "debug": - level = slog.LevelDebug - case "info": - level = slog.LevelInfo - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - default: - level = slog.LevelInfo - } - return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})) -} - -// wireDeps constructs the core service layer dependencies. -func wireDeps(cfg *internal.Config, store repository.Store, logger *slog.Logger, appCtx context.Context) *appDeps { - clk := clock.RealClock{} - hasher := auth.NewBCryptHasher(12) - sm := auth.NewSessionManager(store, clk, time.Duration(cfg.SessionTimeoutHours)*time.Hour) - tm := auth.NewTokenManager() - - prober := probe.NewFFProber() - thumbGen := thumb.NewFFmpegGenerator() - // Explicit filesystem thumbnail resolver: keeps service.GetThumbnail - // free of direct os.Stat calls and makes the dependency easy to swap - // out in tests or alternate deployments (e.g. object storage). - thumbResolver := thumb.NewFSResolver() - // thumb.FSMaker encapsulates the "create .thumbnails dir + invoke - // generator + warn-on-failure" policy so the scanner only - // orchestrates the scan and does not own thumbnail layout policy. - thumbMaker := thumb.NewFSMaker(thumbGen, nil, logger) - - helper := service.NewAccessHelper(store) - browser := service.NewPodcastBrowseService(store, cfg.MediaRoot) - mediaSvc := service.NewMediaServiceWithDeps(store, clk, cfg.MediaRoot, thumbGen, prober, browser, thumbResolver) - playbackHintSvc := service.NewPlaybackHintsService(helper) - - fsScanner := scanner.NewFSScannerWithMaker(store, prober, thumbMaker, clk, cfg.MediaRoot, logger) - adminSvc := service.NewAdminServiceWithLogger(store, clk, hasher, fsScanner, cfg.MediaRoot, appCtx, logger) - - progressSvc := service.NewProgressService(store, clk) - authSvc := service.NewAuthService(store, clk, hasher, sm, tm) - - podcastSvc := service.NewPodcastServiceWithLogger(store, clk, cfg.MediaRoot, helper, prober, thumbGen, &http.Client{Timeout: service.DefaultHTTPClientTimeout}, cfg.PodcastCheckMinutes, logger) - - gcWorker := service.NewGCWorker(store, clk, cfg.MediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger) - - return &appDeps{ - store: store, - hasher: hasher, - sm: sm, - cfg: cfg, - clk: clk, - mediaSvc: mediaSvc, - adminSvc: adminSvc, - progressSvc: progressSvc, - authSvc: authSvc, - podcastSvc: podcastSvc, - playbackHintSvc: playbackHintSvc, - scanner: fsScanner, - gcWorker: gcWorker, - logger: logger, - appCtx: appCtx, - } -} - -// startBackgroundWorkers launches background goroutines (GC, podcast feed checker). -func startBackgroundWorkers(deps *appDeps) { - deps.gcWorker.Start() - - // Start podcast feed background checker. - go func() { - ticker := time.NewTicker(time.Duration(deps.cfg.PodcastCheckMinutes) * time.Minute) - defer ticker.Stop() - for { - select { - case <-ticker.C: - func() { - // Use the unified service.RecoverWorker helper so this - // matches every other background-worker panic path in - // the codebase (gc, rescan, podcast feed check). - defer func() { - service.RecoverWorker(deps.logger, "podcast checker", recover()) - }() - if err := deps.podcastSvc.CheckFeeds(context.Background()); err != nil { - deps.logger.Error("podcast feed check failed", "err", err) - } - }() - case <-deps.appCtx.Done(): - return - } - } - }() - if deps.workersStarted != nil { - select { - case deps.workersStarted <- struct{}{}: - default: - } - } -} - -// ensureSignalChannel returns the provided channel or creates a new one wired -// to OS interrupt signals. -func ensureSignalChannel(sigCh <-chan os.Signal) <-chan os.Signal { - if sigCh != nil { - return sigCh - } - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - return quit -} - -// shutdownGracefully performs a timed graceful shutdown of the server. -func shutdownGracefully(gs *api.GracefulServer, logger *slog.Logger) error { - logger.Info("shutting down server") - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := gs.Server.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("failed to shutdown server: %w", err) - } - logger.Info("server stopped") - return nil -} - -// runServer starts the HTTP server and blocks until shutdown. -func runServer(handler http.Handler, cfg *internal.Config, logger *slog.Logger, sigCh <-chan os.Signal) error { - gs := api.NewGracefulServer(handler, cfg) - - logger.Info("player starting", "version", internal.Version, "addr", gs.Server.Addr) - - errCh := make(chan error, 1) - go func() { - if err := gs.Server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errCh <- fmt.Errorf("failed to start server: %w", err) - } - }() - - sigCh = ensureSignalChannel(sigCh) - - select { - case <-sigCh: - case err := <-errCh: - if err != nil { - return err - } - } - - return shutdownGracefully(gs, logger) -} - +// runWithSignal parses flags, loads config, builds the logger, and delegates +// the full application lifecycle to app.RunWithSignal. Keeping flag parsing +// and config loading in main keeps the boundary between CLI concerns and +// application concerns clear. sigCh may be nil (production) or a synthetic +// channel (tests). func runWithSignal(args []string, sigCh <-chan os.Signal) error { showVersion, err := parseVersionFlag(args) if err != nil { @@ -232,56 +51,7 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error { return fmt.Errorf("failed to load config: %w", err) } - logger := buildLogger(cfg.LogLevel) - - store, err := repository.Open(cfg.DBPath) - if err != nil { - return fmt.Errorf("failed to open database: %w", err) - } - defer func() { - if err := store.Close(); err != nil { - logger.Error("failed to close database", "err", err) - } - }() - - appCtx, appCancel := context.WithCancel(context.Background()) - defer appCancel() - - deps := wireDeps(cfg, store, logger, appCtx) - defer deps.gcWorker.Stop() - startBackgroundWorkers(deps) - - staticFS := http.Dir("web") - remuxer := probe.NewFFRemuxer() - streamer := service.NewMediaStreamer(remuxer, cfg.MediaRoot) - server, err := api.NewServerWithLogger(api.ServerDeps{ - Store: store, - Hasher: deps.hasher, - SessionManager: deps.sm, - Config: cfg, - Services: api.ServerServices{ - Browse: deps.mediaSvc, - Write: deps.mediaSvc, - Share: deps.mediaSvc, - Tag: deps.mediaSvc, - Favorite: deps.mediaSvc, - Note: deps.mediaSvc, - Admin: deps.adminSvc, - Progress: deps.progressSvc, - Auth: deps.authSvc, - Podcast: deps.podcastSvc, - PlaybackHints: deps.playbackHintSvc, - }, - StaticFS: staticFS, - MediaStreamer: streamer, - // Share the already-wired clock so handler-level time arithmetic - // (share expiry, session cookie Expires, API token expiry) uses - // the same source as the rest of the services (scanner, auth, etc). - Clock: deps.clk, - }, logger) - if err != nil { - return fmt.Errorf("failed to create API server: %w", err) - } + logger := app.BuildLogger(cfg.LogLevel) - return runServer(server, cfg, logger, sigCh) + return app.RunWithSignal(cfg, logger, sigCh) } diff --git a/player-server/cmd/player/main_test.go b/player-server/cmd/player/main_test.go index 21b6ff0..da3ee6f 100644 --- a/player-server/cmd/player/main_test.go +++ b/player-server/cmd/player/main_test.go @@ -13,6 +13,7 @@ import ( "time" "codeberg.org/snonux/player/internal" + "codeberg.org/snonux/player/internal/app" "codeberg.org/snonux/player/internal/clock" "codeberg.org/snonux/player/internal/repository" "codeberg.org/snonux/player/internal/service" @@ -184,7 +185,8 @@ func TestRunWithSignal_ServerErrorPath(t *testing.T) { } func TestWireDeps_DoesNotStartBackgroundWorkers(t *testing.T) { - // wireDeps must only construct dependencies; it must not start any background goroutines. + // app.Wire must only construct dependencies; it must not start any + // background goroutines. StartBackgroundWorkers is a separate step. tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") mediaRoot := filepath.Join(tmpDir, "media") @@ -212,18 +214,18 @@ func TestWireDeps_DoesNotStartBackgroundWorkers(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - deps := wireDeps(cfg, store, logger, ctx) - if deps.gcWorker == nil { - t.Fatal("expected gcWorker to be non-nil") + deps := app.Wire(cfg, store, logger, ctx) + if deps.GCWorker == nil { + t.Fatal("expected GCWorker to be non-nil") } // We call Stop() immediately. If Start() had been called this is safe (idempotent). // If Start() was NOT called, the internal stopCh is still open, so Stop() must handle it gracefully. - deps.gcWorker.Stop() + deps.GCWorker.Stop() } func TestStartBackgroundWorkers_StartsAndStops(t *testing.T) { - // startBackgroundWorkers should launch background goroutines that exit + // app.StartBackgroundWorkers should launch background goroutines that exit // cleanly when the app context is cancelled. tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "test.db") @@ -252,10 +254,10 @@ func TestStartBackgroundWorkers_StartsAndStops(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - deps := wireDeps(cfg, store, logger, ctx) + deps := app.Wire(cfg, store, logger, ctx) workersStarted := make(chan struct{}, 1) - deps.workersStarted = workersStarted - startBackgroundWorkers(deps) + deps.WorkersStarted = workersStarted + app.StartBackgroundWorkers(deps) select { case <-workersStarted: @@ -267,7 +269,7 @@ func TestStartBackgroundWorkers_StartsAndStops(t *testing.T) { cancel() // Stop the GC worker explicitly (safe and idempotent). - deps.gcWorker.Stop() + deps.GCWorker.Stop() // No explicit assertion for goroutine exit beyond the fact that we have not leaked; // the final goroutine dump check in the test run will catch leaks. @@ -278,8 +280,8 @@ func TestStartBackgroundWorkers_NilDepsPanics(t *testing.T) { // so the bug is surfaced at start-up rather than later as a nil dereference. defer func() { if r := recover(); r == nil { - t.Fatal("expected panic when startBackgroundWorkers receives nil") + t.Fatal("expected panic when StartBackgroundWorkers receives nil") } }() - startBackgroundWorkers(nil) + app.StartBackgroundWorkers(nil) } |
