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 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) (limited to 'internal/probe/probe.go') 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 +} -- cgit v1.2.3