summaryrefslogtreecommitdiff
path: root/player-server
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
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')
-rw-r--r--player-server/cmd/player/main.go246
-rw-r--r--player-server/cmd/player/main_test.go26
-rw-r--r--player-server/internal/api/handlers_file.go16
-rw-r--r--player-server/internal/api/handlers_media.go68
-rw-r--r--player-server/internal/api/handlers_playback_test.go6
-rw-r--r--player-server/internal/api/handlers_podcast_test.go22
-rw-r--r--player-server/internal/api/handlers_progress.go18
-rw-r--r--player-server/internal/api/handlers_share.go32
-rw-r--r--player-server/internal/api/handlers_share_test.go16
-rw-r--r--player-server/internal/api/handlers_test.go22
-rw-r--r--player-server/internal/api/integration_test.go22
-rw-r--r--player-server/internal/api/server.go107
-rw-r--r--player-server/internal/app/app.go285
13 files changed, 479 insertions, 407 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)
}
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")