diff --git a/internal/adapters/audio/ffprobe.go b/internal/adapters/audio/ffprobe.go new file mode 100644 index 0000000..d4d27b6 --- /dev/null +++ b/internal/adapters/audio/ffprobe.go @@ -0,0 +1,95 @@ +// Package audio holds the adapters that inspect and convert user-supplied audio +// with ffmpeg's tools (ADR-033). They sit behind small ports so the use case is +// tested with fakes and only this package knows a subprocess is involved. +package audio + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "strconv" + "time" + + "git.d-ma.be/mathias/tapir/internal/uploads" +) + +// FFprobe implements uploads.Prober by running ffprobe on the spooled file. +type FFprobe struct { + // Bin is the ffprobe executable ("ffprobe" resolves via PATH). + Bin string +} + +// killGrace bounds how long Probe waits for a cancelled ffprobe's pipes to close. +const killGrace = 2 * time.Second + +// Probe runs ffprobe and classifies the outcome. A non-zero exit means ffprobe +// looked at the file and could not read it: that is the FILE's fault and returns +// uploads.ErrUnreadable. A missing binary, a cancelled context or unparsable +// output is OURS and is returned as an ordinary error, so it is never reported to +// the user as their file being bad. +func (f *FFprobe) Probe(ctx context.Context, path string) (uploads.Probe, error) { + bin := f.Bin + if bin == "" { + bin = "ffprobe" + } + cmd := exec.CommandContext(ctx, bin, + "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path) + cmd.WaitDelay = killGrace + out, err := cmd.Output() + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return uploads.Probe{}, fmt.Errorf("audio: ffprobe: %w", ctxErr) + } + var exit *exec.ExitError + if errors.As(err, &exit) { + return uploads.Probe{}, fmt.Errorf("%w: ffprobe: %s", uploads.ErrUnreadable, exit.Stderr) + } + return uploads.Probe{}, fmt.Errorf("audio: run ffprobe: %w", err) + } + p, err := ParseProbe(out) + if err != nil { + return uploads.Probe{}, fmt.Errorf("audio: ffprobe output: %w", err) + } + return p, nil +} + +// probeJSON is the subset of `ffprobe -print_format json` output that matters. +type probeJSON struct { + Streams []struct { + CodecType string `json:"codec_type"` + Disposition struct { + AttachedPic int `json:"attached_pic"` + } `json:"disposition"` + } `json:"streams"` + Format struct { + Duration string `json:"duration"` + } `json:"format"` +} + +// ParseProbe turns ffprobe's JSON into a uploads.Probe. A video stream that is +// only attached cover art (an mp3/m4a with an album picture) is not video. A +// missing or "N/A" duration yields zero, which uploads.Validate rejects as +// unreadable rather than accepting an empty recording. +func ParseProbe(data []byte) (uploads.Probe, error) { + var j probeJSON + if err := json.Unmarshal(data, &j); err != nil { + return uploads.Probe{}, fmt.Errorf("decode: %w", err) + } + var p uploads.Probe + for _, s := range j.Streams { + switch s.CodecType { + case "audio": + p.HasAudio = true + case "video": + if s.Disposition.AttachedPic == 0 { + p.HasVideo = true + } + } + } + if secs, err := strconv.ParseFloat(j.Format.Duration, 64); err == nil && secs > 0 { + p.Duration = time.Duration(secs * float64(time.Second)) + } + return p, nil +}