Make ffmpeg path configurable, also finds it automatically in current folder. Fixes #1932

This commit is contained in:
Deluan 2023-02-07 13:08:25 -05:00
parent b8c5e49dd3
commit 759ff844e2
8 changed files with 137 additions and 46 deletions

View File

@ -47,7 +47,7 @@ type configOptions struct {
IgnoredArticles string IgnoredArticles string
IndexGroups string IndexGroups string
SubsonicArtistParticipations bool SubsonicArtistParticipations bool
ProbeCommand string FFmpegPath string
CoverArtPriority string CoverArtPriority string
CoverJpegQuality int CoverJpegQuality int
EnableGravatar bool EnableGravatar bool
@ -246,7 +246,7 @@ func init() {
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
viper.SetDefault("subsonicartistparticipations", false) viper.SetDefault("subsonicartistparticipations", false)
viper.SetDefault("probecommand", "ffmpeg %s -f ffmetadata") viper.SetDefault("ffmpegpath", "")
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
viper.SetDefault("coverjpegquality", 75) viper.SetDefault("coverjpegquality", 75)
viper.SetDefault("enablegravatar", false) viper.SetDefault("enablegravatar", false)

View File

@ -9,34 +9,61 @@ import (
"os/exec" "os/exec"
"strconv" "strconv"
"strings" "strings"
"sync"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
) )
type FFmpeg interface { type FFmpeg interface {
Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error)
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
// TODO Move scanner ffmpeg probe to here Probe(ctx context.Context, files []string) (string, error)
CmdPath() (string, error)
} }
func New() FFmpeg { func New() FFmpeg {
return &ffmpeg{} return &ffmpeg{}
} }
const extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -" const (
extractImageCmd = "ffmpeg -i %s -an -vcodec copy -f image2pipe -"
probeCmd = "ffmpeg %s -f ffmetadata"
)
type ffmpeg struct{} type ffmpeg struct{}
func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) { func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate int) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(command, path, maxBitRate) args := createFFmpegCommand(command, path, maxBitRate)
return e.start(ctx, args) return e.start(ctx, args)
} }
func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) {
if _, err := ffmpegCmd(); err != nil {
return nil, err
}
args := createFFmpegCommand(extractImageCmd, path, 0) args := createFFmpegCommand(extractImageCmd, path, 0)
return e.start(ctx, args) return e.start(ctx, args)
} }
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) CmdPath() (string, error) {
return ffmpegCmd()
}
func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) { func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error) {
log.Trace(ctx, "Executing ffmpeg command", "cmd", args) log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args} j := &ffCmd{args: args}
@ -87,7 +114,7 @@ func (j *ffCmd) wait() {
// Path will always be an absolute path // Path will always be an absolute path
func createFFmpegCommand(cmd, path string, maxBitRate int) []string { func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
split := strings.Split(cmd, " ") split := strings.Split(fixCmd(cmd), " ")
for i, s := range split { for i, s := range split {
s = strings.ReplaceAll(s, "%s", path) s = strings.ReplaceAll(s, "%s", path)
s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate))
@ -96,3 +123,59 @@ func createFFmpegCommand(cmd, path string, maxBitRate int) []string {
return split return split
} }
func createProbeCommand(cmd string, inputs []string) []string {
split := strings.Split(fixCmd(cmd), " ")
var args []string
for _, s := range split {
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.Split(cmd, " ")
var result []string
cmdPath, _ := ffmpegCmd()
for _, s := range split {
if s == "ffmpeg" || s == "ffmpeg.exe" {
result = append(result, cmdPath)
} else {
result = append(result, s)
}
}
return strings.Join(result, " ")
}
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
}
var (
ffOnce sync.Once
ffmpegPath string
ffmpegErr error
)

View File

@ -16,9 +16,23 @@ func TestFFmpeg(t *testing.T) {
RunSpecs(t, "FFmpeg Suite") RunSpecs(t, "FFmpeg Suite")
} }
var _ = Describe("createFFmpegCommand", func() { var _ = Describe("ffmpeg", func() {
It("creates a valid command line", func() { BeforeEach(func() {
args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123) _, _ = ffmpegCmd()
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) 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)
Expect(args).To(Equal([]string{"ffmpeg", "-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"}))
})
}) })
}) })

View File

