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>
187 lines
6.3 KiB
Go
187 lines
6.3 KiB
Go
package audio_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
|
|
"git.d-ma.be/mathias/tapir/internal/uploads"
|
|
)
|
|
|
|
func TestParseProbe(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
want uploads.Probe
|
|
}{
|
|
{
|
|
name: "audio-only m4a",
|
|
json: `{"streams":[{"codec_type":"audio","disposition":{"attached_pic":0}}],"format":{"duration":"2701.5"}}`,
|
|
want: uploads.Probe{Duration: 2701*time.Second + 500*time.Millisecond, HasAudio: true},
|
|
},
|
|
{
|
|
name: "mp4 with a real video stream",
|
|
json: `{"streams":[{"codec_type":"video","disposition":{"attached_pic":0}},{"codec_type":"audio"}],"format":{"duration":"240.0"}}`,
|
|
want: uploads.Probe{Duration: 240 * time.Second, HasAudio: true, HasVideo: true},
|
|
},
|
|
{
|
|
name: "mp3 whose only video stream is attached cover art is NOT video",
|
|
json: `{"streams":[{"codec_type":"audio"},{"codec_type":"video","disposition":{"attached_pic":1}}],"format":{"duration":"60"}}`,
|
|
want: uploads.Probe{Duration: 60 * time.Second, HasAudio: true},
|
|
},
|
|
{
|
|
name: "no streams at all",
|
|
json: `{"streams":[],"format":{"duration":"10"}}`,
|
|
want: uploads.Probe{Duration: 10 * time.Second},
|
|
},
|
|
{
|
|
name: "duration absent means zero, which Validate rejects as unreadable",
|
|
json: `{"streams":[{"codec_type":"audio"}],"format":{}}`,
|
|
want: uploads.Probe{HasAudio: true},
|
|
},
|
|
{
|
|
name: "duration reported as N/A means zero",
|
|
json: `{"streams":[{"codec_type":"audio"}],"format":{"duration":"N/A"}}`,
|
|
want: uploads.Probe{HasAudio: true},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := audio.ParseProbe([]byte(tt.json))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseProbe_GarbageIsAnError(t *testing.T) {
|
|
_, err := audio.ParseProbe([]byte("not json"))
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// stub writes an executable shell script standing in for ffprobe. This is a fake
|
|
// at the process boundary: the real binary is not on every host (it is not on
|
|
// koala), and what is under test is how Probe drives and interprets a process.
|
|
func stub(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "ffprobe")
|
|
require.NoError(t, os.WriteFile(p, []byte("#!/bin/sh\n"+body+"\n"), 0o755))
|
|
return p
|
|
}
|
|
|
|
func TestFFprobeProbe_ParsesWhatTheProcessPrints(t *testing.T) {
|
|
bin := stub(t, `echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"90"}}'`)
|
|
|
|
got, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/ignored")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, uploads.Probe{Duration: 90 * time.Second, HasAudio: true}, got)
|
|
}
|
|
|
|
func TestFFprobeProbe_PassesTheFilePathAsTheLastArgument(t *testing.T) {
|
|
out := filepath.Join(t.TempDir(), "args")
|
|
bin := stub(t, `for a; do last="$a"; done; printf '%s' "$last" > `+out+`
|
|
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
|
|
|
|
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-123")
|
|
|
|
require.NoError(t, err)
|
|
got, err := os.ReadFile(out)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "/spool/upload-123", string(got))
|
|
}
|
|
|
|
func TestFFprobeProbe_NonzeroExitMeansTheFileIsUnreadable(t *testing.T) {
|
|
bin := stub(t, `echo "Invalid data found when processing input" >&2; exit 1`)
|
|
|
|
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/x")
|
|
|
|
assert.ErrorIs(t, err, uploads.ErrUnreadable)
|
|
}
|
|
|
|
func TestFFprobeProbe_MissingBinaryIsOurFaultNotAnUnreadableFile(t *testing.T) {
|
|
_, err := (&audio.FFprobe{Bin: filepath.Join(t.TempDir(), "no-such-ffprobe")}).Probe(context.Background(), "/x")
|
|
|
|
require.Error(t, err)
|
|
assert.False(t, errors.Is(err, uploads.ErrUnreadable),
|
|
"a missing ffprobe must not be reported to the user as their file being bad")
|
|
}
|
|
|
|
func TestFFprobeProbe_StopsWhenTheContextIsCancelled(t *testing.T) {
|
|
bin := stub(t, `sleep 30`)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
|
defer cancel()
|
|
|
|
start := time.Now()
|
|
_, err := (&audio.FFprobe{Bin: bin}).Probe(ctx, "/x")
|
|
|
|
require.Error(t, err)
|
|
assert.Less(t, time.Since(start), 5*time.Second)
|
|
assert.False(t, errors.Is(err, uploads.ErrUnreadable), "a timeout is not a verdict on the file")
|
|
}
|
|
|
|
// argvStub records the arguments it was called with into a file and then behaves as
|
|
// a successful ffprobe, so a test can assert exactly what the process was asked.
|
|
func argvStub(t *testing.T) (bin, argvFile string) {
|
|
t.Helper()
|
|
argvFile = filepath.Join(t.TempDir(), "argv")
|
|
bin = stub(t, `printf '%s\n' "$@" > `+argvFile+`
|
|
echo '{"streams":[{"codec_type":"audio"}],"format":{"duration":"1"}}'`)
|
|
return bin, argvFile
|
|
}
|
|
|
|
func argvOf(t *testing.T, file string) []string {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(file)
|
|
require.NoError(t, err)
|
|
return strings.Split(strings.TrimSpace(string(raw)), "\n")
|
|
}
|
|
|
|
func indexOf(args []string, want string) int {
|
|
for i, a := range args {
|
|
if a == want {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func TestFFprobeProbe_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
|
|
// The input is attacker-supplied. ffmpeg can be told by a file's CONTENT to open
|
|
// other files or URLs (HLS and concat playlists); recent versions refuse by
|
|
// default, but that is version luck. Pin it: local files and pipes only, and
|
|
// only the demuxers real audio recordings use.
|
|
bin, argvFile := argvStub(t)
|
|
|
|
_, err := (&audio.FFprobe{Bin: bin}).Probe(context.Background(), "/spool/upload-1")
|
|
|
|
require.NoError(t, err)
|
|
args := argvOf(t, argvFile)
|
|
pw := indexOf(args, "-protocol_whitelist")
|
|
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
|
|
assert.Equal(t, "file,pipe", args[pw+1])
|
|
fw := indexOf(args, "-format_whitelist")
|
|
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
|
|
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
|
|
assert.Equal(t, "/spool/upload-1", args[len(args)-1], "the path stays the last argument")
|
|
}
|
|
|
|
func TestAllowedDemuxers_ExcludeThePlaylistFormats(t *testing.T) {
|
|
for _, bad := range []string{"concat", "hls", "applehttp", "dash", "rtsp", "sdp", "image2"} {
|
|
assert.NotContains(t, strings.Split(audio.AllowedDemuxers, ","), bad,
|
|
"%s can make ffmpeg open other files or URLs named inside the upload", bad)
|
|
}
|
|
for _, good := range []string{"wav", "mov", "mp4", "m4a", "mp3", "ogg", "matroska", "flac"} {
|
|
assert.Contains(t, strings.Split(audio.AllowedDemuxers, ","), good)
|
|
}
|
|
}
|