summaryrefslogtreecommitdiff
path: root/internal/service
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-07 00:17:05 +0300
committerPaul Buetow <paul@buetow.org>2026-05-07 00:17:05 +0300
commite1bea8d74391a20201327299481550bf94766236 (patch)
tree49cf5c625815f3f3532a6b432a3670f812c79593 /internal/service
parent3b195a54b4c844f23415092b4293ff10e4a72dff (diff)
task 31: introduce media streamer service
Diffstat (limited to 'internal/service')
-rw-r--r--internal/service/service.go21
-rw-r--r--internal/service/streamer.go66
-rw-r--r--internal/service/streamer_test.go122
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
+}