diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-07 00:17:05 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-07 00:17:05 +0300 |
| commit | e1bea8d74391a20201327299481550bf94766236 (patch) | |
| tree | 49cf5c625815f3f3532a6b432a3670f812c79593 /internal/service | |
| parent | 3b195a54b4c844f23415092b4293ff10e4a72dff (diff) | |
task 31: introduce media streamer service
Diffstat (limited to 'internal/service')
| -rw-r--r-- | internal/service/service.go | 21 | ||||
| -rw-r--r-- | internal/service/streamer.go | 66 | ||||
| -rw-r--r-- | internal/service/streamer_test.go | 122 |
3 files changed, 209 insertions, 0 deletions
diff --git a/internal/service/service.go b/internal/service/service.go index 16cd5f1..3cba3d3 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -221,6 +221,14 @@ type ProgressService interface { UpdateProgress(ctx context.Context, sessionID string, userID, mediaID int64, position float64) error } +// MediaStreamer prepares authorized file results for HTTP streaming. +type MediaStreamer interface { + // Open opens a file result and returns the headers/reader needed by the API. + Open(ctx context.Context, file *FileResult, attachment bool) (*StreamResult, error) + // Remux writes a remuxed stream to w for results where StreamResult.Remuxed is true. + Remux(ctx context.Context, stream *StreamResult, w io.Writer) error +} + // PermissionsMatrix is the shape returned by ListPermissions. type PermissionsMatrix struct { Sets []model.Set `json:"sets"` @@ -236,6 +244,19 @@ type FileResult struct { Duration float64 // DB-stored duration (seconds), used for remuxed streams. } +// StreamResult contains an opened file and metadata needed for HTTP streaming. +type StreamResult struct { + File io.ReadSeekCloser + Path string + FileName string + Size int64 + ModTime time.Time + ContentType string + Attachment bool + Remuxed bool + Duration float64 +} + // MediaDetail combines media with related data. type MediaDetail struct { Media *model.Media `json:"media"` diff --git a/internal/service/streamer.go b/internal/service/streamer.go new file mode 100644 index 0000000..e04293e --- /dev/null +++ b/internal/service/streamer.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + "codeberg.org/snonux/player/internal/mediatype" + "codeberg.org/snonux/player/internal/probe" +) + +type mediaStreamer struct { + remuxer probe.Remuxer +} + +// NewMediaStreamer creates the default service for preparing media files for HTTP streaming. +func NewMediaStreamer(remuxer probe.Remuxer) MediaStreamer { + return &mediaStreamer{remuxer: remuxer} +} + +func (s *mediaStreamer) Open(ctx context.Context, file *FileResult, attachment bool) (*StreamResult, error) { + if file == nil { + return nil, ErrNotFound + } + + f, err := os.Open(file.Path) + if err != nil { + return nil, fmt.Errorf("%w: open stream file: %v", ErrNotFound, err) + } + + stat, err := f.Stat() + if err != nil { + f.Close() + return nil, fmt.Errorf("%w: stat stream file: %v", ErrNotFound, err) + } + + remuxed := !attachment && s.remuxer != nil && probe.LooksLikeMPEGTS(file.Path) + contentType := mediatype.MIMETypeForExt(file.FileName) + if remuxed { + contentType = "video/mp4" + } + + return &StreamResult{ + File: f, + Path: file.Path, + FileName: file.FileName, + Size: stat.Size(), + ModTime: stat.ModTime(), + ContentType: contentType, + Attachment: attachment, + Remuxed: remuxed, + Duration: file.Duration, + }, nil +} + +func (s *mediaStreamer) Remux(ctx context.Context, stream *StreamResult, w io.Writer) error { + if stream == nil { + return ErrNotFound + } + if s.remuxer == nil { + return errors.New("remuxer not configured") + } + return s.remuxer.Remux(ctx, stream.Path, w) +} diff --git a/internal/service/streamer_test.go b/internal/service/streamer_test.go new file mode 100644 index 0000000..ea0eec4 --- /dev/null +++ b/internal/service/streamer_test.go @@ -0,0 +1,122 @@ +package service + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "testing" +) + +type mockRemuxer struct { + path string + data string + err error +} + +func (m *mockRemuxer) Remux(ctx context.Context, inputPath string, w io.Writer) error { + m.path = inputPath + if m.data != "" { + _, _ = io.WriteString(w, m.data) + } + return m.err +} + +func TestMediaStreamerOpenDirect(t *testing.T) { + path := filepath.Join(t.TempDir(), "clip.mp4") + if err := os.WriteFile(path, []byte("mp4"), 0o644); err != nil { + t.Fatal(err) + } + + stream, err := NewMediaStreamer(nil).Open(context.Background(), &FileResult{ + Path: path, + FileName: "clip.mp4", + }, false) + if err != nil { + t.Fatalf("open stream: %v", err) + } + defer stream.File.Close() + + if stream.Remuxed { + t.Fatal("expected direct stream") + } + if stream.ContentType != "video/mp4" { + t.Fatalf("expected video/mp4, got %q", stream.ContentType) + } + if stream.Size != 3 { + t.Fatalf("expected size 3, got %d", stream.Size) + } +} + +func TestMediaStreamerOpenAttachmentSkipsRemux(t *testing.T) { + path := writeMPEGTSFile(t) + stream, err := NewMediaStreamer(&mockRemuxer{}).Open(context.Background(), &FileResult{ + Path: path, + FileName: "clip.ts", + }, true) + if err != nil { + t.Fatalf("open stream: %v", err) + } + defer stream.File.Close() + + if stream.Remuxed { + t.Fatal("expected attachment to skip remux") + } + if !stream.Attachment { + t.Fatal("expected attachment flag") + } +} + +func TestMediaStreamerOpenRemuxedMPEGTS(t *testing.T) { + path := writeMPEGTSFile(t) + stream, err := NewMediaStreamer(&mockRemuxer{}).Open(context.Background(), &FileResult{ + Path: path, + FileName: "mislabelled.mp4", + Duration: 42, + }, false) + if err != nil { + t.Fatalf("open stream: %v", err) + } + defer stream.File.Close() + + if !stream.Remuxed { + t.Fatal("expected remuxed stream") + } + if stream.ContentType != "video/mp4" { + t.Fatalf("expected video/mp4, got %q", stream.ContentType) + } + if stream.Duration != 42 { + t.Fatalf("expected duration 42, got %f", stream.Duration) + } +} + +func TestMediaStreamerRemux(t *testing.T) { + remuxer := &mockRemuxer{data: "remuxed"} + streamer := NewMediaStreamer(remuxer) + var out bytes.Buffer + + err := streamer.Remux(context.Background(), &StreamResult{Path: "/media/input.ts"}, &out) + if err != nil { + t.Fatalf("remux: %v", err) + } + if remuxer.path != "/media/input.ts" { + t.Fatalf("expected remux path, got %q", remuxer.path) + } + if out.String() != "remuxed" { + t.Fatalf("expected remuxed output, got %q", out.String()) + } +} + +func writeMPEGTSFile(t *testing.T) string { + t.Helper() + ts := make([]byte, 188*5) + for i := 0; i < len(ts); i += 188 { + ts[i] = 0x47 + } + path := filepath.Join(t.TempDir(), "clip.ts") + if err := os.WriteFile(path, ts, 0o644); err != nil { + t.Fatal(err) + } + return path +} |
