Skip to content

Commit d786b68

Browse files
committed
refactor: improve ffmpeg binary lookup
1 parent 508418f commit d786b68

2 files changed

Lines changed: 169 additions & 21 deletions

File tree

lib/ffmpeg_core/configuration.rb

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,54 @@ def detect_encoders
4040
end
4141

4242
def detect_binary(name)
43-
# Check common locations
44-
paths = ENV["PATH"].split(File::PATH_SEPARATOR)
45-
paths.each do |path|
46-
binary = if Gem.win_platform? then File.join(path, name + ".exe") else File.join(path, name) end
47-
return binary if File.executable?(binary)
48-
end
43+
binary_from_env(name) ||
44+
binary_from_system_lookup(name) ||
45+
binary_from_known_paths(name) ||
46+
raise(BinaryNotFoundError, <<~MSG)
47+
#{name} not found.
48+
Install FFmpeg and ensure it's in PATH.
49+
macOS: brew install ffmpeg
50+
Linux: apt install ffmpeg / yum install ffmpeg
51+
Windows: choco install ffmpeg or scoop install ffmpeg
52+
MSG
53+
end
4954

50-
# Homebrew locations (macOS)
51-
homebrew_paths = [
52-
"/opt/homebrew/bin/#{name}", # Apple Silicon
53-
"/usr/local/bin/#{name}" # Intel
54-
]
55-
homebrew_paths.each do |path|
56-
return path if File.executable?(path)
57-
end
55+
# Checks FFMPEGCORE_<NAME> env variable for an explicit binary override.
56+
def binary_from_env(name)
57+
path = ENV["FFMPEGCORE_#{name.upcase}"]
58+
path if path && File.executable?(path)
59+
end
60+
61+
# Uses the OS-native `which` (Unix) or `where` (Windows) command.
62+
def binary_from_system_lookup(name)
63+
cmd = Gem.win_platform? ? "where #{name}" : "which #{name}"
64+
stdout, status = Open3.capture2(cmd)
65+
return unless status.success?
5866

59-
raise BinaryNotFoundError, "#{name} binary not found. Please install FFmpeg: brew install ffmpeg"
67+
path = stdout.lines.first&.strip
68+
path if path && File.executable?(path)
69+
end
70+
71+
# Falls back to a list of well-known installation paths.
72+
def binary_from_known_paths(name)
73+
known_paths(name).find { |p| File.executable?(p) }
74+
end
75+
76+
def known_paths(name)
77+
if Gem.win_platform?
78+
[
79+
"C:/ffmpeg/bin/#{name}.exe",
80+
"C:/ProgramData/chocolatey/bin/#{name}.exe",
81+
"#{ENV["USERPROFILE"]}/scoop/apps/ffmpeg/current/bin/#{name}.exe"
82+
]
83+
else
84+
[
85+
"/opt/homebrew/bin/#{name}", # macOS ARM
86+
"/usr/local/bin/#{name}", # macOS Intel / Linux
87+
"/usr/bin/#{name}",
88+
"/snap/bin/#{name}"
89+
]
90+
end
6091
end
6192
end
6293

spec/ffmpeg_core/configuration_spec.rb

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,154 @@
44
describe "#initialize" do
55
it "detects ffmpeg binary" do
66
config = described_class.new
7-
expect(config.ffmpeg_binary).not_to be_nil
87
expect(config.ffmpeg_binary).to include("ffmpeg")
98
end
109

1110
it "detects ffprobe binary" do
1211
config = described_class.new
13-
expect(config.ffprobe_binary).not_to be_nil
1412
expect(config.ffprobe_binary).to include("ffprobe")
1513
end
1614

