summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-01 23:23:13 +0300
committerPaul Buetow <paul@buetow.org>2026-05-01 23:23:13 +0300
commit4dd62a8b61c56ef28f5f0cab58b8c2da94806717 (patch)
treed138502ca018aa324113fe64104a9abde70eda7d /internal/api
parent36bf8b9f1f1adf99aebd5dea37ff8cf6f3e847cd (diff)
api: remux MPEG-TS files to MP4 on the fly
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/handlers.go80
-rw-r--r--internal/api/handlers_more_test.go31
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)