// 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 }