mirror of
https://github.com/navidrome/navidrome.git
synced 2026-06-19 07:37:15 +00:00
6abc2ed517
* fix(transcoding): preserve source metadata when transcoding downloads Default transcoding commands used `-map 0:a:0` with no metadata mapping, so transcoded files lost all source tags (title, artist, album, etc.). Downloads in the original format were unaffected because the file is copied byte-for-byte. Add `-map_metadata 0 -map_metadata 0:s:0` to the default commands. Both flags are required: `-map_metadata 0` copies format-level tags (MP3/FLAC sources) and `-map_metadata 0:s:0` copies stream-level tags (OPUS/OGG sources), which store tags at different levels. The flags are added in three coordinated places, since for users on the default command the args are built programmatically (buildDynamicArgs) rather than from the stored command string: - consts.go default commands, for new installations - buildDynamicArgs, the active path for default-command users - a migration updating only rows that still hold the exact old default, so customized commands are left untouched AAC is included for consistency but remains a no-op: its `-f adts` container cannot hold metadata, and the MP4 alternative breaks pipe streaming. Fixes #5623 * fix(transcoding): target audio stream for metadata and propagate ctx Address review feedback on the metadata-preservation change: - Use `-map_metadata 0:s:a:0` instead of `0:s:0` to copy tags from the first audio stream specifically. When a source has embedded cover art exposed as a video stream at index 0 (common in music files), `0:s:0` pulls the image stream's metadata and the audio tags are lost. Verified empirically with ffmpeg 7.1.3: a source with video at stream 0 and a tagged audio stream loses its title under `0:s:0` but keeps it under `0:s:a:0`; audio-only OPUS/MP3/FLAC sources are unaffected by the change. - Propagate the migration context via `tx.ExecContext(ctx, ...)` instead of discarding it, so the migration honors cancellation/timeouts. Claude-Session: https://claude.ai/code/session_015iFHDzX53wCKt11qFHMeZk
759 lines
27 KiB
Go
759 lines
27 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"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 -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456)
|
|
Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"}))
|
|
})
|
|
|
|
})
|
|
Context("when command does not have time offset param", func() {
|
|
It("adds time offset before 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", "-ss", "456", "-i", "/music library/file.mp3", "-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 -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue())
|
|
})
|
|
It("returns true for known default opus command", func() {
|
|
Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s: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 -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s: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 -ss %t -i %s -map 0:a:0 -map_metadata 0 -map_metadata 0:s: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",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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",
|
|
"-ss", "30",
|
|
"-i", "/music/file.mp3",
|
|
"-map", "0:a:0",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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",
|
|
"-map_metadata", "0", "-map_metadata", "0:s: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())
|
|
})
|
|
})
|
|
})
|
|
|
|
Describe("parseEncodersOutput", func() {
|
|
const sample = `Encoders:
|
|
V..... = Video
|
|
------
|
|
V....D apng APNG (Animated Portable Network Graphics) image
|
|
V....D libwebp_anim libwebp WebP image (codec webp)
|
|
V....D libwebp libwebp WebP image (codec webp)
|
|
A....D aac AAC (Advanced Audio Coding)
|
|
`
|
|
It("returns true when the encoder is present", func() {
|
|
Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue())
|
|
Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue())
|
|
Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue())
|
|
})
|
|
It("returns false when the encoder is absent", func() {
|
|
Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse())
|
|
Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse())
|
|
})
|
|
It("does not match partial names", func() {
|
|
// libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word.
|
|
stripped := `Encoders:
|
|
V....D libwebp libwebp WebP image (codec webp)
|
|
`
|
|
Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse())
|
|
})
|
|
It("handles empty output", func() {
|
|
Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse())
|
|
Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Describe("ConvertAnimatedImage", func() {
|
|
// Point ffmpegCmd at a stand-in binary that produces empty `-encoders`
|
|
// output so hasAnimatedWebPEncoder returns false. /usr/bin/true is
|
|
// portable across POSIX systems.
|
|
It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() {
|
|
truePath, err := exec.LookPath("true")
|
|
if err != nil {
|
|
Skip("true(1) not available")
|
|
}
|
|
origPath, origErr := ffmpegPath, ffmpegErr
|
|
ffmpegPath = truePath
|
|
ffmpegErr = nil
|
|
defer func() {
|
|
ffmpegPath, ffmpegErr = origPath, origErr
|
|
}()
|
|
|
|
ff := &ffmpeg{}
|
|
_, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75)
|
|
Expect(err).To(MatchError(ErrAnimatedWebPUnsupported))
|
|
})
|
|
})
|
|
})
|