From 30e95cd41fc26cc2cbef658c47c690eb5e388b04 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 14:18:26 +0300 Subject: 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 --- player-server/cmd/player/main.go | 246 +----------------- player-server/cmd/player/main_test.go | 26 +- player-server/internal/api/handlers_file.go | 16 +- player-server/internal/api/handlers_media.go | 68 ++--- .../internal/api/handlers_playback_test.go | 6 +- .../internal/api/handlers_podcast_test.go | 22 +- player-server/internal/api/handlers_progress.go | 18 +- player-server/internal/api/handlers_share.go | 32 +-- player-server/internal/api/handlers_share_test.go | 16 +- player-server/internal/api/handlers_test.go | 22 +- player-server/internal/api/integration_test.go | 22 +- player-server/internal/api/server.go | 107 ++++---- player-server/internal/app/app.go | 285 +++++++++++++++++++++ 13 files changed, 479 insertions(+), 407 deletions(-) create mode 100644 player-server/internal/app/app.go 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) } diff --git a/player-server/internal/api/handlers_file.go b/player-server/internal/api/handlers_file.go index 97de92e..f1e8343 100644 --- a/player-server/internal/api/handlers_file.go +++ b/player-server/internal/api/handlers_file.go @@ -41,14 +41,14 @@ func (s *Server) fileHandler(fn func(context.Context, int64, int64) (*service.Fi } func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } - s.fileHandler(s.browseSvc.StreamMedia)(w, r) + s.fileHandler(s.media.Browse.StreamMedia)(w, r) } func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } id, err := pathID(r, "id") @@ -56,7 +56,7 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - res, err := s.browseSvc.DownloadMedia(r.Context(), id, userIDFromContext(r)) + res, err := s.media.Browse.DownloadMedia(r.Context(), id, userIDFromContext(r)) if err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) @@ -77,15 +77,15 @@ func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } w.Header().Set("Cache-Control", "no-cache") - s.fileHandler(s.browseSvc.GetThumbnail)(w, r) + s.fileHandler(s.media.Browse.GetThumbnail)(w, r) } func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.writeSvc) { + if !requireService(w, s.media.Write) { return } id, err := pathID(r, "id") @@ -93,7 +93,7 @@ func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - if err := s.writeSvc.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil { + if err := s.media.Write.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) return diff --git a/player-server/internal/api/handlers_media.go b/player-server/internal/api/handlers_media.go index 0570d66..055447e 100644 --- a/player-server/internal/api/handlers_media.go +++ b/player-server/internal/api/handlers_media.go @@ -19,10 +19,10 @@ const multipartFormMemoryLimit = 32 << 20 // ------------------------------------------------------------------ func (s *Server) handleListSets(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } - sets, err := s.browseSvc.ListSets(r.Context(), userIDFromContext(r)) + sets, err := s.media.Browse.ListSets(r.Context(), userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -31,7 +31,7 @@ func (s *Server) handleListSets(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } setID, err := pathID(r, "id") @@ -40,7 +40,7 @@ func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) { return } folder := r.URL.Query().Get("folder") - fr, err := s.browseSvc.GetSetCover(r.Context(), setID, folder, userIDFromContext(r)) + fr, err := s.media.Browse.GetSetCover(r.Context(), setID, folder, userIDFromContext(r)) if err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) @@ -58,7 +58,7 @@ func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) { } func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.writeSvc) { + if !requireService(w, s.media.Write) { return } setID, err := pathID(r, "id") @@ -67,7 +67,7 @@ func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { return } folder := r.URL.Query().Get("folder") - if err := s.writeSvc.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil { + if err := s.media.Write.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) return @@ -83,7 +83,7 @@ func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleBrowseSet(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } setID, err := pathID(r, "id") @@ -92,7 +92,7 @@ func (s *Server) handleBrowseSet(w http.ResponseWriter, r *http.Request) { return } parent := r.URL.Query().Get("parent") - result, err := s.browseSvc.BrowseSet(r.Context(), setID, userIDFromContext(r), parent) + result, err := s.media.Browse.BrowseSet(r.Context(), setID, userIDFromContext(r), parent) if err != nil { if errors.Is(err, service.ErrForbidden) { forbidden(w, "forbidden") @@ -105,7 +105,7 @@ func (s *Server) handleBrowseSet(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.writeSvc) { + if !requireService(w, s.media.Write) { return } setID, err := pathID(r, "id") @@ -134,7 +134,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - media, err := s.writeSvc.UploadMedia(r.Context(), setID, userIDFromContext(r), fh.Filename, file, fh.Size) + media, err := s.media.Write.UploadMedia(r.Context(), setID, userIDFromContext(r), fh.Filename, file, fh.Size) if err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) @@ -224,7 +224,7 @@ func parseMediaListQuery(q url.Values) service.MediaQueryFilter { } func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } path := r.URL.Path @@ -238,7 +238,7 @@ func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) { maxDur := q.Get("max_duration") start := time.Now() filter := parseMediaListQuery(q) - media, err := s.browseSvc.ListMedia(r.Context(), userIDFromContext(r), filter) + media, err := s.media.Browse.ListMedia(r.Context(), userIDFromContext(r), filter) dur := time.Since(start) if err != nil { s.logger.Error("api list media failed", "path", path, "set_id", setID, "set_ids", setIDs, "search", search, "type", typ, "favorites", fav, "min_duration", minDur, "max_duration", maxDur, "duration", dur, "err", err) @@ -250,7 +250,7 @@ func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.browseSvc) { + if !requireService(w, s.media.Browse) { return } id, err := pathID(r, "id") @@ -258,7 +258,7 @@ func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - detail, err := s.browseSvc.GetMediaDetail(r.Context(), id, userIDFromContext(r)) + detail, err := s.media.Browse.GetMediaDetail(r.Context(), id, userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -271,7 +271,7 @@ func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.favSvc) { + if !requireService(w, s.media.Favorite) { return } id, err := pathID(r, "id") @@ -279,7 +279,7 @@ func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - fav, err := s.favSvc.ToggleFavorite(r.Context(), userIDFromContext(r), id) + fav, err := s.media.Favorite.ToggleFavorite(r.Context(), userIDFromContext(r), id) if err != nil { handleError(w, err) return @@ -288,10 +288,10 @@ func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.tagSvc) { + if !requireService(w, s.media.Tag) { return } - tags, err := s.tagSvc.ListTags(r.Context(), userIDFromContext(r)) + tags, err := s.media.Tag.ListTags(r.Context(), userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -300,7 +300,7 @@ func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.tagSvc) { + if !requireService(w, s.media.Tag) { return } id, err := pathID(r, "id") @@ -315,7 +315,7 @@ func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) { badRequest(w, "tag required") return } - if err := s.tagSvc.AssignTag(r.Context(), id, userIDFromContext(r), req.Tag); err != nil { + if err := s.media.Tag.AssignTag(r.Context(), id, userIDFromContext(r), req.Tag); err != nil { handleError(w, err) return } @@ -323,7 +323,7 @@ func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRemoveTag(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.tagSvc) { + if !requireService(w, s.media.Tag) { return } id, err := pathID(r, "id") @@ -332,7 +332,7 @@ func (s *Server) handleRemoveTag(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid parameters") return } - if err := s.tagSvc.RemoveTag(r.Context(), id, userIDFromContext(r), tagName); err != nil { + if err := s.media.Tag.RemoveTag(r.Context(), id, userIDFromContext(r), tagName); err != nil { handleError(w, err) return } @@ -340,7 +340,7 @@ func (s *Server) handleRemoveTag(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSoftDelete(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.writeSvc) { + if !requireService(w, s.media.Write) { return } id, err := pathID(r, "id") @@ -348,7 +348,7 @@ func (s *Server) handleSoftDelete(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - if err := s.writeSvc.SoftDeleteMedia(r.Context(), id, userIDFromContext(r)); err != nil { + if err := s.media.Write.SoftDeleteMedia(r.Context(), id, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) return @@ -364,7 +364,7 @@ func (s *Server) handleSoftDelete(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.writeSvc) { + if !requireService(w, s.media.Write) { return } id, err := pathID(r, "id") @@ -372,7 +372,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - if err := s.writeSvc.RestoreMedia(r.Context(), id, userIDFromContext(r)); err != nil { + if err := s.media.Write.RestoreMedia(r.Context(), id, userIDFromContext(r)); err != nil { if errors.Is(err, service.ErrNotFound) { notFound(w) return @@ -395,7 +395,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) { // the client can decide whether to play natively or request a future transcoded // variant. It performs no actual transcoding — only a DB lookup. func (s *Server) handlePlaybackHints(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.playbackHintSvc) { + if !requireService(w, s.media.PlaybackHints) { return } id, err := pathID(r, "id") @@ -403,7 +403,7 @@ func (s *Server) handlePlaybackHints(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - hint, err := s.playbackHintSvc.GetPlaybackHint(r.Context(), id, userIDFromContext(r)) + hint, err := s.media.PlaybackHints.GetPlaybackHint(r.Context(), id, userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -416,7 +416,7 @@ func (s *Server) handlePlaybackHints(w http.ResponseWriter, r *http.Request) { // ------------------------------------------------------------------ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.noteSvc) { + if !requireService(w, s.media.Note) { return } id, err := pathID(r, "id") @@ -424,7 +424,7 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - note, err := s.noteSvc.GetNote(r.Context(), id, userIDFromContext(r)) + note, err := s.media.Note.GetNote(r.Context(), id, userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -437,7 +437,7 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleUpsertNote(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.noteSvc) { + if !requireService(w, s.media.Note) { return } id, err := pathID(r, "id") @@ -453,7 +453,7 @@ func (s *Server) handleUpsertNote(w http.ResponseWriter, r *http.Request) { return } note := &model.Note{MediaID: id, UserID: userIDFromContext(r), Content: req.Content} - if err := s.noteSvc.UpsertNote(r.Context(), note); err != nil { + if err := s.media.Note.UpsertNote(r.Context(), note); err != nil { handleError(w, err) return } @@ -461,7 +461,7 @@ func (s *Server) handleUpsertNote(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.noteSvc) { + if !requireService(w, s.media.Note) { return } id, err := pathID(r, "id") @@ -469,7 +469,7 @@ func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - if err := s.noteSvc.DeleteNote(r.Context(), id, userIDFromContext(r)); err != nil { + if err := s.media.Note.DeleteNote(r.Context(), id, userIDFromContext(r)); err != nil { handleError(w, err) return } diff --git a/player-server/internal/api/handlers_playback_test.go b/player-server/internal/api/handlers_playback_test.go index fcf0f80..bcd84d1 100644 --- a/player-server/internal/api/handlers_playback_test.go +++ b/player-server/internal/api/handlers_playback_test.go @@ -35,8 +35,10 @@ func newPlaybackTestServer(t *testing.T, store repository.Store, sm auth.Session SessionManager: sm, Config: &internal.Config{}, Services: ServerServices{ - Auth: authSvc, - PlaybackHints: hintSvc, + Media: MediaServices{ + PlaybackHints: hintSvc, + }, + Auth: authSvc, }, StaticFS: fs, MediaStreamer: service.NewMediaStreamer(nil, ""), diff --git a/player-server/internal/api/handlers_podcast_test.go b/player-server/internal/api/handlers_podcast_test.go index 73e76e2..3dae97b 100644 --- a/player-server/internal/api/handlers_podcast_test.go +++ b/player-server/internal/api/handlers_podcast_test.go @@ -57,16 +57,18 @@ func newPodcastTestServer(t *testing.T, store repository.Store, hasher auth.Hash SessionManager: sm, Config: cfg, Services: ServerServices{ - Browse: browseSvc, - Write: writeSvc, - Share: shareSvc, - Tag: tagSvc, - Favorite: favSvc, - Note: noteSvc, - Admin: adminSvc, - Progress: progressSvc, - Auth: authSvc, - Podcast: podcastSvc, + Media: MediaServices{ + Browse: browseSvc, + Write: writeSvc, + Share: shareSvc, + Tag: tagSvc, + Favorite: favSvc, + Note: noteSvc, + Progress: progressSvc, + }, + Admin: adminSvc, + Auth: authSvc, + Podcast: podcastSvc, }, StaticFS: fs, MediaStreamer: service.NewMediaStreamer(nil, ""), diff --git a/player-server/internal/api/handlers_progress.go b/player-server/internal/api/handlers_progress.go index 0d82bb7..9eb2326 100644 --- a/player-server/internal/api/handlers_progress.go +++ b/player-server/internal/api/handlers_progress.go @@ -8,7 +8,7 @@ import ( ) func (s *Server) handleProgress(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.progressSvc) { + if !requireService(w, s.media.Progress) { return } var req struct { @@ -28,7 +28,7 @@ func (s *Server) handleProgress(w http.ResponseWriter, r *http.Request) { badRequest(w, "session required") return } - err := s.progressSvc.UpdateProgress( + err := s.media.Progress.UpdateProgress( r.Context(), sessionID, userIDFromContext(r), @@ -43,7 +43,7 @@ func (s *Server) handleProgress(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleBatchProgress(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.progressSvc) { + if !requireService(w, s.media.Progress) { return } var req struct { @@ -76,7 +76,7 @@ func (s *Server) handleBatchProgress(w http.ResponseWriter, r *http.Request) { badRequest(w, "session required") return } - if err := s.progressSvc.BatchUpdateProgress(r.Context(), sessionID, userIDFromContext(r), updates); err != nil { + if err := s.media.Progress.BatchUpdateProgress(r.Context(), sessionID, userIDFromContext(r), updates); err != nil { handleError(w, err) return } @@ -84,7 +84,7 @@ func (s *Server) handleBatchProgress(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleProgressStatus(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.progressSvc) { + if !requireService(w, s.media.Progress) { return } var req struct { @@ -103,9 +103,9 @@ func (s *Server) handleProgressStatus(w http.ResponseWriter, r *http.Request) { var err error switch req.Status { case "finished": - err = s.progressSvc.MarkFinished(r.Context(), userIDFromContext(r), req.MediaID) + err = s.media.Progress.MarkFinished(r.Context(), userIDFromContext(r), req.MediaID) case "not_started": - err = s.progressSvc.MarkNotStarted(r.Context(), userIDFromContext(r), req.MediaID) + err = s.media.Progress.MarkNotStarted(r.Context(), userIDFromContext(r), req.MediaID) default: badRequest(w, "invalid status") return @@ -118,10 +118,10 @@ func (s *Server) handleProgressStatus(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleInProgress(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.progressSvc) { + if !requireService(w, s.media.Progress) { return } - media, err := s.progressSvc.ListInProgress(r.Context(), userIDFromContext(r)) + media, err := s.media.Progress.ListInProgress(r.Context(), userIDFromContext(r)) if err != nil { handleError(w, err) return diff --git a/player-server/internal/api/handlers_share.go b/player-server/internal/api/handlers_share.go index ccf1632..8e63159 100644 --- a/player-server/internal/api/handlers_share.go +++ b/player-server/internal/api/handlers_share.go @@ -15,7 +15,7 @@ import ( // ------------------------------------------------------------------ func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } id, err := pathID(r, "id") @@ -27,7 +27,7 @@ func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) { // share-expiry semantics (e.g. assert that expiresAt is exactly // ShareDefaultExpiryDays * 24h after the mock clock's T). expiresAt := s.clk.Now().Add(time.Duration(s.cfg.ShareDefaultExpiryDays) * 24 * time.Hour) - share, err := s.shareSvc.CreateShare(r.Context(), userIDFromContext(r), id, expiresAt) + share, err := s.media.Share.CreateShare(r.Context(), userIDFromContext(r), id, expiresAt) if err != nil { handleError(w, err) return @@ -36,7 +36,7 @@ func (s *Server) handleCreateShare(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } id, err := pathID(r, "id") @@ -44,7 +44,7 @@ func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { badRequest(w, "invalid media id") return } - shares, err := s.shareSvc.ListShares(r.Context(), id, userIDFromContext(r)) + shares, err := s.media.Share.ListShares(r.Context(), id, userIDFromContext(r)) if err != nil { handleError(w, err) return @@ -53,7 +53,7 @@ func (s *Server) handleListShares(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } token := r.PathValue("token") @@ -61,7 +61,7 @@ func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) { badRequest(w, "token required") return } - if err := s.shareSvc.RevokeShare(r.Context(), token, userIDFromContext(r)); err != nil { + if err := s.media.Share.RevokeShare(r.Context(), token, userIDFromContext(r)); err != nil { handleError(w, err) return } @@ -69,11 +69,11 @@ func (s *Server) handleRevokeShare(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } token := r.PathValue("token") - res, err := s.shareSvc.GetSharedMedia(r.Context(), token) + res, err := s.media.Share.GetSharedMedia(r.Context(), token) if err != nil || res == nil { if err != nil && errors.Is(err, service.ErrShareExpired) { http.Error(w, "gone", http.StatusGone) @@ -115,11 +115,11 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleShareThumbnail(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } token := r.PathValue("token") - fr, err := s.shareSvc.GetSharedThumbnail(r.Context(), token) + fr, err := s.media.Share.GetSharedThumbnail(r.Context(), token) if err != nil { if errors.Is(err, service.ErrShareExpired) { http.Error(w, "gone", http.StatusGone) @@ -141,11 +141,11 @@ func (s *Server) handleShareThumbnail(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } token := r.PathValue("token") - res, err := s.shareSvc.StreamSharedMedia(r.Context(), token) + res, err := s.media.Share.StreamSharedMedia(r.Context(), token) if err != nil { if errors.Is(err, service.ErrShareExpired) { http.Error(w, "gone", http.StatusGone) @@ -166,11 +166,11 @@ func (s *Server) handleShareStream(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleShareDownload(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } token := r.PathValue("token") - fr, err := s.shareSvc.StreamSharedMedia(r.Context(), token) + fr, err := s.media.Share.StreamSharedMedia(r.Context(), token) if err != nil { if errors.Is(err, service.ErrShareExpired) { http.Error(w, "gone", http.StatusGone) @@ -191,10 +191,10 @@ func (s *Server) handleShareDownload(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleMyShares(w http.ResponseWriter, r *http.Request) { - if !requireService(w, s.shareSvc) { + if !requireService(w, s.media.Share) { return } - shares, err := s.shareSvc.ListMyShares(r.Context(), userIDFromContext(r)) + shares, err := s.media.Share.ListMyShares(r.Context(), userIDFromContext(r)) if err != nil { handleError(w, err) return diff --git a/player-server/internal/api/handlers_share_test.go b/player-server/internal/api/handlers_share_test.go index 021e023..effdeb4 100644 --- a/player-server/internal/api/handlers_share_test.go +++ b/player-server/internal/api/handlers_share_test.go @@ -56,13 +56,15 @@ func TestCreateShare_UsesInjectedClock(t *testing.T) { SessionManager: sm, Config: cfg, Services: ServerServices{ - Browse: ms, - Write: ms, - Share: ms, - Tag: ms, - Favorite: ms, - Note: ms, - Auth: authSvc, + Media: MediaServices{ + Browse: ms, + Write: ms, + Share: ms, + Tag: ms, + Favorite: ms, + Note: ms, + }, + Auth: authSvc, }, StaticFS: newTestFS(map[string]string{"index.html": "x"}), MediaStreamer: service.NewMediaStreamer(nil, ""), diff --git a/player-server/internal/api/handlers_test.go b/player-server/internal/api/handlers_test.go index 4f04f98..96c4552 100644 --- a/player-server/internal/api/handlers_test.go +++ b/player-server/internal/api/handlers_test.go @@ -81,16 +81,20 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm Hasher: hasher, SessionManager: sm, Config: cfg, + // Use the nested MediaServices group so tests wire all media-domain + // services in one block, mirroring the production ServerServices layout. Services: ServerServices{ - Browse: browseSvc, - Write: writeSvc, - Share: shareSvc, - Tag: tagSvc, - Favorite: favSvc, - Note: noteSvc, - Admin: adminSvc, - Progress: progressSvc, - Auth: authSvc, + Media: MediaServices{ + Browse: browseSvc, + Write: writeSvc, + Share: shareSvc, + Tag: tagSvc, + Favorite: favSvc, + Note: noteSvc, + Progress: progressSvc, + }, + Admin: adminSvc, + Auth: authSvc, }, StaticFS: fs, MediaStreamer: mediaStreamer, diff --git a/player-server/internal/api/integration_test.go b/player-server/internal/api/integration_test.go index 08c0d86..9397de3 100644 --- a/player-server/internal/api/integration_test.go +++ b/player-server/internal/api/integration_test.go @@ -71,16 +71,18 @@ func newIntegrationServer(t *testing.T) *integrationEnv { SessionManager: sm, Config: cfg, Services: ServerServices{ - Browse: buildBrowseMock(), - Write: buildBrowseMock(), - Share: buildBrowseMock(), - Tag: buildBrowseMock(), - Favorite: buildBrowseMock(), - Note: buildBrowseMock(), - Admin: buildAdminMock(), - Progress: buildProgressMock(), - Auth: authSvc, - Podcast: &integrationPodcastService{}, + Media: MediaServices{ + Browse: buildBrowseMock(), + Write: buildBrowseMock(), + Share: buildBrowseMock(), + Tag: buildBrowseMock(), + Favorite: buildBrowseMock(), + Note: buildBrowseMock(), + Progress: buildProgressMock(), + }, + Admin: buildAdminMock(), + Auth: authSvc, + Podcast: &integrationPodcastService{}, }, StaticFS: http.FS(staticFS), MediaStreamer: service.NewMediaStreamer(nil, ""), diff --git a/player-server/internal/api/server.go b/player-server/internal/api/server.go index c2ee32c..fa2ca74 100644 --- a/player-server/internal/api/server.go +++ b/player-server/internal/api/server.go @@ -17,7 +17,24 @@ import ( "codeberg.org/snonux/player/internal/web" ) +// MediaServices groups the media-domain service dependencies used by route +// handlers. Keeping all media-related services in one sub-struct reduces the +// width of Server/ServerServices and makes it easy to see which concerns belong +// to the media vertical slice. If any service is nil its routes return 501. +type MediaServices struct { + Browse service.MediaBrowseService + Write service.MediaWriteService + Share service.MediaShareService + Tag service.MediaTagService + Favorite service.MediaFavoriteService + Note service.MediaNoteService + Progress service.ProgressService + PlaybackHints service.PlaybackHintsService +} + // Server holds HTTP handlers and dependencies. +// Service dependencies are grouped into vertical slices (media, auth, admin, +// podcast) to reduce the width of the struct and clarify ownership boundaries. type Server struct { store repository.Store hasher auth.Hasher @@ -27,41 +44,32 @@ type Server struct { // expiry, session cookie Expires, API token expiry). Injected so tests // can substitute a clock.MockClock and assert deterministic semantics // instead of racing the wall clock. - clk clock.Clock - mux *http.ServeMux - handler http.Handler - browseSvc service.MediaBrowseService - writeSvc service.MediaWriteService - shareSvc service.MediaShareService - tagSvc service.MediaTagService - favSvc service.MediaFavoriteService - noteSvc service.MediaNoteService - adminSvc service.AdminService - progressSvc service.ProgressService - authSvc service.AuthService - podcastSvc service.PodcastEpisodeService - playbackHintSvc service.PlaybackHintsService - streamer service.MediaStreamer - staticFS http.FileSystem - shareRenderer *web.SharePageRenderer - logger *slog.Logger - mw *Middleware + clk clock.Clock + mux *http.ServeMux + handler http.Handler + // media groups all media-domain services (browse, write, share, tags, + // favorites, notes, progress, playback hints) into a single vertical slice. + media MediaServices + authSvc service.AuthService + adminSvc service.AdminService + podcastSvc service.PodcastEpisodeService + streamer service.MediaStreamer + staticFS http.FileSystem + shareRenderer *web.SharePageRenderer + logger *slog.Logger + mw *Middleware } // ServerServices groups the optional service dependencies used by route handlers. +// Media-related services are collected into the Media sub-struct to reduce +// width and reflect the media vertical-slice boundary. Non-media services +// (Auth, Admin, Podcast) remain as direct fields. // If any service is nil, its respective routes return 501. type ServerServices struct { - Browse service.MediaBrowseService - Write service.MediaWriteService - Share service.MediaShareService - Tag service.MediaTagService - Favorite service.MediaFavoriteService - Note service.MediaNoteService - Admin service.AdminService - Progress service.ProgressService - Auth service.AuthService - Podcast service.PodcastEpisodeService - PlaybackHints service.PlaybackHintsService + Media MediaServices + Auth service.AuthService + Admin service.AdminService + Podcast service.PodcastEpisodeService } // ServerDeps contains the dependencies needed to construct a Server. @@ -117,29 +125,24 @@ func NewServerWithLogger(deps ServerDeps, logger *slog.Logger) (*Server, error) if deps.Clock == nil { deps.Clock = clock.RealClock{} } + // Populate the media vertical-slice sub-struct directly from the nested + // ServerServices.Media group so the Server never sees the flat list. s := &Server{ - store: deps.Store, - hasher: deps.Hasher, - sm: deps.SessionManager, - cfg: deps.Config, - clk: deps.Clock, - mux: http.NewServeMux(), - browseSvc: deps.Services.Browse, - writeSvc: deps.Services.Write, - shareSvc: deps.Services.Share, - tagSvc: deps.Services.Tag, - favSvc: deps.Services.Favorite, - noteSvc: deps.Services.Note, - adminSvc: deps.Services.Admin, - progressSvc: deps.Services.Progress, - authSvc: deps.Services.Auth, - podcastSvc: deps.Services.Podcast, - playbackHintSvc: deps.Services.PlaybackHints, - streamer: deps.MediaStreamer, - staticFS: deps.StaticFS, - shareRenderer: web.NewSharePageRenderer(deps.StaticFS), - logger: logger, - mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), + store: deps.Store, + hasher: deps.Hasher, + sm: deps.SessionManager, + cfg: deps.Config, + clk: deps.Clock, + mux: http.NewServeMux(), + media: deps.Services.Media, + authSvc: deps.Services.Auth, + adminSvc: deps.Services.Admin, + podcastSvc: deps.Services.Podcast, + streamer: deps.MediaStreamer, + staticFS: deps.StaticFS, + shareRenderer: web.NewSharePageRenderer(deps.StaticFS), + logger: logger, + mw: NewMiddleware(deps.Services.Auth, deps.SessionManager), } s.routes() s.handler = withCORS(s.cfg.CORSAllowedOrigins, s.mw.BootstrapRedirect(s.mux)) diff --git a/player-server/internal/app/app.go b/player-server/internal/app/app.go new file mode 100644 index 0000000..d37d52b --- /dev/null +++ b/player-server/internal/app/app.go @@ -0,0 +1,285 @@ +// Package app contains the application bootstrap, dependency wiring, and +// server lifecycle. It is the single place responsible for constructing all +// service/repository dependencies and starting/stopping the HTTP server. +// cmd/player/main.go is intentionally kept thin: it parses CLI flags, loads +// config, and delegates everything else to this package. +package app + +import ( + "context" + "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" +) + +// Deps bundles all wired service-layer dependencies assembled during +// application bootstrap. It is the single structure passed between the +// wiring stage and the server-start stage so that the two concerns remain +// clearly separated. +type Deps 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 is an optional channel that receives a signal once all + // background workers have been started. Tests use this to synchronise + // without polling or sleeping. + WorkersStarted chan<- struct{} +} + +// BuildLogger creates a slog.Logger aligned with the named log level. +// Unknown levels fall back to INFO so the application always produces +// structured output even when misconfigured. +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})) +} + +// Wire constructs the full service-layer dependency graph from the provided +// config and store. It does NOT start any background goroutines; that is the +// responsibility of StartBackgroundWorkers. Separating construction from +// activation makes the wiring easy to test in isolation. +func Wire(cfg *internal.Config, store repository.Store, logger *slog.Logger, appCtx context.Context) *Deps { + 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 &Deps{ + 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 worker, podcast +// feed checker). It must be called after Wire() and before RunServer(). +// Workers are stopped either by cancelling AppCtx or by calling +// deps.GCWorker.Stop(). +func StartBackgroundWorkers(deps *Deps) { + deps.GCWorker.Start() + + // Start podcast feed background checker. It runs on a fixed ticker and + // exits when the application context is cancelled. + 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 + } + } + }() + + // Signal to callers (typically tests) that all workers are running. + 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 (SIGINT, SIGTERM). Production callers pass nil to +// get real OS-signal behaviour; tests inject a synthetic channel. +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 HTTP server. +// It allows up to five seconds for in-flight requests to complete before +// forcing the server to stop. +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 a shutdown signal is +// received or the server returns an error. It is the last step in the +// application lifecycle and returns only after a graceful shutdown attempt. +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 is the primary application entry point after flag parsing and +// config loading. It opens the database, wires all dependencies, starts +// background workers, and runs the HTTP server until a shutdown signal +// arrives. sigCh may be nil for production use (OS signals are used); tests +// inject a synthetic channel to drive shutdown deterministically. +func RunWithSignal(cfg *internal.Config, logger *slog.Logger, sigCh <-chan os.Signal) error { + 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 := Wire(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, + // Use the grouped MediaServices sub-struct to wire all media-domain + // services in one block, reducing the width of the ServerServices literal. + Services: api.ServerServices{ + Media: api.MediaServices{ + Browse: deps.MediaSvc, + Write: deps.MediaSvc, + Share: deps.MediaSvc, + Tag: deps.MediaSvc, + Favorite: deps.MediaSvc, + Note: deps.MediaSvc, + Progress: deps.ProgressSvc, + PlaybackHints: deps.PlaybackHintSvc, + }, + Admin: deps.AdminSvc, + Auth: deps.AuthSvc, + Podcast: deps.PodcastSvc, + }, + 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) + } + + return RunServer(server, cfg, logger, sigCh) +} -- cgit v1.2.3