🏡 index : quine-global/quintodrome.git

package ffmpeg

import (
	"context"
	"errors"
	"io"
	"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", "-"}))
			})
		})
	})

	When("ffmpegPath is set", func() {
		It("returns the correct ffmpeg path", func() {
			ffmpegPath = "/usr/bin/ffmpeg"
			args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
			Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"}))
		})
		It("returns the correct ffmpeg path with spaces", func() {
			ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
			args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
			Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"}))
		})
	})

	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("ProbeError", func() {
		It("uses the underlying cause in Error() so logs keep the full detail", func() {
			e := &ProbeError{Path: "/music/foo.flac",
				err: errors.New("/music/foo.flac: Invalid data found when processing input")}
			Expect(e.Error()).To(ContainSubstring("/music/foo.flac"))
			Expect(e.Error()).To(ContainSubstring("Invalid data found when processing input"))
		})

		It("returns the path-free reason from SafeReason()", func() {
			e := &ProbeError{Path: "/music/foo.flac", Reason: "the file: Invalid data found when processing input"}
			Expect(e.SafeReason()).To(Equal("the file: Invalid data found when processing input"))
			Expect(e.SafeReason()).ToNot(ContainSubstring("/music/foo.flac"))
		})

		It("unwraps to the underlying cause so errors.Is detects a missing file", func() {
			e := &ProbeError{Path: "/music/foo.flac", Reason: "file not found", err: os.ErrNotExist}
			Expect(errors.Is(e, os.ErrNotExist)).To(BeTrue())
		})
	})

	Describe("probeClientReason", func() {
		It("strips the file path from ffprobe stderr", func() {
			if runtime.GOOS == "windows" {
				Skip("uses /bin/sh")
			}
			_, err := exec.Command("/bin/sh", "-c", "echo '/music/foo.flac: Invalid data found' >&2; exit 1").Output()
			Expect(err).To(HaveOccurred())
			Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("the file: Invalid data found"))
		})

		It("returns a generic reason for launch failures, without leaking the binary path", func() {
			err := errors.New("fork/exec /opt/navidrome/bin/ffprobe: no such file or directory")
			Expect(probeClientReason(err, "/music/foo.flac")).To(Equal("could not read file"))
		})
	})

	Describe("probeDetail", func() {
		It("surfaces ffprobe stderr for logging", func() {
			if runtime.GOOS == "windows" {
				Skip("uses /bin/sh")
			}
			_, err := exec.Command("/bin/sh", "-c", "echo 'boom detail' >&2; exit 1").Output()
			Expect(err).To(HaveOccurred())
			Expect(probeDetail(err)).To(Equal("boom detail"))
		})
	})

	Describe("fileAccessReason", func() {
		It("reports a missing file as 'file not found', not a raw stat message", func() {
			_, err := os.Stat("/no/such/dir/really-missing.flac")
			Expect(err).To(HaveOccurred())
			Expect(fileAccessReason(err)).To(Equal("file not found"))
		})

		It("falls back to a generic reason for other access errors", func() {
			Expect(fileAccessReason(errors.New("boom"))).To(Equal("file not accessible"))
		})
	})

	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("ProbeAudioStream returns a not-found ProbeError for a missing file", func() {
				_, err := ff.ProbeAudioStream(GinkgoT().Context(), "/no/such/dir/really-missing.flac")
				Expect(err).To(HaveOccurred())
				Expect(errors.Is(err, os.ErrNotExist)).To(BeTrue())
				var pe *ProbeError
				Expect(errors.As(err, &pe)).To(BeTrue())
				Expect(pe.SafeReason()).To(Equal("file not found"))
				Expect(pe.NotFound).To(BeTrue())
			})

			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))
			})

			It("fills in total_samples on a piped FLAC transcode", func() {
				stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
					Command:  "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
					Format:   "flac",
					FilePath: "tests/fixtures/test.flac",
					Duration: 1, // the fixture is exactly 1s at 44100Hz
				})
				Expect(err).ToNot(HaveOccurred())
				defer stream.Close()

				out, err := io.ReadAll(stream)
				Expect(err).ToNot(HaveOccurred())
				Expect(string(out[:4])).To(Equal("fLaC"))
				Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
			})

			It("patches the duration net of the requested offset", func() {
				// The command has no %t, so ffmpeg still emits the whole fixture.
				// What is under test is the header arithmetic, not the audio.
				stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
					Command:  "ffmpeg -i %s -map 0:a:0 -v 0 -c:a flac -f flac -",
					Format:   "flac",
					FilePath: "tests/fixtures/test.flac",
					Duration: 3,
					Offset:   1,
				})
				Expect(err).ToNot(HaveOccurred())
				defer stream.Close()

				out, err := io.ReadAll(stream)
				Expect(err).ToNot(HaveOccurred())
				Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
			})
		})

		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))
		})
	})
})