1use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::process::{Command, Output, Stdio};
6
7#[cfg(feature = "tokio")]
8use tokio::process::Command as TokioCommand;
9
10use which::which;
11
12use crate::error::{Error, Result};
13
14#[derive(Debug, Clone)]
16pub struct FfmpegBinaryPaths {
17 ffmpeg: PathBuf,
18 ffprobe: PathBuf,
19}
20
21impl FfmpegBinaryPaths {
22 pub fn auto() -> Result<Self> {
24 let ffmpeg = which("ffmpeg").map_err(|_| Error::FFmpegNotFound {
25 suggestion: Some("install ffmpeg with 'brew install ffmpeg' (macOS), 'apt install ffmpeg' (Linux), or download from ffmpeg.org".to_string()),
26 })?;
27 let ffprobe = which("ffprobe").map_err(|_| Error::FFmpegNotFound {
28 suggestion: Some("ffprobe comes with ffmpeg installation".to_string()),
29 })?;
30 Ok(Self { ffmpeg, ffprobe })
31 }
32
33 pub fn with_paths<P, Q>(ffmpeg: P, ffprobe: Q) -> Self
35 where
36 P: Into<PathBuf>,
37 Q: Into<PathBuf>,
38 {
39 Self {
40 ffmpeg: ffmpeg.into(),
41 ffprobe: ffprobe.into(),
42 }
43 }
44
45 pub fn ffmpeg(&self) -> &Path {
47 &self.ffmpeg
48 }
49
50 pub fn ffprobe(&self) -> &Path {
52 &self.ffprobe
53 }
54}
55
56#[derive(Debug)]
58pub struct FfmpegCommand {
59 binary: PathBuf,
60 args: Vec<OsString>,
61}
62
63impl FfmpegCommand {
64 pub fn new(binary: impl Into<PathBuf>) -> Self {
66 Self {
67 binary: binary.into(),
68 args: Vec::new(),
69 }
70 }
71
72 pub fn arg<T: AsRef<OsStr>>(&mut self, arg: T) -> &mut Self {
74 self.args.push(arg.as_ref().into());
75 self
76 }
77
78 pub fn args<T: AsRef<OsStr>>(&mut self, args: &[T]) -> &mut Self {
80 self.args.extend(args.iter().map(|arg| arg.as_ref().into()));
81 self
82 }
83
84 fn spawn_command(&self) -> Command {
85 let mut cmd = Command::new(&self.binary);
86 cmd.args(&self.args)
87 .stdout(Stdio::inherit())
88 .stderr(Stdio::piped());
89 cmd
90 }
91
92 #[cfg(feature = "tokio")]
93 fn spawn_async_command(&self) -> TokioCommand {
94 let mut cmd = TokioCommand::new(&self.binary);
95 cmd.args(&self.args)
96 .stdout(Stdio::inherit())
97 .stderr(Stdio::piped());
98 cmd
99 }
100
101 pub fn run(&self) -> Result<()> {
103 let output = self.run_with_output()?;
104 if !output.status.success() {
105 return Err(Error::command_failed(
106 display_path(&self.binary),
107 output.status.code(),
108 &output.stderr,
109 ));
110 }
111 Ok(())
112 }
113
114 pub fn run_with_output(&self) -> Result<Output> {
116 let mut cmd = self.spawn_command();
117 let output = cmd.output()?;
118 Ok(output)
119 }
120
121 #[cfg(feature = "tokio")]
123 pub async fn run_async(&self) -> Result<()> {
124 let output = self.run_with_output_async().await?;
125 if !output.status.success() {
126 return Err(Error::command_failed(
127 display_path(&self.binary),
128 output.status.code(),
129 &output.stderr,
130 ));
131 }
132 Ok(())
133 }
134
135 #[cfg(feature = "tokio")]
137 pub async fn run_with_output_async(&self) -> Result<Output> {
138 let mut cmd = self.spawn_async_command();
139 let output = cmd.output().await?;
140 Ok(output)
141 }
142}
143
144pub struct FfprobeCommand {
146 binary: PathBuf,
147 input: PathBuf,
148 extra_args: Vec<OsString>,
149}
150
151impl FfprobeCommand {
152 pub fn new(binary: impl Into<PathBuf>, input: impl Into<PathBuf>) -> Self {
154 Self {
155 binary: binary.into(),
156 input: input.into(),
157 extra_args: Vec::new(),
158 }
159 }
160
161 pub fn arg<T: AsRef<OsStr>>(&mut self, arg: T) -> &mut Self {
163 self.extra_args.push(arg.as_ref().into());
164 self
165 }
166
167 fn build_command(&self) -> Command {
168 let mut cmd = Command::new(&self.binary);
169 cmd.arg("-v")
170 .arg("quiet")
171 .arg("-print_format")
172 .arg("json")
173 .arg("-show_format")
174 .arg("-show_streams");
175 for arg in &self.extra_args {
176 cmd.arg(arg);
177 }
178 cmd.arg(&self.input)
179 .stdout(Stdio::piped())
180 .stderr(Stdio::piped());
181 cmd
182 }
183
184 #[cfg(feature = "tokio")]
185 fn build_async_command(&self) -> TokioCommand {
186 let mut cmd = TokioCommand::new(&self.binary);
187 cmd.arg("-v")
188 .arg("quiet")
189 .arg("-print_format")
190 .arg("json")
191 .arg("-show_format")
192 .arg("-show_streams");
193 for arg in &self.extra_args {
194 cmd.arg(arg);
195 }
196 cmd.arg(&self.input)
197 .stdout(Stdio::piped())
198 .stderr(Stdio::piped());
199 cmd
200 }
201
202 pub fn run(&self) -> Result<Output> {
204 let output = self.build_command().output()?;
205 if !output.status.success() {
206 return Err(Error::command_failed(
207 display_path(&self.binary),
208 output.status.code(),
209 &output.stderr,
210 ));
211 }
212 Ok(output)
213 }
214
215 #[cfg(feature = "tokio")]
217 pub async fn run_async(&self) -> Result<Output> {
218 let output = self.build_async_command().output().await?;
219 if !output.status.success() {
220 return Err(Error::command_failed(
221 display_path(&self.binary),
222 output.status.code(),
223 &output.stderr,
224 ));
225 }
226 Ok(output)
227 }
228}
229
230pub fn ffprobe_json(paths: &FfmpegBinaryPaths, input: impl AsRef<Path>) -> Result<String> {
232 let cmd = FfprobeCommand::new(paths.ffprobe(), input.as_ref());
233 let output = cmd.run()?;
234 let json = String::from_utf8(output.stdout).map_err(|err| Error::Parse(err.to_string()))?;
235 Ok(json)
236}
237
238#[cfg(feature = "tokio")]
240pub async fn ffprobe_json_async(
241 paths: &FfmpegBinaryPaths,
242 input: impl AsRef<Path>,
243) -> Result<String> {
244 let mut cmd = FfprobeCommand::new(paths.ffprobe(), input.as_ref());
245 let output = cmd.run_async().await?;
246 let json = String::from_utf8(output.stdout).map_err(|err| Error::Parse(err.to_string()))?;
247 Ok(json)
248}
249
250fn display_path(path: &Path) -> &str {
251 path.to_str().unwrap_or("<invalid utf8 path>")
252}
253
254#[cfg(test)]
255impl FfmpegCommand {
256 pub(crate) fn test_binary(&self) -> &Path {
257 &self.binary
258 }
259
260 pub(crate) fn test_args(&self) -> &[OsString] {
261 &self.args
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn stringify_args(cmd: &FfmpegCommand) -> Vec<String> {
270 cmd.test_args()
271 .iter()
272 .map(|arg| arg.to_string_lossy().into_owned())
273 .collect()
274 }
275
276 #[test]
277 fn ffmpeg_command_collects_args_in_order() {
278 let mut cmd = FfmpegCommand::new("/usr/bin/ffmpeg");
279 cmd.arg("-y")
280 .arg("-i")
281 .arg("input.mp4")
282 .args(&[OsStr::new("-c:v"), OsStr::new("libx264")]);
283
284 assert_eq!(cmd.test_binary(), Path::new("/usr/bin/ffmpeg"));
285 assert_eq!(
286 stringify_args(&cmd),
287 vec!["-y", "-i", "input.mp4", "-c:v", "libx264"]
288 );
289 }
290
291 #[test]
292 fn ffprobe_command_includes_json_flags() {
293 let cmd = FfprobeCommand::new("/usr/bin/ffprobe", "video.mkv");
294 let process = cmd.build_command();
295 let args = process
296 .get_args()
297 .map(|arg| arg.to_string_lossy().into_owned())
298 .collect::<Vec<_>>();
299
300 assert_eq!(
301 args,
302 vec![
303 "-v",
304 "quiet",
305 "-print_format",
306 "json",
307 "-show_format",
308 "-show_streams",
309 "video.mkv"
310 ]
311 );
312 }
313}