diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-03 20:18:26 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-03 20:18:26 +0300 |
| commit | deba845a03de659b490452e00f7b7d501ba1fdb2 (patch) | |
| tree | eb719c2a629316fc166961560942508a4542d6d6 | |
| parent | df8f3713abee8b5ec53b5751032200b53673e285 (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.go | 3 | ||||
| -rw-r--r-- | internal/api/handlers.go | 124 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 5 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 8 | ||||
| -rw-r--r-- | internal/api/server.go | 4 | ||||
| -rw-r--r-- | internal/probe/remux.go | 147 |
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" +} |
