From 36a7be9eaf822447ce531ef5ee06c87dae1f8698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 7 Apr 2026 20:11:38 -0400 Subject: [PATCH] fix(transcoding): include ffprobe in MSI and fall back gracefully when absent (#5326) * fix(msi): include ffprobe executable in MSI build Signed-off-by: Deluan * feat(ffmpeg): add IsProbeAvailable() to FFmpeg interface Add runtime check for ffprobe binary availability with cached result and startup logging. When ffprobe is missing, logs a warning at startup. * feat(stream): guard MakeDecision behind ffprobe availability When ffprobe is not available, MakeDecision returns a decision with ErrorReason set and both CanDirectPlay and CanTranscode false, instead of failing with an opaque exec error. * feat(subsonic): only advertise transcoding extension when ffprobe is available The OpenSubsonic transcoding extension is now conditionally included based on ffprobe availability, so clients know not to call getTranscodeDecision when ffprobe is missing. * refactor(ffmpeg): move ffprobe startup warning to initial_setup Move the ffprobe availability warning from the lazy IsProbeAvailable() check to checkFFmpegInstallation() in server/initial_setup.go, alongside the existing ffmpeg warning. This ensures the warning appears at startup rather than on first endpoint call. * fix(e2e): set noopFFmpeg.IsProbeAvailable to true The e2e tests use pre-populated probe data and don't need a real ffprobe binary. Setting IsProbeAvailable to true allows the transcode decision logic to proceed normally in e2e tests. * fix(stream): only guard on ffprobe when probing is needed Move the IsProbeAvailable() guard inside the SkipProbe check so that legacy stream requests (which pass SkipProbe: true) are not blocked when ffprobe is missing. The guard only applies when probing is actually required (i.e., getTranscodeDecision endpoint). * refactor(stream): fall back to tag metadata when ffprobe is unavailable Instead of blocking getTranscodeDecision when ffprobe is missing, fall back to tag-based metadata (same behavior as /rest/stream). The transcoding extension is always advertised. A startup warning still alerts admins when ffprobe is not found. * fix(stream): downgrade ffprobe-unavailable log to Debug Avoids log spam when clients call getTranscodeDecision repeatedly without ffprobe installed. The startup warning in initial_setup.go already alerts admins at Warn level. --------- Signed-off-by: Deluan --- core/ffmpeg/ffmpeg.go | 16 ++++++++++++++++ core/stream/decider.go | 12 ++++++++---- core/stream/decider_test.go | 1 + release/wix/build_msi.sh | 3 ++- release/wix/navidrome.wxs | 5 +++++ server/e2e/e2e_suite_test.go | 1 + server/initial_setup.go | 13 ++++++++----- tests/mock_ffmpeg.go | 7 ++++++- 8 files changed, 47 insertions(+), 11 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 33d6733c8..c034ca7d0 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -49,6 +49,7 @@ type FFmpeg interface { ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) CmdPath() (string, error) IsAvailable() bool + IsProbeAvailable() bool Version() string } @@ -224,6 +225,19 @@ func (e *ffmpeg) IsAvailable() bool { return err == nil } +func (e *ffmpeg) IsProbeAvailable() bool { + if _, err := ffmpegCmd(); err != nil { + return false + } + probeOnce.Do(func() { + probePath := ffprobePath(ffmpegPath) + if _, err := exec.LookPath(probePath); err == nil { + probeAvail = true + } + }) + return probeAvail +} + // 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 { @@ -533,4 +547,6 @@ var ( ffOnce sync.Once ffmpegPath string ffmpegErr error + probeOnce sync.Once + probeAvail bool ) diff --git a/core/stream/decider.go b/core/stream/decider.go index 6c1f06a06..713c779fe 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -44,10 +44,14 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, var probe *ffmpeg.AudioProbeResult if !opts.SkipProbe { - var err error - probe, err = s.ensureProbed(ctx, mf) - if err != nil { - return nil, err + if !s.ff.IsProbeAvailable() { + log.Debug(ctx, "ffprobe not available, using tag metadata for transcode decision", "mediaID", mf.ID) + } else { + var err error + probe, err = s.ensureProbed(ctx, mf) + if err != nil { + return nil, err + } } } diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 9eaa00990..c776cbdc3 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1164,6 +1164,7 @@ var _ = Describe("Decider", func() { Expect(bitrate).To(Equal(fallbackBitrate)) }) }) + }) Describe("ensureProbed", func() { diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index 7e595311e..a8781a965 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -43,8 +43,9 @@ FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}" wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ "https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip" rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg" -unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" +unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe" cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUTPUT_DIR" +cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffprobe.exe "$MSI_OUTPUT_DIR" cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR" cp "$BINARY" "$MSI_OUTPUT_DIR" diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 8ebba4632..6d94bab9d 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -67,6 +67,10 @@ + + + + @@ -87,6 +91,7 @@ + diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 262a5ed36..03fa9bbef 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -337,6 +337,7 @@ func (n noopFFmpeg) ConvertAnimatedImage(context.Context, io.Reader, int, int) ( func (n noopFFmpeg) CmdPath() (string, error) { return "", nil } func (n noopFFmpeg) IsAvailable() bool { return false } +func (n noopFFmpeg) IsProbeAvailable() bool { return true } func (n noopFFmpeg) Version() string { return "noop" } // noopArchiver implements core.Archiver diff --git a/server/initial_setup.go b/server/initial_setup.go index d50f25958..7e974dc21 100644 --- a/server/initial_setup.go +++ b/server/initial_setup.go @@ -68,13 +68,16 @@ func createInitialAdminUser(ds model.DataStore, initialPassword string) error { func checkFFmpegInstallation() { f := ffmpeg.New() _, err := f.CmdPath() - if err == nil { + if err != nil { + log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if conf.Server.Scanner.Extractor == "ffmpeg" { + log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") + conf.Server.Scanner.Extractor = "taglib" + } return } - log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) - if conf.Server.Scanner.Extractor == "ffmpeg" { - log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") - conf.Server.Scanner.Extractor = "taglib" + if !f.IsProbeAvailable() { + log.Warn("Unable to find ffprobe. Transcoding decisions will be limited") } } diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go index 346209b71..f9862767e 100644 --- a/tests/mock_ffmpeg.go +++ b/tests/mock_ffmpeg.go @@ -12,7 +12,7 @@ import ( ) func NewMockFFmpeg(data string) *MockFFmpeg { - return &MockFFmpeg{Reader: strings.NewReader(data)} + return &MockFFmpeg{Reader: strings.NewReader(data), ProbeAvailable: true} } type MockFFmpeg struct { @@ -21,12 +21,17 @@ type MockFFmpeg struct { closed atomic.Bool Error error ProbeAudioResult *ffmpeg.AudioProbeResult + ProbeAvailable bool } func (ff *MockFFmpeg) IsAvailable() bool { return true } +func (ff *MockFFmpeg) IsProbeAvailable() bool { + return ff.ProbeAvailable +} + func (ff *MockFFmpeg) Transcode(_ context.Context, _ ffmpeg.TranscodeOptions) (io.ReadCloser, error) { if ff.Error != nil { return nil, ff.Error