@ -179,7 +179,7 @@ func (r *mediaFileRepository) DeleteByPath(basePath string) (int64, error) {
func (r *mediaFileRepository) removeNonAlbumArtistIds() error { func (r *mediaFileRepository) removeNonAlbumArtistIds() error {
upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id"))) upd := Update(r.tableName).Set("artist_id", "").Where(notExists("artist", ConcatExpr("id = artist_id")))
log.Debug(r.ctx, "Removing non-album artist_id") log.Debug(r.ctx, "Removing non-album artist_ids")
_, err := r.executeSQL(upd) _, err := r.executeSQL(upd)
return err return err
} }

View File

@ -2,33 +2,35 @@ package ffmpeg
import ( import (
"bufio" "bufio"
"context"
"errors" "errors"
"os/exec"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/scanner/metadata" "github.com/navidrome/navidrome/scanner/metadata"
) )
const ExtractorID = "ffmpeg" const ExtractorID = "ffmpeg"
type Extractor struct{} type Extractor struct {
ffmpeg ffmpeg.FFmpeg
}
func (e *Extractor) Parse(files ...string) (map[string]metadata.ParsedTags, error) { func (e *Extractor) Parse(files ...string) (map[string]metadata.ParsedTags, error) {
args := e.createProbeCommand(files) output, err := e.ffmpeg.Probe(context.TODO(), files)
if err != nil {
log.Trace("Executing command", "args", args) log.Error("Cannot use ffmpeg to extract tags. Aborting", err)
cmd := exec.Command(args[0], args[1:]...) // #nosec return nil, err
output, _ := cmd.CombinedOutput() }
fileTags := map[string]metadata.ParsedTags{} fileTags := map[string]metadata.ParsedTags{}
if len(output) == 0 { if len(output) == 0 {
return fileTags, errors.New("error extracting metadata files") return fileTags, errors.New("error extracting metadata files")
} }
infos := e.parseOutput(string(output)) infos := e.parseOutput(output)
for file, info := range infos { for file, info := range infos {
tags, err := e.extractMetadata(file, info) tags, err := e.extractMetadata(file, info)
// Skip files with errors // Skip files with errors
@ -197,22 +199,6 @@ func (e *Extractor) parseChannels(tag string) string {
} }
// Inputs will always be absolute paths // Inputs will always be absolute paths
func (e *Extractor) createProbeCommand(inputs []string) []string {
split := strings.Split(conf.Server.ProbeCommand, " ")
args := make([]string, 0)
for _, s := range split {
if s == "%s" {
for _, inp := range inputs {
args = append(args, "-i", inp)
}
} else {
args = append(args, s)
}
}
return args
}
func init() { func init() {
metadata.RegisterExtractor(ExtractorID, &Extractor{}) metadata.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()})
} }

View File

@ -280,11 +280,6 @@ Input #0, mp3, from '/Users/deluan/Music/Music/Media/_/Wyclef Jean - From the Hu
}) })
}) })
It("creates a valid command line", func() {
args := e.createProbeCommand([]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"}))
})
It("parses an integer TBPM tag", func() { It("parses an integer TBPM tag", func() {
const output = ` const output = `
Input #0, mp3, from 'tests/fixtures/test.mp3': Input #0, mp3, from 'tests/fixtures/test.mp3':

View File

@ -3,12 +3,12 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"os/exec"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
) )
@ -77,9 +77,9 @@ func createJWTSecret(ds model.DataStore) error {
} }
func checkFfmpegInstallation() { func checkFfmpegInstallation() {
path, err := exec.LookPath("ffmpeg") f := ffmpeg.New()
_, err := f.CmdPath()
if err == nil { if err == nil {
log.Info("Found ffmpeg", "path", path)
return return
} }
log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err)

View File

@ -20,20 +20,33 @@ type MockFFmpeg struct {
Error error Error error
} }
func (ff *MockFFmpeg) Transcode(ctx context.Context, cmd, path string, maxBitRate int) (f io.ReadCloser, err error) { func (ff *MockFFmpeg) Transcode(_ context.Context, _, _ string, _ int) (f io.ReadCloser, err error) {
if ff.Error != nil { if ff.Error != nil {
return nil, ff.Error return nil, ff.Error
} }
return ff, nil return ff, nil
} }
func (ff *MockFFmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { func (ff *MockFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) {
if ff.Error != nil { if ff.Error != nil {
return nil, ff.Error return nil, ff.Error
} }
return ff, nil return ff, nil
} }
func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) {
if ff.Error != nil {
return "", ff.Error
}
return "", nil
}
func (ff *MockFFmpeg) CmdPath() (string, error) {
if ff.Error != nil {
return "", ff.Error
}
return "ffmpeg", nil
}
func (ff *MockFFmpeg) Read(p []byte) (n int, err error) { func (ff *MockFFmpeg) Read(p []byte) (n int, err error) {
ff.lock.Lock() ff.lock.Lock()
defer ff.lock.Unlock() defer ff.lock.Unlock()