ffmpeg_light/
command.rs

1//! Low-level helpers for invoking the `ffmpeg` and `ffprobe` binaries.
2
3use 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/// Paths to ffmpeg/ffprobe binaries used by the crate.
15#[derive(Debug, Clone)]
16pub struct FfmpegBinaryPaths {
17    ffmpeg: PathBuf,
18    ffprobe: PathBuf,
19}
20
21impl FfmpegBinaryPaths {
22    /// Locate binaries on PATH.
23    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    /// Override binaries manually.
34    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    /// Path to the ffmpeg binary.
46    pub fn ffmpeg(&self) -> &Path {
47        &self.ffmpeg
48    }
49
50    /// Path to the ffprobe binary.
51    pub fn ffprobe(&self) -> &Path {
52        &self.ffprobe
53    }
54}
55
56/// Builder around `ffmpeg` command invocations.
57#[derive(Debug)]
58pub struct FfmpegCommand {
59    binary: PathBuf,
60    args: Vec<OsString>,
61}
62
63impl FfmpegCommand {
64    /// Start building a command using the provided binary path.
65    pub fn new(binary: impl Into<PathBuf>) -> Self {
66        Self {
67            binary: binary.into(),
68            args: Vec::new(),
69        }
70    }
71
72    /// Append an argument.
73    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    /// Append multiple arguments.
79    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    /// Run the command and inherit stdout.
102    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    /// Run the command and capture stdout/stderr.
115    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    /// Run the command asynchronously (requires the `tokio` feature).
122    #[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    /// Run the command asynchronously and capture stdout/stderr (requires `tokio`).
136    #[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
144/// Specialized command for `ffprobe` returning JSON output.
145pub struct FfprobeCommand {
146    binary: PathBuf,
147    input: PathBuf,
148    extra_args: Vec<OsString>,
149}
150
151impl FfprobeCommand {
152    /// Create a command targeting a specific input file.
153    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    /// Add extra arguments (before ffprobe defaults, e.g. -v quiet).
162    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    /// Execute ffprobe and fetch the captured output.
203    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    /// Async variant of [`run`] (requires `tokio`).
216    #[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
230/// Convenience to run ffprobe and return stdout as string.
231pub 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/// Async helper returning the ffprobe JSON payload.
239#[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}