Files
navidrome/core/ffmpeg/ffmpeg_test.go
Deluan 2588558946 fix: resolve flaky ffmpeg context cancellation test
Replaced single Read assertion with Eventually loop to drain buffered
pipe data after context cancellation. The previous test assumed the first
Read after cancel() would fail, but ffmpeg may have already written data
into the pipe buffer before being killed, causing the Read to succeed
from buffered content.
2026-03-27 19:38:42 -04:00

697 lines
24 KiB
Go

package ffmpeg
import (
"context"
"os"
"path/filepath"
"runtime"
sync "sync"
"testing"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestFFmpeg(t *testing.T) {
// Inline test init to avoid import cycle with tests package
//nolint:dogsled
_, file, _, _ := runtime.Caller(0)
appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
confPath := filepath.Join(appPath, "tests", "navidrome-test.toml")
_ = os.Chdir(appPath)
conf.LoadFromFile(confPath)
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "FFmpeg Suite")
}
var _ = Describe("ffmpeg", func() {
BeforeEach(func() {
_, _ = ffmpegCmd()
ffmpegPath = "ffmpeg"
ffmpegErr = nil
})
Describe("createFFmpegCommand", func() {
It("creates a valid command line", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
It("handles extra spaces in the command string", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 0)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
})
Context("when command has time offset param", func() {
It("creates a valid command line with offset", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"}))
})
})
Context("when command does not have time offset param", func() {
It("adds time offset after the input file name", func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"}))
})
})
})
Describe("createProbeCommand", func() {
It("creates a valid command line", func() {
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
})
})
When("ffmpegPath is set", func() {
It("returns the correct ffmpeg path", func() {
ffmpegPath = "/usr/bin/ffmpeg"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
})
It("returns the correct ffmpeg path with spaces", func() {
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
args := createProbeCommand(probeCmd, []string{"one.mp3"})
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
Describe("isDefaultCommand", func() {
It("returns true for known default mp3 command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
})
It("returns true for known default opus command", func() {
Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue())
})
It("returns true for known default aac command", func() {
Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue())
})
It("returns true for known default flac command", func() {
Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue())
})
It("returns false for a custom command", func() {
Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse())
})
It("returns false for unknown format", func() {
Expect(isDefaultCommand("wav", "ffmpeg -i %s -f wav -")).To(BeFalse())
})
})
Describe("buildDynamicArgs", func() {
It("builds mp3 args with bitrate, samplerate, and channels", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 256,
SampleRate: 48000,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "256k",
"-ar", "48000",
"-ac", "2",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds flac args without bitrate", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-ar", "48000",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("builds opus args with bitrate only", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "opus",
FilePath: "/music/file.flac",
BitRate: 128,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "libopus",
"-b:a", "128k",
"-v", "0",
"-f", "opus",
"-",
}))
})
It("includes offset when specified", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "mp3",
FilePath: "/music/file.mp3",
BitRate: 192,
Offset: 30,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.mp3",
"-ss", "30",
"-map", "0:a:0",
"-c:a", "libmp3lame",
"-b:a", "192k",
"-v", "0",
"-f", "mp3",
"-",
}))
})
It("builds aac args with ADTS output", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "aac",
FilePath: "/music/file.flac",
BitRate: 256,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-map", "0:a:0",
"-c:a", "aac",
"-b:a", "256k",
"-v", "0",
"-f", "adts",
"-",
}))
})
It("builds flac args with bit depth", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-map", "0:a:0",
"-c:a", "flac",
"-sample_fmt", "s32",
"-v", "0",
"-f", "flac",
"-",
}))
})
It("omits -sample_fmt when bit depth is 0", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.flac",
BitDepth: 0,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
It("omits -sample_fmt when bit depth is too low (DSD)", func() {
args := buildDynamicArgs(TranscodeOptions{
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 1,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
})
DescribeTable("omits -sample_fmt for lossy formats even when bit depth >= 16",
func(format string, bitRate int) {
args := buildDynamicArgs(TranscodeOptions{
Format: format,
FilePath: "/music/file.flac",
BitRate: bitRate,
BitDepth: 16,
})
Expect(args).ToNot(ContainElement("-sample_fmt"))
},
Entry("mp3", "mp3", 256),
Entry("aac", "aac", 256),
Entry("opus", "opus", 128),
)
})
Describe("bitDepthToSampleFmt", func() {
It("converts 16-bit", func() {
Expect(bitDepthToSampleFmt(16)).To(Equal("s16"))
})
It("converts 24-bit to s32 (FLAC only supports s16/s32)", func() {
Expect(bitDepthToSampleFmt(24)).To(Equal("s32"))
})
It("converts 32-bit", func() {
Expect(bitDepthToSampleFmt(32)).To(Equal("s32"))
})
})
Describe("buildTemplateArgs", func() {
It("injects -ar and -ac into custom template", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 44100,
Channels: 2,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "44100", "-ac", "2",
"-",
}))
})
It("injects only -ar when channels is 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
SampleRate: 48000,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-ar", "48000",
"-",
}))
})
It("does not inject anything when sample rate and channels are 0", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
FilePath: "/music/file.flac",
BitRate: 192,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
It("injects -sample_fmt for lossless output format with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -v 0 -c:a flac -f flac -",
Format: "flac",
FilePath: "/music/file.dsf",
BitDepth: 24,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.dsf",
"-v", "0", "-c:a", "flac", "-f", "flac",
"-sample_fmt", "s32",
"-",
}))
})
It("does not inject -sample_fmt for lossy output format even with bit depth", func() {
args := buildTemplateArgs(TranscodeOptions{
Command: "ffmpeg -i %s -b:a %bk -v 0 -f mp3 -",
Format: "mp3",
FilePath: "/music/file.flac",
BitRate: 192,
BitDepth: 16,
})
Expect(args).To(Equal([]string{
"ffmpeg", "-i", "/music/file.flac",
"-b:a", "192k", "-v", "0", "-f", "mp3",
"-",
}))
})
})
Describe("injectBeforeOutput", func() {
It("inserts flag before trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-f", "mp3", "-ar", "48000", "-"}))
})
It("appends when no trailing dash", func() {
args := injectBeforeOutput([]string{"ffmpeg", "-i", "file.mp3"}, "-ar", "48000")
Expect(args).To(Equal([]string{"ffmpeg", "-i", "file.mp3", "-ar", "48000"}))
})
})
Describe("parseProbeOutput", func() {
It("parses MP3 with embedded artwork (real ffprobe output)", func() {
// Real: MP3 file with mjpeg artwork stream after audio
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"mp3","codec_long_name":"MP3 (MPEG audio layer 3)","codec_type":"audio",` +
`"sample_fmt":"fltp","sample_rate":"44100","channels":2,"channel_layout":"stereo",` +
`"bits_per_sample":0,"bit_rate":"198314","tags":{"encoder":"LAME3.99r"}},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline","width":400,"height":400}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("mp3"))
Expect(result.Profile).To(BeEmpty()) // MP3 has no profile field
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(198)) // 198314 bps -> 198 kbps
Expect(result.BitDepth).To(Equal(0)) // lossy codec
})
It("parses AAC-LC in m4a container (real ffprobe output)", func() {
// Real: AAC LC file with profile and artwork
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
`"profile":"LC","codec_type":"audio","sample_fmt":"fltp","sample_rate":"44100",` +
`"channels":2,"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"279958"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("aac"))
Expect(result.Profile).To(Equal("LC"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(279)) // 279958 bps -> 279 kbps
})
It("parses HE-AACv2 in mp4 container with video stream (real ffprobe output)", func() {
// Real: Fraunhofer HE-AACv2 sample (LFE-SBRstereo.mp4), video stream before audio
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"h264","codec_type":"video","profile":"Main"},` +
`{"index":1,"codec_name":"aac","codec_long_name":"AAC (Advanced Audio Coding)",` +
`"profile":"HE-AACv2","codec_type":"audio","sample_fmt":"fltp",` +
`"sample_rate":"48000","channels":2,"channel_layout":"stereo",` +
`"bits_per_sample":0,"bit_rate":"55999"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("aac"))
Expect(result.Profile).To(Equal("HE-AACv2"))
Expect(result.SampleRate).To(Equal(48000))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(55)) // 55999 bps -> 55 kbps
})
It("parses FLAC using bits_per_raw_sample and format-level bit_rate (real ffprobe output)", func() {
// Real: FLAC reports bit depth in bits_per_raw_sample, not bits_per_sample.
// Stream-level bit_rate is absent; format-level bit_rate is used as fallback.
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0,"bits_per_raw_sample":"16"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}],` +
`"format":{"bit_rate":"906900"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("flac"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
Expect(result.BitRate).To(Equal(906)) // format-level: 906900 bps -> 906 kbps
Expect(result.Profile).To(BeEmpty()) // no profile field in real output
})
It("parses Opus with format-level bit_rate fallback (real ffprobe output)", func() {
// Real: Opus stream-level bit_rate is absent; format-level is used as fallback.
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"opus","codec_long_name":"Opus (Opus Interactive Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"48000","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0}],` +
`"format":{"bit_rate":"128000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("opus"))
Expect(result.SampleRate).To(Equal(48000))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(128)) // format-level: 128000 bps -> 128 kbps
Expect(result.BitDepth).To(Equal(0))
})
It("parses WAV/PCM with bits_per_sample (real ffprobe output)", func() {
// Real: WAV uses bits_per_sample directly
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"pcm_s16le","codec_long_name":"PCM signed 16-bit little-endian",` +
`"codec_type":"audio","sample_fmt":"s16","sample_rate":"44100","channels":2,` +
`"bits_per_sample":16,"bit_rate":"1411200"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("pcm_s16le"))
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitDepth).To(Equal(16))
Expect(result.BitRate).To(Equal(1411))
})
It("parses ALAC in m4a container (real ffprobe output)", func() {
// Real: Beatles - You Can't Do That (2023 Mix), ALAC 16-bit
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"alac","codec_long_name":"ALAC (Apple Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s16p","sample_rate":"44100","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":0,"bit_rate":"1011003",` +
`"bits_per_raw_sample":"16"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("alac"))
Expect(result.BitDepth).To(Equal(16)) // from bits_per_raw_sample
Expect(result.SampleRate).To(Equal(44100))
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(1011)) // 1011003 bps -> 1011 kbps
})
It("skips video-only streams", func() {
data := []byte(`{"streams":[{"index":0,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no audio stream"))
})
It("returns error for empty streams array", func() {
data := []byte(`{"streams":[]}`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
})
It("returns error for invalid JSON", func() {
data := []byte(`not json`)
_, err := parseProbeOutput(data)
Expect(err).To(HaveOccurred())
})
It("parses HiRes multichannel FLAC with format-level bit_rate (real ffprobe output)", func() {
// Real: Pink Floyd - 192kHz/24-bit/7.1 surround FLAC
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_long_name":"FLAC (Free Lossless Audio Codec)",` +
`"codec_type":"audio","sample_fmt":"s32","sample_rate":"192000","channels":8,` +
`"channel_layout":"7.1","bits_per_sample":0,"bits_per_raw_sample":"24"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Progressive"}],` +
`"format":{"bit_rate":"18432000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("flac"))
Expect(result.SampleRate).To(Equal(192000))
Expect(result.BitDepth).To(Equal(24))
Expect(result.Channels).To(Equal(8))
Expect(result.BitRate).To(Equal(18432)) // format-level: 18432000 bps -> 18432 kbps
})
It("parses DSD/DSF file (real ffprobe output)", func() {
// Real: Yes - Owner of a Lonely Heart, DSD64 DSF
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"dsd_lsbf_planar",` +
`"codec_long_name":"DSD (Direct Stream Digital), least significant bit first, planar",` +
`"codec_type":"audio","sample_fmt":"fltp","sample_rate":"352800","channels":2,` +
`"channel_layout":"stereo","bits_per_sample":8,"bit_rate":"5644800"},` +
`{"index":1,"codec_name":"mjpeg","codec_type":"video","profile":"Baseline"}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Codec).To(Equal("dsd_lsbf_planar"))
Expect(result.BitDepth).To(Equal(8)) // DSD reports 8 bits_per_sample
Expect(result.SampleRate).To(Equal(352800)) // DSD64 sample rate
Expect(result.Channels).To(Equal(2))
Expect(result.BitRate).To(Equal(5644)) // 5644800 bps -> 5644 kbps
})
It("prefers stream-level bit_rate over format-level when both are present", func() {
// ALAC/DSD: stream has bit_rate, format also has bit_rate — stream wins
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"alac","codec_type":"audio","sample_fmt":"s16p",` +
`"sample_rate":"44100","channels":2,"bits_per_sample":0,` +
`"bit_rate":"1011003","bits_per_raw_sample":"16"}],` +
`"format":{"bit_rate":"1050000"}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.BitRate).To(Equal(1011)) // stream-level: 1011003 bps -> 1011 kbps (not format's 1050)
})
It("returns BitRate 0 when neither stream nor format has bit_rate", func() {
data := []byte(`{"streams":[` +
`{"index":0,"codec_name":"flac","codec_type":"audio","sample_fmt":"s16",` +
`"sample_rate":"44100","channels":2,"bits_per_sample":0,"bits_per_raw_sample":"16"}],` +
`"format":{}}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.BitRate).To(Equal(0))
})
It("clears 'unknown' profile to empty string", func() {
data := []byte(`{"streams":[{"index":0,"codec_name":"flac",` +
`"codec_type":"audio","profile":"unknown","sample_rate":"44100",` +
`"channels":2,"bits_per_sample":0}]}`)
result, err := parseProbeOutput(data)
Expect(err).ToNot(HaveOccurred())
Expect(result.Profile).To(BeEmpty())
})
})
Describe("FFmpeg", func() {
Context("when FFmpeg is available", func() {
var ff FFmpeg
BeforeEach(func() {
ffOnce = sync.Once{}
ff = New()
// Skip if FFmpeg is not available
if !ff.IsAvailable() {
Skip("FFmpeg not available on this system")
}
})
It("should interrupt transcoding when context is cancelled", func() {
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Use a command that generates audio indefinitely
// -f lavfi uses FFmpeg's built-in audio source
// -t 0 means no time limit (runs forever)
command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
// The input file is not used here, but we need to provide a valid path to the Transcode function
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: command,
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Read some data first to ensure FFmpeg is running
buf := make([]byte, 1024)
_, err = stream.Read(buf)
Expect(err).ToNot(HaveOccurred())
// Cancel the context
cancel()
// Subsequent reads should eventually fail due to cancelled context.
// There may be buffered data in the pipe, so we drain until an error occurs.
Eventually(func() error {
_, err = stream.Read(buf)
return err
}).WithTimeout(5 * time.Second).WithPolling(10 * time.Millisecond).Should(HaveOccurred())
})
It("should handle immediate context cancellation", func() {
ctx, cancel := context.WithCancel(GinkgoT().Context())
cancel() // Cancel immediately
// This should fail immediately
_, err := ff.Transcode(ctx, TranscodeOptions{
Command: "ffmpeg -i %s -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.mp3",
BitRate: 128,
})
Expect(err).To(MatchError(context.Canceled))
})
})
Context("stderr capture", func() {
BeforeEach(func() {
if runtime.GOOS == "windows" {
Skip("stderr capture tests use /bin/sh, skipping on Windows")
}
})
It("should include stderr in error when process fails", func() {
ff := &ffmpeg{}
ctx := GinkgoT().Context()
// Directly call start() with a bash command that writes to stderr and fails
args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"}
stream, err := ff.start(ctx, args)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
buf := make([]byte, 1024)
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("codec not found: libopus"))
})
It("should not include stderr in error when process succeeds", func() {
ff := &ffmpeg{}
ctx := GinkgoT().Context()
// Command that writes to stderr but exits successfully
args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"}
stream, err := ff.start(ctx, args)
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
buf := make([]byte, 1024)
n, err := stream.Read(buf)
Expect(err).ToNot(HaveOccurred())
Expect(string(buf[:n])).To(Equal("output"))
})
})
Context("with mock process behavior", func() {
var longRunningCmd string
BeforeEach(func() {
// Use a long-running command for testing cancellation
switch runtime.GOOS {
case "windows":
// Use PowerShell's Start-Sleep
ffmpegPath = "powershell"
longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
default:
// Use sleep on Unix-like systems
ffmpegPath = "sleep"
longRunningCmd = "sleep 10"
}
})
It("should terminate the underlying process when context is cancelled", func() {
ff := New()
ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
defer cancel()
// Start a process that will run for a while
stream, err := ff.Transcode(ctx, TranscodeOptions{
Command: longRunningCmd,
FilePath: "tests/fixtures/test.mp3",
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
// Give the process time to start
time.Sleep(50 * time.Millisecond)
// Cancel the context
cancel()
// Try to read from the stream, which should fail
buf := make([]byte, 100)
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
// Verify the stream is closed by attempting another read
_, err = stream.Read(buf)
Expect(err).To(HaveOccurred())
})
})
})
})