17-
it "sets default timeout" do
15+
it "sets default timeout to 30 seconds" do
16+
expect(described_class.new.timeout).to eq(30)
17+
end
18+
end
19+
20+
describe "#detect_binary (via initialize)" do
21+
context "when FFMPEGCORE_FFMPEG points to an executable file" do
22+
it "uses the env override" do
23+
allow(File).to receive(:executable?).and_call_original
24+
allow(File).to receive(:executable?).with("/custom/ffmpeg").and_return(true)
25+
26+
with_env("FFMPEGCORE_FFMPEG" => "/custom/ffmpeg") do
27+
config = described_class.new
28+
expect(config.ffmpeg_binary).to eq("/custom/ffmpeg")
29+
end
30+
end
31+
end
32+
33+
context "when FFMPEGCORE_FFMPEG points to a non-executable path" do
34+
it "falls through to system lookup" do
35+
allow(File).to receive(:executable?).and_call_original
36+
allow(File).to receive(:executable?).with("/not/executable").and_return(false)
37+
38+
with_env("FFMPEGCORE_FFMPEG" => "/not/executable") do
39+
# Should not raise — falls through to which/where and finds real ffmpeg
40+
expect { described_class.new }.not_to raise_error
41+
end
42+
end
43+
end
44+
45+
context "when binary is not found anywhere" do
46+
before do
47+
# Make all File.executable? checks return false
48+
allow(File).to receive(:executable?).and_return(false)
49+
# Make system lookup fail
50+
allow(Open3).to receive(:capture2).and_return(["", double(success?: false)])
51+
end
52+
53+
# Restore real behaviour before spec_helper's global `after` runs
54+
# `reset_configuration!`, otherwise the stubs make *that* raise too.
55+
after do
56+
allow(File).to receive(:executable?).and_call_original
57+
allow(Open3).to receive(:capture2).and_call_original
58+
end
59+
60+
it "raises BinaryNotFoundError with install instructions" do
61+
expect { described_class.new }.to raise_error(
62+
FFmpegCore::BinaryNotFoundError,
63+
/ffmpeg not found/i
64+
)
65+
end
66+
67+
it "includes platform hints in the error message" do
68+
expect { described_class.new }.to raise_error(
69+
FFmpegCore::BinaryNotFoundError,
70+
/brew install ffmpeg/
71+
)
72+
end
73+
end
74+
75+
context "when binary is not in PATH but exists at a known location" do
76+
let(:known_ffmpeg_path) { "/opt/homebrew/bin/ffmpeg" }
77+
let(:known_ffprobe_path) { "/opt/homebrew/bin/ffprobe" }
78+
79+
before do
80+
allow(File).to receive(:executable?).and_return(false)
81+
allow(File).to receive(:executable?).with(known_ffmpeg_path).and_return(true)
82+
allow(File).to receive(:executable?).with(known_ffprobe_path).and_return(true)
83+
allow(Open3).to receive(:capture2).and_return(["", double(success?: false)])
84+
end
85+
86+
# Same reason as above: restore real methods before global after hook fires.
87+
after do
88+
allow(File).to receive(:executable?).and_call_original
89+
allow(Open3).to receive(:capture2).and_call_original
90+
end
91+
92+
it "finds the binary at the known location" do
93+
config = described_class.new
94+
expect(config.ffmpeg_binary).to eq(known_ffmpeg_path)
95+
end
96+
end
97+
end
98+
99+
describe "#encoders" do
100+
it "returns a set of encoder names" do
101+
expect(described_class.new.encoders).to be_a(Set)
102+
end
103+
104+
it "includes common encoders" do
105+
encoders = described_class.new.encoders
106+
expect(encoders).to include("libx264").or include("aac")
107+
end
108+
109+
it "returns empty Set when ffmpeg binary is nil" do
18110
config = described_class.new
19-
expect(config.timeout).to eq(30)
111+
config.ffmpeg_binary = nil
112+
expect(config.encoders).to eq(Set.new)
20113
end
21114
end
22115
end
23116

24117
RSpec.describe FFmpegCore do
25118
describe ".configuration" do
26-
it "returns configuration instance" do
119+
it "returns a Configuration instance" do
27120
expect(described_class.configuration).to be_a(FFmpegCore::Configuration)
28121
end
122+
123+
it "returns the same instance on subsequent calls" do
124+
first = described_class.configuration
125+
second = described_class.configuration
126+
expect(first).to equal(second)
127+
end
29128
end
30129

31130
describe ".configure" do
32-
it "yields configuration block" do
131+
it "yields the configuration object" do
33132
described_class.configure do |config|
34133
config.timeout = 60
35134
end
36135

37136
expect(described_class.configuration.timeout).to eq(60)
38137
end
39138
end
139+
140+
describe ".reset_configuration!" do
141+
it "resets to a fresh Configuration instance" do
142+
original = described_class.configuration
143+
described_class.reset_configuration!
144+
expect(described_class.configuration).not_to equal(original)
145+
end
146+
end
147+
end
148+
149+
# Helpers
150+
151+
def with_env(vars, &block)
152+
old = vars.transform_values { |key| ENV.fetch(key, nil) }
153+
vars.each { |k, v| ENV[k] = v }
154+
yield
155+
ensure
156+
vars.each { |k, _| old[k].nil? ? ENV.delete(k) : ENV.store(k, old[k]) }
40157
end

0 commit comments

Comments
 (0)