summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-03 20:18:26 +0300
committerPaul Buetow <paul@buetow.org>2026-05-03 20:18:26 +0300
commitdeba845a03de659b490452e00f7b7d501ba1fdb2 (patch)
treeeb719c2a629316fc166961560942508a4542d6d6
parentdf8f3713abee8b5ec53b5751032200b53673e285 (diff)
refactor(api): extract Remuxer interface and move ffmpeg/MPEG-TS/mime logic to internal/probe
Move LooksLikeMPEGTS, MimeTypeForFilename and the ffmpeg remux path out of the api transport layer into a new internal/probe/remux.go package. This removes os/exec and mime tables from the api layer, satisfying the DIP principle. - New probe.Remuxer interface with FFRemuxer implementation - Server depends on probe.Remuxer, injected via NewServer - Updated cmd/mediaplayer wiring and all test helpers - All tests pass: go test ./... -race -cover
-rw-r--r--cmd/mediaplayer/main.go3
-rw-r--r--internal/api/handlers.go124
-rw-r--r--internal/api/handlers_more_test.go5
-rw-r--r--internal/api/handlers_test.go8
-rw-r--r--internal/api/server.go4
-rw-r--r--internal/probe/remux.go147
6 files changed, 167 insertions, 124 deletions
diff --git a/cmd/mediaplayer/main.go b/cmd/mediaplayer/main.go
index f7c5928..7ecf052 100644
--- a/cmd/mediaplayer/main.go
+++ b/cmd/mediaplayer/main.go
@@ -96,7 +96,8 @@ func runWithSignal(args []string, sigCh <-chan os.Signal) error {
defer gcWorker.Stop()
staticFS := http.Dir("web")
- server := api.NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, staticFS)
+ remuxer := probe.NewFFRemuxer()
+ server := api.NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, staticFS, remuxer)
gs := api.NewGracefulServer(server, cfg)
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 8afcd8f..911b694 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -5,15 +5,12 @@ import (
"fmt"
"io"
"log/slog"
- "mime"
"net/http"
"os"
- "os/exec"
- "path/filepath"
"strconv"
- "strings"
"codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/probe"
"codeberg.org/snonux/player/internal/service"
)
@@ -127,7 +124,7 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se
return
}
- if !attachment && looksLikeMPEGTS(res.Path) {
+ if !attachment && probe.LooksLikeMPEGTS(res.Path) {
s.serveRemuxedMP4(w, r, res, stat.Size())
return
}
@@ -138,130 +135,17 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se
}
// Set Content-Type so browsers know how to decode the file without
// needing to sniff, which avoids buffering delays during streaming.
- w.Header().Set("Content-Type", mimeTypeForFilename(res.FileName))
+ w.Header().Set("Content-Type", probe.MimeTypeForFilename(res.FileName))
w.Header().Set("Accept-Ranges", "bytes")
fmt.Printf("[api] stream file=%s size=%d bytes range=%s\n", res.FileName, stat.Size(), r.Header.Get("Range"))
http.ServeContent(w, r, res.FileName, stat.ModTime(), f)
}
func (s *Server) serveRemuxedMP4(w http.ResponseWriter, r *http.Request, res *service.FileResult, size int64) {
- cmd := exec.CommandContext(
- r.Context(),
- "ffmpeg",
- "-hide_banner",
- "-loglevel", "error",
- "-i", res.Path,
- "-map", "0:v:0?",
- "-map", "0:a:0?",
- "-dn",
- "-sn",
- "-c", "copy",
- "-bsf:a", "aac_adtstoasc",
- "-movflags", "frag_keyframe+empty_moov+default_base_moof",
- "-f", "mp4",
- "pipe:1",
- )
- cmd.Stderr = os.Stderr
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- http.Error(w, "stream setup failed", http.StatusInternalServerError)
- return
- }
- if err := cmd.Start(); err != nil {
- http.Error(w, "stream setup failed", http.StatusInternalServerError)
- return
- }
-
w.Header().Set("Content-Type", "video/mp4")
w.Header().Set("Cache-Control", "no-store")
fmt.Printf("[api] remux stream file=%s size=%d bytes range=%s\n", res.FileName, size, r.Header.Get("Range"))
- if _, err := io.Copy(w, stdout); err != nil && r.Context().Err() == nil {
- slog.Error("copy remuxed media", "file", res.FileName, "err", err)
- }
- if err := cmd.Wait(); err != nil && r.Context().Err() == nil {
+ if err := s.remuxer.Remux(r.Context(), res.Path, w); err != nil {
slog.Error("remux media", "file", res.FileName, "err", err)
}
}
-
-func looksLikeMPEGTS(path string) bool {
- f, err := os.Open(path)
- if err != nil {
- return false
- }
- defer f.Close()
-
- buf := make([]byte, 188*5)
- n, err := f.Read(buf)
- if err != nil && err != io.EOF {
- return false
- }
- buf = buf[:n]
- return hasMPEGTSsync(buf, 188) || hasMPEGTSsync(buf, 192)
-}
-
-func hasMPEGTSsync(buf []byte, packetSize int) bool {
- if len(buf) < packetSize*3+1 {
- return false
- }
- for offset := 0; offset < packetSize; offset++ {
- matches := 0
- for pos := offset; pos < len(buf); pos += packetSize {
- if buf[pos] != 0x47 {
- break
- }
- matches++
- if matches >= 3 {
- return true
- }
- }
- }
- return false
-}
-
-// mimeTypeForFilename returns an HTTP Content-Type based on the file extension.
-func mimeTypeForFilename(name string) string {
- ext := strings.ToLower(filepath.Ext(name))
- t := mime.TypeByExtension(ext)
- if t != "" {
- return t
- }
- switch ext {
- case ".mp4", ".m4v":
- return "video/mp4"
- case ".mkv":
- return "video/x-matroska"
- case ".avi":
- return "video/x-msvideo"
- case ".mov":
- return "video/quicktime"
- case ".webm":
- return "video/webm"
- case ".mp3":
- return "audio/mpeg"
- case ".flac":
- return "audio/flac"
- case ".wav":
- return "audio/wav"
- case ".aac", ".m4a":
- return "audio/mp4"
- case ".ogg", ".opus":
- return "audio/ogg"
- case ".m4b":
- return "audio/x-m4b"
- case ".jpg", ".jpeg":
- return "image/jpeg"
- case ".png":
- return "image/png"
- case ".gif":
- return "image/gif"
- case ".webp":
- return "image/webp"
- case ".bmp":
- return "image/bmp"
- case ".avif":
- return "image/avif"
- case ".svg":
- return "image/svg+xml"
- }
- return "application/octet-stream"
-}
diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go
index fa4b95b..47adfe7 100644
--- a/internal/api/handlers_more_test.go
+++ b/internal/api/handlers_more_test.go
@@ -21,6 +21,7 @@ import (
"codeberg.org/snonux/player/internal/auth"
"codeberg.org/snonux/player/internal/clock"
"codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/probe"
"codeberg.org/snonux/player/internal/repository"
"codeberg.org/snonux/player/internal/service"
)
@@ -626,7 +627,7 @@ func TestLooksLikeMPEGTS(t *testing.T) {
if err := os.WriteFile(tsPath, ts, 0o644); err != nil {
t.Fatal(err)
}
- if !looksLikeMPEGTS(tsPath) {
+ if !probe.LooksLikeMPEGTS(tsPath) {
t.Fatal("expected MPEG-TS sync bytes to be detected")
}
@@ -634,7 +635,7 @@ func TestLooksLikeMPEGTS(t *testing.T) {
if err := os.WriteFile(mp4Path, []byte("\x00\x00\x00\x18ftypmp42"), 0o644); err != nil {
t.Fatal(err)
}
- if looksLikeMPEGTS(mp4Path) {
+ if probe.LooksLikeMPEGTS(mp4Path) {
t.Fatal("did not expect MP4 header to be detected as MPEG-TS")
}
}
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
index 0003b88..5d188fa 100644
--- a/internal/api/handlers_test.go
+++ b/internal/api/handlers_test.go
@@ -17,6 +17,7 @@ import (
"codeberg.org/snonux/player/internal/auth"
"codeberg.org/snonux/player/internal/clock"
"codeberg.org/snonux/player/internal/model"
+ "codeberg.org/snonux/player/internal/probe"
"codeberg.org/snonux/player/internal/repository"
"codeberg.org/snonux/player/internal/service"
)
@@ -37,6 +38,7 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm
mediaSvc service.MediaService, adminSvc service.AdminService, progressSvc service.ProgressService,
authSvc service.AuthService,
fs http.FileSystem,
+ remuxer ...probe.Remuxer,
) *Server {
t.Helper()
if fs == nil {
@@ -47,7 +49,11 @@ func newTestServer(t *testing.T, store repository.Store, hasher auth.Hasher, sm
"share.html": "share",
})
}
- return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, fs)
+ var rem probe.Remuxer
+ if len(remuxer) > 0 {
+ rem = remuxer[0]
+ }
+ return NewServer(store, hasher, sm, cfg, mediaSvc, adminSvc, progressSvc, authSvc, fs, rem)
}
func addSessionCookie(t *testing.T, store repository.Store, sm *auth.SessionManager, userID int64) *http.Cookie {
diff --git a/internal/api/server.go b/internal/api/server.go
index 11518df..e5fdd8d 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -8,6 +8,7 @@ import (
"codeberg.org/snonux/player/internal"
"codeberg.org/snonux/player/internal/auth"
+ "codeberg.org/snonux/player/internal/probe"
"codeberg.org/snonux/player/internal/repository"
"codeberg.org/snonux/player/internal/service"
)
@@ -24,6 +25,7 @@ type Server struct {
progressSvc service.ProgressService
authSvc service.AuthService
staticFS http.FileSystem
+ remuxer probe.Remuxer
mw *Middleware
}
@@ -39,6 +41,7 @@ func NewServer(
progressSvc service.ProgressService,
authSvc service.AuthService,
staticFS http.FileSystem,
+ remuxer probe.Remuxer,
) *Server {
if staticFS == nil {
staticFS = http.Dir("web")
@@ -54,6 +57,7 @@ func NewServer(
progressSvc: progressSvc,
authSvc: authSvc,
staticFS: staticFS,
+ remuxer: remuxer,
mw: NewMiddleware(store, sm),
}
s.routes()
diff --git a/internal/probe/remux.go b/internal/probe/remux.go
new file mode 100644
index 0000000..594e1e0
--- /dev/null
+++ b/internal/probe/remux.go
@@ -0,0 +1,147 @@
+package probe
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// Remuxer remuxes media on-the-fly to a browser-friendly container.
+type Remuxer interface {
+ Remux(ctx context.Context, inputPath string, w io.Writer) error
+}
+
+// FFRemuxer implements Remuxer using ffmpeg.
+type FFRemuxer struct{}
+
+// NewFFRemuxer creates a new FFRemuxer.
+func NewFFRemuxer() *FFRemuxer {
+ return &FFRemuxer{}
+}
+
+// Remux runs ffmpeg to copy video/audio streams into a fragmented MP4
+// suitable for streaming to a browser.
+func (f *FFRemuxer) Remux(ctx context.Context, inputPath string, w io.Writer) error {
+ cmd := exec.CommandContext(
+ ctx,
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel", "error",
+ "-i", inputPath,
+ "-map", "0:v:0?",
+ "-map", "0:a:0?",
+ "-dn",
+ "-sn",
+ "-c", "copy",
+ "-bsf:a", "aac_adtstoasc",
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
+ "-f", "mp4",
+ "pipe:1",
+ )
+ cmd.Stderr = os.Stderr
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("remux stdout pipe: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("remux start: %w", err)
+ }
+ if _, err := io.Copy(w, stdout); err != nil && ctx.Err() == nil {
+ slog.Error("copy remuxed media", "file", inputPath, "err", err)
+ }
+ if err := cmd.Wait(); err != nil && ctx.Err() == nil {
+ return fmt.Errorf("remux wait: %w", err)
+ }
+ return nil
+}
+
+// LooksLikeMPEGTS inspects the first bytes of a file for MPEG-TS sync
+// markers (0x47 at 188- or 192-byte intervals).
+func LooksLikeMPEGTS(path string) bool {
+ f, err := os.Open(path)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+
+ buf := make([]byte, 188*5)
+ n, err := f.Read(buf)
+ if err != nil && err != io.EOF {
+ return false
+ }
+ buf = buf[:n]
+ return hasMPEGTSsync(buf, 188) || hasMPEGTSsync(buf, 192)
+}
+
+func hasMPEGTSsync(buf []byte, packetSize int) bool {
+ if len(buf) < packetSize*3+1 {
+ return false
+ }
+ for offset := 0; offset < packetSize; offset++ {
+ matches := 0
+ for pos := offset; pos < len(buf); pos += packetSize {
+ if buf[pos] != 0x47 {
+ break
+ }
+ matches++
+ if matches >= 3 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// MimeTypeForFilename returns an HTTP Content-Type based on the file extension.
+func MimeTypeForFilename(name string) string {
+ ext := strings.ToLower(filepath.Ext(name))
+ t := mime.TypeByExtension(ext)
+ if t != "" {
+ return t
+ }
+ switch ext {
+ case ".mp4", ".m4v":
+ return "video/mp4"
+ case ".mkv":
+ return "video/x-matroska"
+ case ".avi":
+ return "video/x-msvideo"
+ case ".mov":
+ return "video/quicktime"
+ case ".webm":
+ return "video/webm"
+ case ".mp3":
+ return "audio/mpeg"
+ case ".flac":
+ return "audio/flac"
+ case ".wav":
+ return "audio/wav"
+ case ".aac", ".m4a":
+ return "audio/mp4"
+ case ".ogg", ".opus":
+ return "audio/ogg"
+ case ".m4b":
+ return "audio/x-m4b"
+ case ".jpg", ".jpeg":
+ return "image/jpeg"
+ case ".png":
+ return "image/png"
+ case ".gif":
+ return "image/gif"
+ case ".webp":
+ return "image/webp"
+ case ".bmp":
+ return "image/bmp"
+ case ".avif":
+ return "image/avif"
+ case ".svg":
+ return "image/svg+xml"
+ }
+ return "application/octet-stream"
+}