summaryrefslogtreecommitdiff
path: root/internal/probe
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
committerPaul Buetow <paul@buetow.org>2026-05-17 15:25:52 +0300
commit914bd7cd6aa14e839332a98d91c30b19865b0cf2 (patch)
tree02b11537237a204048589e14ed24938b805e42f7 /internal/probe
parent3b24f0e1be832584d6550e8cc3e5d24329f66f90 (diff)
Restructure repo: move Go server into player-server/
Diffstat (limited to 'internal/probe')
-rw-r--r--internal/probe/image_test.go232
-rw-r--r--internal/probe/probe.go242
-rw-r--r--internal/probe/probe_test.go126
-rw-r--r--internal/probe/remux.go119
-rw-r--r--internal/probe/remux_test.go85
-rw-r--r--internal/probe/testdata/golden.json14
6 files changed, 0 insertions, 818 deletions
diff --git a/internal/probe/image_test.go b/internal/probe/image_test.go
deleted file mode 100644
index 77677a3..0000000
--- a/internal/probe/image_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package probe
-
-import (
- "context"
- "os"
- "os/exec"
- "path/filepath"
- "testing"
-
- "codeberg.org/snonux/player/internal/mediatype"
- "codeberg.org/snonux/player/internal/model"
-)
-
-func generateEXIFImage(t *testing.T, dst string) {
- t.Helper()
- if _, err := exec.LookPath("convert"); err != nil {
- t.Skip("ImageMagick convert not available")
- }
- if _, err := exec.LookPath("exiv2"); err != nil {
- t.Skip("exiv2 not available")
- }
-
- cmd := exec.Command("convert", "-size", "2x2", "xc:red", dst)
- if err := cmd.Run(); err != nil {
- t.Fatalf("convert failed: %v", err)
- }
-
- exiv := exec.Command("exiv2",
- "-Mset Exif.Image.Make Canon",
- "-Mset Exif.Image.Model EOS 5D",
- "-Mset Exif.Photo.LensModel EF 50mm f/1.8",
- "-Mset Exif.Photo.DateTimeOriginal 2024:01:01 12:00:00",
- "-Mset Exif.Photo.ISOSpeedRatings 400",
- "-Mset Exif.Photo.FNumber 18/10",
- "-Mset Exif.Photo.ExposureTime 1/250",
- "-Mset Exif.Photo.FocalLength 50/1",
- dst,
- )
- if err := exiv.Run(); err != nil {
- t.Fatalf("exiv2 failed: %v", err)
- }
-}
-
-func generateVideo(t *testing.T, dst string) {
- t.Helper()
- if _, err := exec.LookPath("ffmpeg"); err != nil {
- t.Skip("ffmpeg not available")
- }
- cmd := exec.Command("ffmpeg", "-f", "lavfi", "-i", "color=c=red:size=2x2:d=1",
- "-pix_fmt", "yuv420p", "-c:v", "libx264", "-an", "-y", dst)
- if err := cmd.Run(); err != nil {
- t.Fatalf("ffmpeg failed: %v", err)
- }
-}
-
-func TestIsImagePath(t *testing.T) {
- cases := []struct {
- path string
- want bool
- }{
- {"photo.jpg", true},
- {"photo.jpeg", true},
- {"image.png", true},
- {"anim.gif", true},
- {"img.webp", true},
- {"legacy.bmp", true},
- {"modern.avif", true},
- {"vector.svg", true},
- {"video.mp4", false},
- {"audio.mp3", false},
- {"archive.tar.gz", false},
- {"no_ext", false},
- }
- for _, c := range cases {
- t.Run(c.path, func(t *testing.T) {
- if got := mediatype.IsImageExt(c.path); got != c.want {
- t.Errorf("IsImageExt(%q) = %v, want %v", c.path, got, c.want)
- }
- })
- }
-}
-
-func TestIsImagePath_CaseInsensitive(t *testing.T) {
- if !mediatype.IsImageExt("UPPER.JPG") {
- t.Error("expected true for uppercase extension")
- }
- if !mediatype.IsImageExt("Mixed.PnG") {
- t.Error("expected true for mixed-case extension")
- }
-}
-
-func TestExtractEXIF(t *testing.T) {
- tmpDir := t.TempDir()
- imgPath := filepath.Join(tmpDir, "test.jpg")
- generateEXIFImage(t, imgPath)
-
- meta := &model.Metadata{}
- extractEXIF(imgPath, meta)
-
- if meta.EXIFCamera != "Canon EOS 5D" {
- t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera)
- }
- if meta.EXIFLens != "EF 50mm f/1.8" {
- t.Errorf("EXIFLens = %q, want \"EF 50mm f/1.8\"", meta.EXIFLens)
- }
- if meta.EXIFDate != "2024:01:01 12:00:00" {
- t.Errorf("EXIFDate = %q, want \"2024:01:01 12:00:00\"", meta.EXIFDate)
- }
- if meta.EXIFISO != "400" {
- t.Errorf("EXIFISO = %q, want \"400\"", meta.EXIFISO)
- }
- if meta.EXIFFNumber != "f/1.8" {
- t.Errorf("EXIFFNumber = %q, want \"f/1.8\"", meta.EXIFFNumber)
- }
- if meta.EXIFExposure != "1/250 s" {
- t.Errorf("EXIFExposure = %q, want \"1/250 s\"", meta.EXIFExposure)
- }
- if meta.EXIFFocalLength != "50.0 mm" {
- t.Errorf("EXIFFocalLength = %q, want \"50.0 mm\"", meta.EXIFFocalLength)
- }
-}
-
-func TestExtractEXIF_NonExistentFile(t *testing.T) {
- meta := &model.Metadata{}
- extractEXIF("/does/not/exist.jpg", meta)
- // Should not panic and leave fields empty.
- if meta.EXIFCamera != "" {
- t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera)
- }
-}
-
-func TestExtractEXIF_FileWithoutEXIF(t *testing.T) {
- t.Helper()
- tmpDir := t.TempDir()
- imgPath := filepath.Join(tmpDir, "noexif.jpg")
- if _, err := exec.LookPath("convert"); err != nil {
- t.Skip("ImageMagick convert not available")
- }
- cmd := exec.Command("convert", "-size", "2x2", "xc:blue", imgPath)
- if err := cmd.Run(); err != nil {
- t.Fatalf("convert failed: %v", err)
- }
-
- meta := &model.Metadata{}
- extractEXIF(imgPath, meta)
- if meta.EXIFCamera != "" {
- t.Errorf("expected empty EXIFCamera, got %q", meta.EXIFCamera)
- }
-}
-
-func TestFFProber_ProbeImage(t *testing.T) {
- tmpDir := t.TempDir()
- imgPath := filepath.Join(tmpDir, "photo.jpg")
- generateEXIFImage(t, imgPath)
-
- p := NewFFProber()
- ctx := context.Background()
- meta, err := p.Probe(ctx, imgPath)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if meta == nil {
- t.Fatal("expected non-nil metadata")
- }
- // Verify EXIF extraction happened.
- if meta.EXIFCamera != "Canon EOS 5D" {
- t.Errorf("EXIFCamera = %q, want \"Canon EOS 5D\"", meta.EXIFCamera)
- }
-}
-
-func TestFFProber_ProbeVideo(t *testing.T) {
- tmpDir := t.TempDir()
- vidPath := filepath.Join(tmpDir, "video.mp4")
- generateVideo(t, vidPath)
-
- p := NewFFProber()
- ctx := context.Background()
- meta, err := p.Probe(ctx, vidPath)
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if meta == nil {
- t.Fatal("expected non-nil metadata")
- }
- if meta.Codec == "" {
- t.Error("expected non-empty Codec for video")
- }
- if meta.Resolution == "" {
- t.Error("expected non-empty Resolution for video")
- }
- if meta.Duration <= 0 {
- t.Errorf("expected positive Duration, got %v", meta.Duration)
- }
-}
-
-func TestFFProber_ProbeNonExistent(t *testing.T) {
- p := NewFFProber()
- ctx := context.Background()
- _, err := p.Probe(ctx, "/nonexistent/file.mp4")
- if err == nil {
- t.Fatal("expected error for nonexistent file")
- }
-}
-
-func TestFFProber_ProbeEmptyFile(t *testing.T) {
- if _, err := exec.LookPath("ffprobe"); err != nil {
- t.Skip("ffprobe not available")
- }
- tmpDir := t.TempDir()
- emptyPath := filepath.Join(tmpDir, "empty.mp4")
- if err := os.WriteFile(emptyPath, []byte{}, 0o644); err != nil {
- t.Fatal(err)
- }
-
- p := NewFFProber()
- ctx := context.Background()
- _, err := p.Probe(ctx, emptyPath)
- if err == nil {
- t.Fatal("expected error for empty file")
- }
-}
-
-func TestParseFFprobeOutput_BitrateParsing(t *testing.T) {
- input := `{"format":{"duration":"60","bit_rate":"0"},"streams":[{"codec_name":"h264","width":640,"height":480,"codec_type":"video"}]}`
- meta, err := parseFFprobeOutput([]byte(input))
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if meta.Bitrate != 0 {
- t.Errorf("Bitrate = %d, want 0", meta.Bitrate)
- }
-}
diff --git a/internal/probe/probe.go b/internal/probe/probe.go
deleted file mode 100644
index 7fd6924..0000000
--- a/internal/probe/probe.go
+++ /dev/null
@@ -1,242 +0,0 @@
-// Package probe implements media metadata probing.
-package probe
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "strconv"
- "time"
-
- "codeberg.org/snonux/player/internal/mediatype"
- "codeberg.org/snonux/player/internal/model"
- "github.com/rwcarlsen/goexif/exif"
-)
-
-const (
- defaultProbeWaitDelay = 10 * time.Second
- defaultProbeMaxRetries = 2
- defaultProbeRetryDelay = 500 * time.Millisecond
-)
-
-// Prober extracts metadata from a media file.
-type Prober interface {
- Probe(ctx context.Context, path string) (*model.Metadata, error)
-}
-
-// FFProber wraps the ffprobe command-line tool.
-type FFProber struct {
- maxRetries int
- retryDelay time.Duration
- waitDelay time.Duration
-}
-
-var _ Prober = (*FFProber)(nil)
-
-// NewFFProber creates a new FFProber with bounded retries and a process wait delay.
-func NewFFProber() *FFProber {
- return &FFProber{
- maxRetries: defaultProbeMaxRetries,
- retryDelay: defaultProbeRetryDelay,
- waitDelay: defaultProbeWaitDelay,
- }
-}
-
-// Probe runs ffprobe against the given path with retries and parses the resulting JSON.
-func (f *FFProber) Probe(ctx context.Context, path string) (*model.Metadata, error) {
- var lastErr error
- attempts := f.maxRetries + 1
- if attempts <= 0 {
- attempts = 1
- }
-
- for i := 0; i < attempts; i++ {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
-
- meta, err := f.probeOnce(ctx, path)
- if err == nil {
- return meta, nil
- }
- lastErr = err
-
- // Don't retry on context cancellation.
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- break
- }
-
- if i < attempts-1 {
- delay := f.retryDelay * time.Duration(1<<i)
- const maxDelay = 30 * time.Second
- if delay > maxDelay {
- delay = maxDelay
- }
- select {
- case <-time.After(delay):
- case <-ctx.Done():
- return nil, ctx.Err()
- }
- }
- }
- return nil, lastErr
-}
-
-// probeOnce performs a single ffprobe invocation.
-func (f *FFProber) probeOnce(ctx context.Context, path string) (*model.Metadata, error) {
- cmd := exec.CommandContext(ctx, "ffprobe",
- "-v", "error",
- "-show_format",
- "-show_streams",
- "-of", "json",
- path,
- )
- cmd.WaitDelay = f.waitDelay
- out, err := cmd.Output()
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
- return nil, fmt.Errorf("ffprobe %s: %w: %s", path, err, string(exitErr.Stderr))
- }
- return nil, fmt.Errorf("ffprobe %s: %w", path, err)
- }
- meta, err := parseFFprobeOutput(out)
- if err != nil {
- return nil, err
- }
- // For images, also extract EXIF data.
- if mediatype.IsImageExt(path) {
- extractEXIF(path, meta)
- }
- return meta, nil
-}
-
-type ffprobeOutput struct {
- Format struct {
- Duration string `json:"duration"`
- BitRate string `json:"bit_rate"`
- } `json:"format"`
- Streams []struct {
- CodecName string `json:"codec_name"`
- Width int `json:"width"`
- Height int `json:"height"`
- CodecType string `json:"codec_type"`
- } `json:"streams"`
-}
-
-func parseFFprobeOutput(data []byte) (*model.Metadata, error) {
- var out ffprobeOutput
- if err := json.Unmarshal(data, &out); err != nil {
- return nil, fmt.Errorf("unmarshal ffprobe output: %w", err)
- }
-
- meta := &model.Metadata{}
- if out.Format.Duration != "" {
- if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil {
- meta.Duration = d
- }
- }
- if out.Format.BitRate != "" {
- if b, err := strconv.Atoi(out.Format.BitRate); err == nil {
- meta.Bitrate = b
- }
- }
-
- for _, s := range out.Streams {
- if s.CodecType == "video" {
- if meta.Codec == "" {
- meta.Codec = s.CodecName
- }
- if s.Width > 0 && s.Height > 0 {
- meta.Resolution = fmt.Sprintf("%dx%d", s.Width, s.Height)
- meta.Width = s.Width
- meta.Height = s.Height
- }
- break
- }
- }
-
- // Fallback to first stream codec if no video stream found.
- if meta.Codec == "" && len(out.Streams) > 0 {
- meta.Codec = out.Streams[0].CodecName
- }
-
- return meta, nil
-}
-
-func extractEXIF(path string, meta *model.Metadata) {
- f, err := os.Open(path)
- if err != nil {
- return
- }
- defer f.Close()
-
- x, err := exif.Decode(f)
- if err != nil {
- return
- }
-
- if v, err := x.Get(exif.Make); err == nil {
- if s, err := v.StringVal(); err == nil {
- meta.EXIFCamera = s
- }
- }
- if v, err := x.Get(exif.Model); err == nil {
- if s, err := v.StringVal(); err == nil {
- if meta.EXIFCamera != "" {
- meta.EXIFCamera = meta.EXIFCamera + " " + s
- } else {
- meta.EXIFCamera = s
- }
- }
- }
- if v, err := x.Get(exif.LensModel); err == nil {
- if s, err := v.StringVal(); err == nil {
- meta.EXIFLens = s
- }
- }
- if v, err := x.Get(exif.DateTimeOriginal); err == nil {
- if s, err := v.StringVal(); err == nil {
- meta.EXIFDate = s
- }
- }
- if v, err := x.Get(exif.ISOSpeedRatings); err == nil {
- if i, err := v.Int(0); err == nil {
- meta.EXIFISO = strconv.Itoa(i)
- }
- }
- if v, err := x.Get(exif.FNumber); err == nil {
- if r, err := v.Rat(0); err == nil {
- num := float64(r.Num().Int64())
- denom := float64(r.Denom().Int64())
- meta.EXIFFNumber = fmt.Sprintf("f/%.1f", num/denom)
- }
- }
- if v, err := x.Get(exif.ExposureTime); err == nil {
- if r, err := v.Rat(0); err == nil {
- meta.EXIFExposure = fmt.Sprintf("%s/%s s", r.Num().String(), r.Denom().String())
- }
- }
- if v, err := x.Get(exif.FocalLength); err == nil {
- if r, err := v.Rat(0); err == nil {
- num := float64(r.Num().Int64())
- denom := float64(r.Denom().Int64())
- meta.EXIFFocalLength = fmt.Sprintf("%.1f mm", num/denom)
- }
- }
-}
-
-// MockProber is a test fake for Prober.
-type MockProber struct {
- ProbeFunc func(ctx context.Context, path string) (*model.Metadata, error)
-}
-
-// Probe delegates to ProbeFunc or returns zero-value metadata.
-func (m *MockProber) Probe(ctx context.Context, path string) (*model.Metadata, error) {
- if m.ProbeFunc != nil {
- return m.ProbeFunc(ctx, path)
- }
- return &model.Metadata{}, nil
-}
diff --git a/internal/probe/probe_test.go b/internal/probe/probe_test.go
deleted file mode 100644
index 54493ca..0000000
--- a/internal/probe/probe_test.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package probe
-
-import (
- "context"
- "testing"
- "time"
-
- "codeberg.org/snonux/player/internal/model"
-)
-
-func TestParseFFprobeOutput(t *testing.T) {
- cases := []struct {
- name string
- input string
- want *model.Metadata
- wantErr bool
- }{
- {
- name: "video with all fields",
- input: `{
- "format": {"duration": "123.45", "bit_rate": "500000"},
- "streams": [
- {"codec_name": "h264", "width": 1920, "height": 1080, "codec_type": "video"},
- {"codec_name": "aac", "codec_type": "audio"}
- ]
- }`,
- want: &model.Metadata{
- Duration: 123.45,
- Codec: "h264",
- Resolution: "1920x1080",
- Bitrate: 500000,
- },
- },
- {
- name: "audio only no video stream",
- input: `{
- "format": {"duration": "200.1", "bit_rate": "128000"},
- "streams": [
- {"codec_name": "mp3", "codec_type": "audio"}
- ]
- }`,
- want: &model.Metadata{
- Duration: 200.1,
- Codec: "mp3",
- Bitrate: 128000,
- },
- },
- {
- name: "invalid json",
- input: `{bad json`,
- wantErr: true,
- },
- {
- name: "empty streams uses first stream fallback",
- input: `{
- "format": {},
- "streams": [
- {"codec_name": "vp9", "codec_type": "video", "width": 0, "height": 0}
- ]
- }`,
- want: &model.Metadata{Codec: "vp9"},
- },
- {
- name: "no format or streams",
- input: `{"format":{},"streams":[]}`,
- want: &model.Metadata{},
- },
- }
-
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got, err := parseFFprobeOutput([]byte(c.input))
- if (err != nil) != c.wantErr {
- t.Fatalf("unexpected error: %v", err)
- }
- if c.wantErr {
- return
- }
- if got.Duration != c.want.Duration {
- t.Errorf("Duration = %v, want %v", got.Duration, c.want.Duration)
- }
- if got.Codec != c.want.Codec {
- t.Errorf("Codec = %v, want %v", got.Codec, c.want.Codec)
- }
- if got.Resolution != c.want.Resolution {
- t.Errorf("Resolution = %v, want %v", got.Resolution, c.want.Resolution)
- }
- if got.Bitrate != c.want.Bitrate {
- t.Errorf("Bitrate = %v, want %v", got.Bitrate, c.want.Bitrate)
- }
- })
- }
-}
-
-func TestMockProber(t *testing.T) {
- ctx := context.Background()
- m := &MockProber{}
- meta, err := m.Probe(ctx, "any")
- if err != nil {
- t.Errorf("unexpected error: %v", err)
- }
- if meta == nil {
- t.Fatal("expected non-nil metadata")
- }
-
- m.ProbeFunc = func(context.Context, string) (*model.Metadata, error) {
- return &model.Metadata{Duration: 42}, nil
- }
- meta, err = m.Probe(ctx, "any")
- if err != nil {
- t.Fatalf("unexpected error: %v", err)
- }
- if meta.Duration != 42 {
- t.Errorf("duration = %v, want 42", meta.Duration)
- }
-}
-
-func TestFFProber_ContextCancellation(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
- defer cancel()
- p := NewFFProber()
- _, err := p.Probe(ctx, "nonexistent_path_should_fail")
- if err == nil {
- t.Fatal("expected error when context is cancelled or ffprobe fails")
- }
-}
diff --git a/internal/probe/remux.go b/internal/probe/remux.go
deleted file mode 100644
index f6becb8..0000000
--- a/internal/probe/remux.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package probe
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "os"
- "os/exec"
- "time"
-)
-
-// 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{}
-
-var _ Remuxer = (*FFRemuxer)(nil)
-
-// NewFFRemuxer creates a new FFRemuxer.
-func NewFFRemuxer() *FFRemuxer {
- return &FFRemuxer{}
-}
-
-const remuxWaitDelay = 10 * time.Second
-
-// 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
- cmd.WaitDelay = remuxWaitDelay
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- return fmt.Errorf("remux stdout pipe: %w", err)
- }
- if err := cmd.Start(); err != nil {
- if isContextError(err) {
- return nil
- }
- return fmt.Errorf("remux start: %w", err)
- }
- var firstErr error
- if _, err := io.Copy(w, stdout); err != nil {
- if !isContextError(err) {
- slog.Error("copy remuxed media", "file", inputPath, "err", err)
- firstErr = fmt.Errorf("copy remuxed media: %w", err)
- }
- }
- if err := cmd.Wait(); err != nil {
- if !isContextError(err) && firstErr == nil {
- firstErr = fmt.Errorf("remux wait: %w", err)
- }
- }
- if firstErr != nil {
- return firstErr
- }
- return nil
-}
-
-func isContextError(err error) bool {
- return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
-}
-
-// 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
-}
diff --git a/internal/probe/remux_test.go b/internal/probe/remux_test.go
deleted file mode 100644
index 0a5a611..0000000
--- a/internal/probe/remux_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package probe
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-type cancelingWriter struct {
- cancel context.CancelFunc
- err error
-}
-
-func (w cancelingWriter) Write(p []byte) (int, error) {
- w.cancel()
- if w.err != nil {
- return 0, w.err
- }
- return len(p), nil
-}
-
-func TestFFRemuxerReturnsCopyErrorWhenContextCanceled(t *testing.T) {
- installFakeFFmpeg(t, "printf remuxed-output\nsleep 10\n")
- errRemuxWrite := errors.New("remux write failed")
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- err := NewFFRemuxer().Remux(ctx, "input.ts", cancelingWriter{
- cancel: cancel,
- err: errRemuxWrite,
- })
- if !errors.Is(err, errRemuxWrite) {
- t.Fatalf("expected copy error, got %v", err)
- }
-}
-
-func TestFFRemuxerReturnsWaitErrorWhenContextCanceled(t *testing.T) {
- installFakeFFmpeg(t, "printf remuxed-output\nexit 7\n")
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- err := NewFFRemuxer().Remux(ctx, "input.ts", cancelingWriter{cancel: cancel})
- if err == nil {
- t.Fatal("expected wait error")
- }
- if !strings.Contains(err.Error(), "remux wait") {
- t.Fatalf("expected remux wait error, got %v", err)
- }
-}
-
-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 installFakeFFmpeg(t *testing.T, body string) {
- t.Helper()
- dir := t.TempDir()
- path := filepath.Join(dir, "ffmpeg")
- script := "#!/bin/sh\n" + body
- if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
- t.Fatal(err)
- }
- t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
-}
diff --git a/internal/probe/testdata/golden.json b/internal/probe/testdata/golden.json
deleted file mode 100644
index 0c6a9c0..0000000
--- a/internal/probe/testdata/golden.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "format": {
- "duration": "123.456",
- "bit_rate": "1000000"
- },
- "streams": [
- {
- "codec_name": "h264",
- "codec_type": "video",
- "width": 1920,
- "height": 1080
- }
- ]
-}