summaryrefslogtreecommitdiff
path: root/cmd
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
commit914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch)
tree02b11537237a204048589e14ed24938b805e42f7 /cmd
parent3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff)
Restructure repo: move Go server into player-server/
Diffstat (limited to 'cmd')
-rw-r--r--cmd/player/main.go269
-rw-r--r--cmd/player/main_test.go285
2 files changed, 0 insertions, 554 deletions
diff --git a/cmd/player/main.go b/cmd/player/main.go
deleted file mode 100644
index 771664f..0000000
--- a/cmd/player/main.go
+++ /dev/null
@@ -1,269 +0,0 @@
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "log/slog"
- "net/http"
- "os"
- "os/signal"
- "runtime/debug"
- "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"
-)
-
-// 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
- 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)
- versionFlag := fs.Bool("version", false, "print version and exit")
- if err := fs.Parse(args); err != nil {
- return false, err
- }
- return *versionFlag, nil
-}
-
-func main() {
- if err := run(os.Args[1:]); err != nil {
- slog.Error("fatal", "err", err)
- os.Exit(1)
- }
-}
-
-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}))
-}
-
-func recoverBackgroundWorkerPanic(logger *slog.Logger, worker string) {
- if r := recover(); r != nil && logger != nil {
- logger.Error("background worker panic", "worker", worker, "panic", r, "stack", string(debug.Stack()))
- }
-}
-
-// 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)
-
- prober := probe.NewFFProber()
- thumbGen := thumb.NewFFmpegGenerator()
-
- helper := service.NewAccessHelper(store)
- browser := service.NewPodcastBrowseService(store, cfg.MediaRoot)
- mediaSvc := service.NewMediaServiceWithPodcastBrowser(store, clk, cfg.MediaRoot, thumbGen, prober, browser)
-
- fsScanner := scanner.NewFSScannerWithLogger(store, prober, thumbGen, 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)
-
- 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,
- 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() {
- defer recoverBackgroundWorkerPanic(deps.logger, "podcast checker")
- 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)
-}
-
-func runWithSignal(args []string, sigCh <-chan os.Signal) error {
- showVersion, err := parseVersionFlag(args)
- if err != nil {
- return err
- }
- if showVersion {
- fmt.Println(internal.Version)
- return nil
- }
-
- cfg, err := internal.LoadConfig()
- if err != nil {
- 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)
- server := 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,
- },
- StaticFS: staticFS,
- MediaStreamer: streamer,
- }, logger)
-
- return runServer(server, cfg, logger, sigCh)
-}
diff --git a/cmd/player/main_test.go b/cmd/player/main_test.go
deleted file mode 100644
index 21b6ff0..0000000
--- a/cmd/player/main_test.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package main
-
-import (
- "bytes"
- "context"
- "io"
- "log/slog"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "testing"
- "time"
-
- "codeberg.org/snonux/player/internal"
- "codeberg.org/snonux/player/internal/clock"
- "codeberg.org/snonux/player/internal/repository"
- "codeberg.org/snonux/player/internal/service"
-)
-
-func captureStdout(fn func()) string {
- old := os.Stdout
- r, w, _ := os.Pipe()
- os.Stdout = w
- fn()
- w.Close()
- os.Stdout = old
- var buf bytes.Buffer
- io.Copy(&buf, r)
- return strings.TrimSpace(buf.String())
-}
-
-func TestGCWorkerWiring(t *testing.T) {
- tmpDir := t.TempDir()
- dbPath := filepath.Join(tmpDir, "test.db")
- mediaRoot := filepath.Join(tmpDir, "media")
- if err := os.MkdirAll(mediaRoot, 0o755); err != nil {
- t.Fatalf("mkdir media root: %v", err)
- }
-
- store, err := repository.Open(dbPath)
- if err != nil {
- t.Fatalf("open db: %v", err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Logf("close db: %v", err)
- }
- }()
-
- cfg := &internal.Config{
- GCIntervalMinutes: 1,
- }
-
- logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
- clk := clock.RealClock{}
-
- w := service.NewGCWorker(store, clk, mediaRoot, time.Duration(cfg.GCIntervalMinutes)*time.Minute, logger)
- w.Start()
- w.Stop() // must not panic even after interacting with real store
-}
-
-func TestRun_VersionFlag(t *testing.T) {
- out := captureStdout(func() {
- err := run([]string{"-version"})
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- })
- if out != internal.Version {
- t.Fatalf("expected %q, got %q", internal.Version, out)
- }
-}
-
-func TestRun_InvalidFlag(t *testing.T) {
- err := run([]string{"-invalidflag"})
- if err == nil {
- t.Fatal("expected error for invalid flag")
- }
-}
-
-func TestRun_InvalidConfig(t *testing.T) {
- t.Setenv("PORT", "invalid")
- err := run([]string{})
- if err == nil {
- t.Fatal("expected error for invalid PORT")
- }
-}
-
-func TestRunWithSignal_NormalShutdown(t *testing.T) {
- if os.Getenv("GO_TEST_IN_CONTAINER") == "no_ffprobe" {
- t.Skip("ffprobe not available in this environment")
- }
-
- tmpDir := t.TempDir()
- t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
- t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
- t.Setenv("PORT", "0")
-
- // Build a channel we can use instead of real OS signals.
- sigCh := make(chan os.Signal, 1)
-
- // Run the server in a goroutine; it will block on <-sigCh.
- errCh := make(chan error, 1)
- go func() {
- errCh <- runWithSignal([]string{}, sigCh)
- }()
-
- // Send a synthetic signal to trigger shutdown.
- sigCh <- syscall.SIGINT
-
- // Wait for graceful shutdown.
- select {
- case err := <-errCh:
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- case <-time.After(10 * time.Second):
- t.Fatal("timeout waiting for server shutdown")
- }
-}
-
-func TestRunWithSignal_LogLevels(t *testing.T) {
- for _, level := range []string{"debug", "info", "warn", "error", "invalid"} {
- t.Run(level, func(t *testing.T) {
- tmpDir := t.TempDir()
- t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
- t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
- t.Setenv("PORT", "0")
- t.Setenv("LOG_LEVEL", level)
-
- sigCh := make(chan os.Signal, 1)
- errCh := make(chan error, 1)
- go func() {
- errCh <- runWithSignal([]string{}, sigCh)
- }()
- sigCh <- syscall.SIGTERM
-
- select {
- case err := <-errCh:
- if level == "invalid" {
- if err == nil {
- t.Fatal("expected error for invalid LOG_LEVEL")
- }
- return
- }
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- case <-time.After(10 * time.Second):
- t.Fatal("timeout waiting for server shutdown")
- }
- })
- }
-}
-
-func TestRunWithSignal_InvalidDB(t *testing.T) {
- tmpDir := t.TempDir()
- t.Setenv("DB_PATH", filepath.Join(tmpDir, "readonly"))
- if err := os.MkdirAll(filepath.Join(tmpDir, "readonly"), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
- t.Setenv("PORT", "0")
-
- err := runWithSignal([]string{}, nil)
- if err == nil {
- t.Fatal("expected error for invalid DB_PATH")
- }
-}
-
-func TestRunWithSignal_ServerErrorPath(t *testing.T) {
- tmpDir := t.TempDir()
- t.Setenv("DB_PATH", filepath.Join(tmpDir, "test.db"))
- t.Setenv("MEDIA_ROOT", filepath.Join(tmpDir, "media"))
- // Port 1 is privileged and should fail on non-root Linux.
- t.Setenv("PORT", "1")
-
- // No signal channel; we expect the server start to fail quickly.
- err := runWithSignal([]string{}, nil)
- if err == nil {
- t.Fatal("expected error when server cannot bind privileged port")
- }
-}
-
-func TestWireDeps_DoesNotStartBackgroundWorkers(t *testing.T) {
- // wireDeps must only construct dependencies; it must not start any background goroutines.
- tmpDir := t.TempDir()
- dbPath := filepath.Join(tmpDir, "test.db")
- mediaRoot := filepath.Join(tmpDir, "media")
- if err := os.MkdirAll(mediaRoot, 0o755); err != nil {
- t.Fatalf("mkdir media root: %v", err)
- }
-
- store, err := repository.Open(dbPath)
- if err != nil {
- t.Fatalf("open db: %v", err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Logf("close db: %v", err)
- }
- }()
-
- cfg := &internal.Config{
- SessionTimeoutHours: 1,
- GCIntervalMinutes: 1,
- PodcastCheckMinutes: 1,
- MediaRoot: mediaRoot,
- }
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- 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")
- }
-
- // 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()
-}
-
-func TestStartBackgroundWorkers_StartsAndStops(t *testing.T) {
- // startBackgroundWorkers should launch background goroutines that exit
- // cleanly when the app context is cancelled.
- tmpDir := t.TempDir()
- dbPath := filepath.Join(tmpDir, "test.db")
- mediaRoot := filepath.Join(tmpDir, "media")
- if err := os.MkdirAll(mediaRoot, 0o755); err != nil {
- t.Fatalf("mkdir media root: %v", err)
- }
-
- store, err := repository.Open(dbPath)
- if err != nil {
- t.Fatalf("open db: %v", err)
- }
- defer func() {
- if err := store.Close(); err != nil {
- t.Logf("close db: %v", err)
- }
- }()
-
- cfg := &internal.Config{
- SessionTimeoutHours: 1,
- GCIntervalMinutes: 1,
- PodcastCheckMinutes: 1,
- MediaRoot: mediaRoot,
- }
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- deps := wireDeps(cfg, store, logger, ctx)
- workersStarted := make(chan struct{}, 1)
- deps.workersStarted = workersStarted
- startBackgroundWorkers(deps)
-
- select {
- case <-workersStarted:
- case <-time.After(time.Second):
- t.Fatal("timeout waiting for background workers to start")
- }
-
- // Cancel the app context; workers should exit.
- cancel()
-
- // Stop the GC worker explicitly (safe and idempotent).
- 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.
-}
-
-func TestStartBackgroundWorkers_NilDepsPanics(t *testing.T) {
- // Verify defensive behaviour: passing a nil pointer should panic quickly
- // 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")
- }
- }()
- startBackgroundWorkers(nil)
-}