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>
This commit was merged in pull request #39.
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
package audio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FFmpeg converts any audio container to the strict format whisper wants: 16 kHz
|
||||
// mono signed 16-bit PCM WAV (ADR-033). It ALWAYS transcodes, even when the input
|
||||
// already is that format — a Teams export often is, and the transcode is then
|
||||
// nearly free; assuming it would be the bug. This replaces whisper-server's
|
||||
// --convert, whose --tmp-dir is created at process start and rots under macOS
|
||||
// periodic cleanup.
|
||||
type FFmpeg struct {
|
||||
// Bin is the ffmpeg executable ("ffmpeg" resolves via PATH).
|
||||
Bin string
|
||||
// MaxSeconds caps the converted length when positive. The duration limit is a claim
|
||||
// in the file's header and can be lied about (a 2 h file claiming 10 s converted to a
|
||||
// 120-minute WAV); this bounds what a lie can cost. A minute of slack is added so a
|
||||
// file at the limit is never cut, and the worker measures the result.
|
||||
MaxSeconds int
|
||||
}
|
||||
|
||||
// lengthSlackSeconds is added to MaxSeconds so ffmpeg's rounding never clips a file that
|
||||
// is really at the limit.
|
||||
const lengthSlackSeconds = 60
|
||||
|
||||
// ToWAV writes src, converted, to dst. Video streams are dropped (-vn): by now the
|
||||
// file has already been probed as audio-only, but a cover-art stream must not end
|
||||
// up in the WAV.
|
||||
func (f *FFmpeg) ToWAV(ctx context.Context, src, dst string) error {
|
||||
bin := f.Bin
|
||||
if bin == "" {
|
||||
bin = "ffmpeg"
|
||||
}
|
||||
// Input restrictions go BEFORE -i: placed after it they would apply to the output.
|
||||
args := append([]string{"-y", "-v", "error"}, inputRestrictions...)
|
||||
args = append(args, "-i", src, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le")
|
||||
if f.MaxSeconds > 0 {
|
||||
// After -i: an OUTPUT cap. Before it, it would limit what is read, not written.
|
||||
args = append(args, "-t", strconv.Itoa(f.MaxSeconds+lengthSlackSeconds))
|
||||
}
|
||||
args = append(args, "-f", "wav", dst)
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.WaitDelay = killGrace
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return fmt.Errorf("audio: ffmpeg: %w", ctxErr)
|
||||
}
|
||||
return fmt.Errorf("audio: ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
// A zero exit that wrote nothing is still a failure: never hand whisper a
|
||||
// missing file and let the error surface three layers later.
|
||||
if info, err := os.Stat(dst); err != nil || info.Size() == 0 {
|
||||
return fmt.Errorf("audio: ffmpeg wrote no output at %s", dst)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package audio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"git.d-ma.be/mathias/tapir/internal/adapters/audio"
|
||||
)
|
||||
|
||||
func TestFFmpegToWAV_AsksForSixteenKilohertzMonoPCMAlways(t *testing.T) {
|
||||
// Always transcode, even when the input already is 16 kHz mono: a Teams export
|
||||
// often is, and the point is to not ASSUME (ADR-033). Records the argv.
|
||||
rec := filepath.Join(t.TempDir(), "argv")
|
||||
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
||||
for a; do last="$a"; done
|
||||
printf data > "$last"`)
|
||||
|
||||
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
|
||||
|
||||
require.NoError(t, err)
|
||||
raw, err := os.ReadFile(rec)
|
||||
require.NoError(t, err)
|
||||
argv := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
joined := " " + strings.Join(argv, " ") + " "
|
||||
for _, want := range []string{" -ac 1 ", " -ar 16000 ", " -c:a pcm_s16le ", " -vn ", " -i /spool/in "} {
|
||||
assert.Contains(t, joined, want)
|
||||
}
|
||||
assert.True(t, strings.HasSuffix(strings.TrimSpace(joined), "out.wav"), "the output path is the last argument")
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_NonzeroExitIsAnErrorCarryingStderr(t *testing.T) {
|
||||
bin := stub(t, `echo "moov atom not found" >&2; exit 1`)
|
||||
|
||||
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "o.wav"))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "moov atom not found")
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_ZeroExitWithNoOutputIsAnError(t *testing.T) {
|
||||
bin := stub(t, `exit 0`)
|
||||
|
||||
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/in", filepath.Join(t.TempDir(), "never-written.wav"))
|
||||
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_MissingBinaryIsAnError(t *testing.T) {
|
||||
err := (&audio.FFmpeg{Bin: filepath.Join(t.TempDir(), "no-ffmpeg")}).ToWAV(context.Background(), "/in", "/out.wav")
|
||||
|
||||
require.Error(t, err)
|
||||
assert.False(t, errors.Is(err, context.Canceled))
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_RestrictsWhatTheFileMayMakeFFmpegOpen(t *testing.T) {
|
||||
rec := filepath.Join(t.TempDir(), "argv")
|
||||
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
||||
for a; do last="$a"; done
|
||||
printf data > "$last"`)
|
||||
|
||||
err := (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "o.wav"))
|
||||
|
||||
require.NoError(t, err)
|
||||
args := argvOf(t, rec)
|
||||
pw, fw, in := indexOf(args, "-protocol_whitelist"), indexOf(args, "-format_whitelist"), indexOf(args, "-i")
|
||||
require.GreaterOrEqual(t, pw, 0, "argv: %v", args)
|
||||
require.GreaterOrEqual(t, fw, 0, "argv: %v", args)
|
||||
assert.Equal(t, "file,pipe", args[pw+1])
|
||||
assert.Equal(t, audio.AllowedDemuxers, args[fw+1])
|
||||
assert.Less(t, pw, in, "input options must come BEFORE -i or ffmpeg applies them to the output")
|
||||
assert.Less(t, fw, in)
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_PassesAnOutputLengthCapAfterTheInputWhenAMaximumIsSet(t *testing.T) {
|
||||
// The duration limit is a CLAIM in the file's header; a file that lies about it can
|
||||
// convert to hours of audio (adversarial review F2: a 2 h file claiming 10 s became a
|
||||
// 120-minute WAV). The cap is an OUTPUT option, after -i, with a minute of slack.
|
||||
rec := filepath.Join(t.TempDir(), "argv")
|
||||
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
||||
for a; do last="$a"; done
|
||||
printf data > "$last"`)
|
||||
|
||||
err := (&audio.FFmpeg{Bin: bin, MaxSeconds: 5400}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav"))
|
||||
|
||||
require.NoError(t, err)
|
||||
raw, err := os.ReadFile(rec)
|
||||
require.NoError(t, err)
|
||||
joined := " " + strings.Join(strings.Split(strings.TrimSpace(string(raw)), "\n"), " ") + " "
|
||||
assert.Contains(t, joined, " -t 5460 ", "limit plus a minute of slack")
|
||||
assert.Less(t, strings.Index(joined, " -i /spool/in "), strings.Index(joined, " -t 5460 "), "-t after -i is an output cap; before it, an input one")
|
||||
}
|
||||
|
||||
func TestFFmpegToWAV_PassesNoLengthCapWhenNoMaximumIsSet(t *testing.T) {
|
||||
rec := filepath.Join(t.TempDir(), "argv")
|
||||
bin := stub(t, `printf '%s\n' "$@" > `+rec+`
|
||||
for a; do last="$a"; done
|
||||
printf data > "$last"`)
|
||||
|
||||
require.NoError(t, (&audio.FFmpeg{Bin: bin}).ToWAV(context.Background(), "/spool/in", filepath.Join(t.TempDir(), "out.wav")))
|
||||
|
||||
raw, err := os.ReadFile(rec)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, " "+strings.ReplaceAll(string(raw), "\n", " ")+" ", " -t ")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package audio_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot runs the REAL ffprobe
|
||||
// (opt-in: TAPIR_LIVE_FFPROBE) on crafted concat/HLS files and on a real m4a.
|
||||
func TestFFprobeLive_HostilePlaylistsAreRefusedAndRealAudioIsNot(t *testing.T) {
|
||||
bin := os.Getenv("TAPIR_LIVE_FFPROBE")
|
||||
if bin == "" {
|
||||
t.Skip("set TAPIR_LIVE_FFPROBE (and TAPIR_LIVE_HOSTILE_DIR, TAPIR_LIVE_M4A) to run against the real ffprobe")
|
||||
}
|
||||
p := &audio.FFprobe{Bin: bin}
|
||||
for _, name := range []string{"up-concat", "up-hls"} {
|
||||
_, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_HOSTILE_DIR")+"/"+name)
|
||||
assert.ErrorIs(t, err, uploads.ErrUnreadable, name)
|
||||
}
|
||||
got, err := p.Probe(context.Background(), os.Getenv("TAPIR_LIVE_M4A"))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, got.HasAudio)
|
||||
assert.False(t, got.HasVideo)
|
||||
assert.InDelta(t, 82.5, got.Duration.Seconds(), 1)
|
||||
}
|
||||
Reference in New Issue
Block a user