Upload a recording (audio, or video with the camera off), get a private Swedish transcript, and chat about it. - ADR-033: RLS-scoped private transcript store; ffprobe/ffmpeg intake; gateway transcription (iguana kb-whisper, berget fallback); in-process single-replica worker; chat over the transcript (local models only). - ADR-034: optional two-engine mode (pianissimo + kb-whisper) reconciled by a LOCAL model; off unless TAPIR_RECONCILE_BASE_MODEL is set. - ADR-035: real progress (phases, running/quiet/lost), phase logging, every judged spot stored, merged segments. - Hardening from four adversarial reviews: errors logged and stored as kinds never text; per-engine deadlines; converted-length check; bounded queue; client hang-ups are not errors; strict local-alias check. - Dockerfile: static ffmpeg/ffprobe 7.0.2 pinned by sha256 (#34). Built and inspected by hand; CI does not build the image on a PR, so its first CI build is this merge. Uploads stay OFF until TAPIR_UPLOADS_ENABLED is set in the deployment. Closes #33, #34, #42, #43, #44. Co-authored-by: Mathias <mthbqv@gmail.com>
110 lines
3.9 KiB
Go
110 lines
3.9 KiB
Go
// 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
|
|
}
|
|
|
|
// AllowedDemuxers are the input formats an upload may be. The input is
|
|
// attacker-supplied, and a file's CONTENT can tell ffmpeg to open other files or
|
|
// URLs (HLS and concat playlists, image2 sequences, network demuxers). Recent
|
|
// ffmpeg refuses the ones tried (7.0.2: HLS without a .m3u8 name is not sniffed,
|
|
// concat rejects unsafe paths), but that is version luck; this list is not. It
|
|
// holds the containers real recordings use and none of the playlist or network
|
|
// formats. "mov" covers .mp4/.m4a/.3gp; "matroska" covers .webm.
|
|
const AllowedDemuxers = "wav,mov,mp4,m4a,3gp,mp3,ogg,matroska,webm,flac,aac,opus,aiff,amr,asf"
|
|
|
|
// inputRestrictions are ffmpeg/ffprobe INPUT options and must precede the input.
|
|
// Local files and pipes only: no network, no other protocols.
|
|
var inputRestrictions = []string{"-protocol_whitelist", "file,pipe", "-format_whitelist", AllowedDemuxers}
|
|
|
|
// 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"
|
|
}
|
|
args := append([]string{"-v", "error"}, inputRestrictions...)
|
|
args = append(args, "-print_format", "json", "-show_format", "-show_streams", path)
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
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
|
|
}
|