d8bc41fbb1
* fix: use ADTS format for AAC transcoding to avoid silent output on ffmpeg 8.0+ The fragmented MP4 muxer (`-f ipod -movflags frag_keyframe+empty_moov`) produces corrupt/silent audio when ffmpeg pipes to stdout, confirmed on ffmpeg 8.0+. The moof atom offset values are zeroed out in pipe mode, causing AAC decoder errors. Switch to `-f adts` (raw AAC framing) which works reliably via pipe and is widely supported by clients including UPnP/Sonos devices. * fix: exclude AAC from transcode decision, as it is not working for Sonos. Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org>
484 lines
13 KiB
Go
484 lines
13 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/navidrome/navidrome/conf"
|
|
"github.com/navidrome/navidrome/consts"
|
|
"github.com/navidrome/navidrome/log"
|
|
)
|
|
|
|
// TranscodeOptions contains all parameters for a transcoding operation.
|
|
type TranscodeOptions struct {
|
|
Command string // DB command template (used to detect custom vs default)
|
|
Format string // Target format (mp3, opus, aac, flac)
|
|
FilePath string
|
|
BitRate int // kbps, 0 = codec default
|
|
SampleRate int // 0 = no constraint
|
|
Channels int // 0 = no constraint
|
|
BitDepth int // 0 = no constraint; valid values: 16, 24, 32
|
|
Offset int // seconds
|
|
}
|
|
|
|
// AudioProbeResult contains authoritative audio stream properties from ffprobe.
|
|
type AudioProbeResult struct {
|
|
Codec string `json:"codec"`
|
|
Profile string `json:"profile,omitempty"`
|
|
BitRate int `json:"bitRate"`
|
|
SampleRate int `json:"sampleRate"`
|
|
BitDepth int `json:"bitDepth"`
|
|
Channels int `json:"channels"`
|
|
}
|
|
|
|
type FFmpeg interface {
|
|
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
|
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
|
Probe(ctx context.Context, files []string) (string, error)
|
|
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
|
CmdPath() (string, error)
|
|
IsAvailable() bool
|
|
Version() string
|
|
}
|
|
|
|
func New() FFmpeg {
|
|
return &ffmpeg{}
|
|
}
|
|
|
|
const (
|
|
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
|
probeCmd = "ffmpeg %s -f ffmetadata"
|
|
probeAudioStreamCmd = "ffprobe -v quiet -select_streams a:0 -print_format json -show_streams -show_format %s"
|
|
)
|
|
|
|
type ffmpeg struct{}
|
|
|
|
func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(opts.FilePath); err != nil {
|
|
return nil, err
|
|
}
|
|
var args []string
|
|
if isDefaultCommand(opts.Format, opts.Command) {
|
|
args = buildDynamicArgs(opts)
|
|
} else {
|
|
args = buildTemplateArgs(opts)
|
|
}
|
|
return e.start(ctx, args)
|
|
}
|
|
|
|
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(path); err != nil {
|
|
return nil, err
|
|
}
|
|
args := createFFmpegCommand(extractImageCmd, path, 0, 0)
|
|
return e.start(ctx, args)
|
|
}
|
|
|
|
func fileExists(path string) error {
|
|
s, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.IsDir() {
|
|
return fmt.Errorf("'%s' is a directory", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return "", err
|
|
}
|
|
args := createProbeCommand(probeCmd, files)
|
|
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
output, _ := cmd.CombinedOutput()
|
|
return string(output), nil
|
|
}
|
|
|
|
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
|
if _, err := ffmpegCmd(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := fileExists(filePath); err != nil {
|
|
return nil, err
|
|
}
|
|
args := createFFmpegCommand(probeAudioStreamCmd, filePath, 0, 0)
|
|
log.Trace(ctx, "Executing ffprobe command", "args", args)
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("running ffprobe on %q: %w", filePath, err)
|
|
}
|
|
return parseProbeOutput(output)
|
|
}
|
|
|
|
type probeOutput struct {
|
|
Streams []probeStream `json:"streams"`
|
|
Format probeFormat `json:"format"`
|
|
}
|
|
|
|
type probeFormat struct {
|
|
BitRate string `json:"bit_rate"`
|
|
}
|
|
|
|
type probeStream struct {
|
|
CodecName string `json:"codec_name"`
|
|
CodecType string `json:"codec_type"`
|
|
Profile string `json:"profile"`
|
|
SampleRate string `json:"sample_rate"`
|
|
BitRate string `json:"bit_rate"`
|
|
Channels int `json:"channels"`
|
|
BitsPerSample int `json:"bits_per_sample"`
|
|
BitsPerRawSample string `json:"bits_per_raw_sample"`
|
|
}
|
|
|
|
func parseProbeOutput(data []byte) (*AudioProbeResult, error) {
|
|
var output probeOutput
|
|
if err := json.Unmarshal(data, &output); err != nil {
|
|
return nil, fmt.Errorf("parsing ffprobe output: %w", err)
|
|
}
|
|
|
|
for _, s := range output.Streams {
|
|
if s.CodecType != "audio" {
|
|
continue
|
|
}
|
|
bitDepth := s.BitsPerSample
|
|
if bitDepth == 0 && s.BitsPerRawSample != "" {
|
|
bitDepth, _ = strconv.Atoi(s.BitsPerRawSample)
|
|
}
|
|
result := &AudioProbeResult{
|
|
Codec: s.CodecName,
|
|
Channels: s.Channels,
|
|
BitDepth: bitDepth,
|
|
}
|
|
|
|
// Profile: "unknown" → empty
|
|
if s.Profile != "" && !strings.EqualFold(s.Profile, "unknown") {
|
|
result.Profile = s.Profile
|
|
}
|
|
|
|
// Sample rate: string → int
|
|
if s.SampleRate != "" {
|
|
result.SampleRate, _ = strconv.Atoi(s.SampleRate)
|
|
}
|
|
|
|
// Bit rate: bps string → kbps int
|
|
if s.BitRate != "" {
|
|
bps, _ := strconv.Atoi(s.BitRate)
|
|
result.BitRate = bps / 1000
|
|
}
|
|
|
|
// Fallback to format-level bit_rate (needed for FLAC, Opus, etc.)
|
|
if result.BitRate == 0 && output.Format.BitRate != "" {
|
|
bps, _ := strconv.Atoi(output.Format.BitRate)
|
|
result.BitRate = bps / 1000
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("no audio stream found in ffprobe output")
|
|
}
|
|
|
|
func (e *ffmpeg) CmdPath() (string, error) {
|
|
return ffmpegCmd()
|
|
}
|
|
|
|
func (e *ffmpeg) IsAvailable() bool {
|
|
_, err := ffmpegCmd()
|
|
return err == nil
|
|
}
|
|
|
|
// Version executes ffmpeg -version and extracts the version from the output.
|
|
// Sample output: ffmpeg version 6.0 Copyright (c) 2000-2023 the FFmpeg developers
|
|
func (e *ffmpeg) Version() string {
|
|
cmd, err := ffmpegCmd()
|
|
if err != nil {
|
|
return "N/A"
|
|
}
|
|
out, err := exec.Command(cmd, "-version").CombinedOutput() // #nosec
|
|
if err != nil {
|
|
return "N/A"
|
|
}
|
|
parts := strings.Split(string(out), " ")
|
|
if len(parts) < 3 {
|
|
return "N/A"
|
|
}
|
|
return parts[2]
|
|
}
|
|
|
|
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
|
|
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
|
|
j := &ffCmd{args: args}
|
|
j.PipeReader, j.out = io.Pipe()
|
|
err := j.start(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
go j.wait()
|
|
return j, nil
|
|
}
|
|
|
|
type ffCmd struct {
|
|
*io.PipeReader
|
|
out *io.PipeWriter
|
|
args []string
|
|
cmd *exec.Cmd
|
|
}
|
|
|
|
func (j *ffCmd) start(ctx context.Context) error {
|
|
cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
|
|
cmd.Stdout = j.out
|
|
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
|
cmd.Stderr = os.Stderr
|
|
} else {
|
|
cmd.Stderr = io.Discard
|
|
}
|
|
j.cmd = cmd
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return fmt.Errorf("starting cmd: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (j *ffCmd) wait() {
|
|
if err := j.cmd.Wait(); err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
_ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()))
|
|
} else {
|
|
_ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err))
|
|
}
|
|
return
|
|
}
|
|
_ = j.out.Close()
|
|
}
|
|
|
|
// formatCodecMap maps target format to ffmpeg codec flag.
|
|
var formatCodecMap = map[string]string{
|
|
"mp3": "libmp3lame",
|
|
"opus": "libopus",
|
|
"aac": "aac",
|
|
"flac": "flac",
|
|
}
|
|
|
|
// formatOutputMap maps target format to ffmpeg output format flag (-f).
|
|
var formatOutputMap = map[string]string{
|
|
"mp3": "mp3",
|
|
"opus": "opus",
|
|
"aac": "adts",
|
|
"flac": "flac",
|
|
}
|
|
|
|
// defaultCommands is used to detect whether a user has customized their transcoding command.
|
|
var defaultCommands = func() map[string]string {
|
|
m := make(map[string]string, len(consts.DefaultTranscodings))
|
|
for _, t := range consts.DefaultTranscodings {
|
|
m[t.TargetFormat] = t.Command
|
|
}
|
|
return m
|
|
}()
|
|
|
|
// isDefaultCommand returns true if the command matches the known default for this format.
|
|
func isDefaultCommand(format, command string) bool {
|
|
return defaultCommands[format] == command
|
|
}
|
|
|
|
// buildDynamicArgs programmatically constructs ffmpeg arguments for known formats,
|
|
// including all transcoding parameters (bitrate, sample rate, channels).
|
|
func buildDynamicArgs(opts TranscodeOptions) []string {
|
|
cmdPath, _ := ffmpegCmd()
|
|
args := []string{cmdPath, "-i", opts.FilePath}
|
|
|
|
if opts.Offset > 0 {
|
|
args = append(args, "-ss", strconv.Itoa(opts.Offset))
|
|
}
|
|
|
|
args = append(args, "-map", "0:a:0")
|
|
|
|
if codec, ok := formatCodecMap[opts.Format]; ok {
|
|
args = append(args, "-c:a", codec)
|
|
}
|
|
|
|
if opts.BitRate > 0 {
|
|
args = append(args, "-b:a", strconv.Itoa(opts.BitRate)+"k")
|
|
}
|
|
if opts.SampleRate > 0 {
|
|
args = append(args, "-ar", strconv.Itoa(opts.SampleRate))
|
|
}
|
|
if opts.Channels > 0 {
|
|
args = append(args, "-ac", strconv.Itoa(opts.Channels))
|
|
}
|
|
// Only pass -sample_fmt for lossless output formats where bit depth matters.
|
|
// Lossy codecs (mp3, aac, opus) handle sample format conversion internally,
|
|
// and passing interleaved formats like "s16" causes silent failures.
|
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
|
args = append(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
|
}
|
|
|
|
args = append(args, "-v", "0")
|
|
|
|
if outputFmt, ok := formatOutputMap[opts.Format]; ok {
|
|
args = append(args, "-f", outputFmt)
|
|
}
|
|
|
|
args = append(args, "-")
|
|
return args
|
|
}
|
|
|
|
// buildTemplateArgs handles user-customized command templates, with dynamic injection
|
|
// of sample rate, channels, and bit depth when requested by the transcode decision.
|
|
// Note: these flags are injected unconditionally when non-zero, even if the template
|
|
// already includes them. FFmpeg uses the last occurrence of duplicate flags.
|
|
func buildTemplateArgs(opts TranscodeOptions) []string {
|
|
args := createFFmpegCommand(opts.Command, opts.FilePath, opts.BitRate, opts.Offset)
|
|
|
|
// Dynamically inject -ar, -ac, and -sample_fmt before the output target
|
|
if opts.SampleRate > 0 {
|
|
args = injectBeforeOutput(args, "-ar", strconv.Itoa(opts.SampleRate))
|
|
}
|
|
if opts.Channels > 0 {
|
|
args = injectBeforeOutput(args, "-ac", strconv.Itoa(opts.Channels))
|
|
}
|
|
if opts.BitDepth >= 16 && isLosslessOutputFormat(opts.Format) {
|
|
args = injectBeforeOutput(args, "-sample_fmt", bitDepthToSampleFmt(opts.BitDepth))
|
|
}
|
|
return args
|
|
}
|
|
|
|
// injectBeforeOutput inserts a flag and value before the trailing "-" (stdout output).
|
|
func injectBeforeOutput(args []string, flag, value string) []string {
|
|
if len(args) > 0 && args[len(args)-1] == "-" {
|
|
result := make([]string, 0, len(args)+2)
|
|
result = append(result, args[:len(args)-1]...)
|
|
result = append(result, flag, value, "-")
|
|
return result
|
|
}
|
|
return append(args, flag, value)
|
|
}
|
|
|
|
// isLosslessOutputFormat returns true if the format is a lossless audio format
|
|
// where preserving bit depth via -sample_fmt is meaningful.
|
|
// Note: this covers only formats ffmpeg can produce as output. For the full set of
|
|
// lossless formats used in transcoding decisions, see core/stream/codec.go:isLosslessFormat.
|
|
func isLosslessOutputFormat(format string) bool {
|
|
switch strings.ToLower(format) {
|
|
case "flac", "alac", "wav", "aiff":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bitDepthToSampleFmt converts a bit depth value to the ffmpeg sample_fmt string.
|
|
// FLAC only supports s16 and s32; for 24-bit sources, s32 is the correct format
|
|
// (ffmpeg packs 24-bit samples into 32-bit containers).
|
|
func bitDepthToSampleFmt(bitDepth int) string {
|
|
switch bitDepth {
|
|
case 16:
|
|
return "s16"
|
|
case 32:
|
|
return "s32"
|
|
default:
|
|
// 24-bit and other depths: use s32 (the next valid container size)
|
|
return "s32"
|
|
}
|
|
}
|
|
|
|
// Path will always be an absolute path
|
|
func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
|
var args []string
|
|
for _, s := range fixCmd(cmd) {
|
|
if strings.Contains(s, "%s") {
|
|
s = strings.ReplaceAll(s, "%s", path)
|
|
args = append(args, s)
|
|
if offset > 0 && !strings.Contains(cmd, "%t") {
|
|
args = append(args, "-ss", strconv.Itoa(offset))
|
|
}
|
|
} else {
|
|
s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset))
|
|
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
|
|
args = append(args, s)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func createProbeCommand(cmd string, inputs []string) []string {
|
|
var args []string
|
|
for _, s := range fixCmd(cmd) {
|
|
if s == "%s" {
|
|
for _, inp := range inputs {
|
|
args = append(args, "-i", inp)
|
|
}
|
|
} else {
|
|
args = append(args, s)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func fixCmd(cmd string) []string {
|
|
split := strings.Fields(cmd)
|
|
cmdPath, _ := ffmpegCmd()
|
|
for i, s := range split {
|
|
if s == "ffmpeg" || s == "ffmpeg.exe" {
|
|
split[i] = cmdPath
|
|
}
|
|
if s == "ffprobe" || s == "ffprobe.exe" {
|
|
split[i] = ffprobePath(cmdPath)
|
|
}
|
|
}
|
|
return split
|
|
}
|
|
|
|
// ffprobePath derives the ffprobe binary path from the resolved ffmpeg path.
|
|
func ffprobePath(ffmpegCmd string) string {
|
|
dir := filepath.Dir(ffmpegCmd)
|
|
base := filepath.Base(ffmpegCmd)
|
|
return filepath.Join(dir, strings.Replace(base, "ffmpeg", "ffprobe", 1))
|
|
}
|
|
|
|
func ffmpegCmd() (string, error) {
|
|
ffOnce.Do(func() {
|
|
if conf.Server.FFmpegPath != "" {
|
|
ffmpegPath = conf.Server.FFmpegPath
|
|
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
|
|
} else {
|
|
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")
|
|
if errors.Is(ffmpegErr, exec.ErrDot) {
|
|
log.Trace("ffmpeg found in current folder '.'")
|
|
ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg")
|
|
}
|
|
}
|
|
if ffmpegErr == nil {
|
|
log.Info("Found ffmpeg", "path", ffmpegPath)
|
|
return
|
|
}
|
|
})
|
|
return ffmpegPath, ffmpegErr
|
|
}
|
|
|
|
// These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead.
|
|
var (
|
|
ffOnce sync.Once
|
|
ffmpegPath string
|
|
ffmpegErr error
|
|
)
|