feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user