summaryrefslogtreecommitdiff
path: root/internal/probe
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-29 19:43:05 +0300
committerPaul Buetow <paul@buetow.org>2026-04-29 19:43:05 +0300
commitcf30414c2a0696cc75c615f4da66ea85d0522c42 (patch)
tree1d0838fdbafbf6fff7e2dd4173f323b6c2968bb9 /internal/probe
parent916d92b94a1ef0f7482914e210d421cf5e3f02cd (diff)
feat: implement filesystem scanner
Diffstat (limited to 'internal/probe')
-rw-r--r--internal/probe/probe.go106
-rw-r--r--internal/probe/probe_test.go126
-rw-r--r--internal/probe/testdata/golden.json14
3 files changed, 246 insertions, 0 deletions
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
+ }
+ ]
+}