From cf30414c2a0696cc75c615f4da66ea85d0522c42 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 29 Apr 2026 19:43:05 +0300 Subject: feat: implement filesystem scanner --- internal/probe/probe.go | 106 ++++++++++++++++++++++++++++++ internal/probe/probe_test.go | 126 ++++++++++++++++++++++++++++++++++++ internal/probe/testdata/golden.json | 14 ++++ 3 files changed, 246 insertions(+) create mode 100644 internal/probe/probe_test.go create mode 100644 internal/probe/testdata/golden.json (limited to 'internal/probe') diff --git a/internal/probe/probe.go b/internal/probe/probe.go index 2cc7dc8..7f5b887 100644 --- a/internal/probe/probe.go +++ b/internal/probe/probe.go @@ -1,2 +1,108 @@ // Package probe implements media metadata probing. package probe + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strconv" + + "github.com/paul/kiss-media-player/internal/model" +) + +// 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{} + +// NewFFProber creates a new FFProber. +func NewFFProber() *FFProber { + return &FFProber{} +} + +// Probe runs ffprobe against the given path and parses the resulting JSON. +func (f *FFProber) Probe(ctx context.Context, path string) (*model.Metadata, error) { + cmd := exec.CommandContext(ctx, "ffprobe", + "-v", "error", + "-show_format", + "-show_streams", + "-of", "json", + path, + ) + 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) + } + return parseFFprobeOutput(out) +} + +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) + } + 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 +} + +// 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 new file mode 100644 index 0000000..9c05846 --- /dev/null +++ b/internal/probe/probe_test.go @@ -0,0 +1,126 @@ +package probe + +import ( + "context" + "testing" + "time" + + "github.com/paul/kiss-media-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/testdata/golden.json b/internal/probe/testdata/golden.json new file mode 100644 index 0000000..0c6a9c0 --- /dev/null +++ b/internal/probe/testdata/golden.json @@ -0,0 +1,14 @@ +{ + "format": { + "duration": "123.456", + "bit_rate": "1000000" + }, + "streams": [ + { + "codec_name": "h264", + "codec_type": "video", + "width": 1920, + "height": 1080 + } + ] +} -- cgit v1.2.3