diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-01 23:23:13 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-01 23:23:13 +0300 |
| commit | 4dd62a8b61c56ef28f5f0cab58b8c2da94806717 (patch) | |
| tree | d138502ca018aa324113fe64104a9abde70eda7d /internal/api | |
| parent | 36bf8b9f1f1adf99aebd5dea37ff8cf6f3e847cd (diff) | |
api: remux MPEG-TS files to MP4 on the fly
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/handlers.go | 80 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 31 |
2 files changed, 107 insertions, 4 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 8573857..41c4197 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -8,6 +8,7 @@ import ( "mime" "net/http" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -122,6 +123,11 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se return } + if !attachment && looksLikeMPEGTS(res.Path) { + s.serveRemuxedMP4(w, r, res, stat.Size()) + return + } + if attachment { disp := fmt.Sprintf("attachment; filename=%q", res.FileName) w.Header().Set("Content-Disposition", disp) @@ -134,6 +140,80 @@ func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *se 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 { + 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)) diff --git a/internal/api/handlers_more_test.go b/internal/api/handlers_more_test.go index a4fd3c4..8a8303d 100644 --- a/internal/api/handlers_more_test.go +++ b/internal/api/handlers_more_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "net/url" "os" + "path/filepath" "strings" "testing" "time" @@ -616,6 +617,28 @@ func TestServer_Stream(t *testing.T) { } } +func TestLooksLikeMPEGTS(t *testing.T) { + ts := make([]byte, 188*5) + for i := 0; i < len(ts); i += 188 { + ts[i] = 0x47 + } + tsPath := filepath.Join(t.TempDir(), "mislabelled.mp4") + if err := os.WriteFile(tsPath, ts, 0o644); err != nil { + t.Fatal(err) + } + if !looksLikeMPEGTS(tsPath) { + t.Fatal("expected MPEG-TS sync bytes to be detected") + } + + mp4Path := filepath.Join(t.TempDir(), "real.mp4") + if err := os.WriteFile(mp4Path, []byte("\x00\x00\x00\x18ftypmp42"), 0o644); err != nil { + t.Fatal(err) + } + if looksLikeMPEGTS(mp4Path) { + t.Fatal("did not expect MP4 header to be detected as MPEG-TS") + } +} + func TestServer_Download(t *testing.T) { path := makeTempFile(t, "filedata") store := buildSessionStore(1) @@ -910,14 +933,14 @@ func TestServer_RevokeShare(t *testing.T) { func TestServer_SharePage(t *testing.T) { cfg := &internal.Config{SessionTimeoutHours: 24} fs := newTestFS(map[string]string{ - "index.html": "index", - "login.html": "login", - "share.html": "<html>share</html>", + "index.html": "index", + "login.html": "login", + "share.html": "<html>share</html>", "bootstrap.html": "bootstrap", }) t.Run("nil service", func(t *testing.T) { - srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil, fs) + srv := newTestServer(t, buildSessionStore(1), nil, nil, cfg, nil, nil, nil, nil, fs) req := httptest.NewRequest(http.MethodGet, "/s/abc", nil) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) |
