summaryrefslogtreecommitdiff
path: root/player-server/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 14:18:26 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 14:18:26 +0300
commit30e95cd41fc26cc2cbef658c47c690eb5e388b04 (patch)
tree6750d3fa31670c6b3c1f2efade0e1348de4ef2d5 /player-server/cmd
parent0e5cefc30a4f6bc9d47069d6a3844027afc8bc55 (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.go246
-rw-r--r--player-server/cmd/player/main_test.go26
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)
}