Skip to main content

ffmpeg_sidecar/
download.rs

1//! Utilities for downloading and unpacking FFmpeg binaries.
2
3use anyhow::Result;
4
5#[cfg(feature = "download_ffmpeg")]
6use std::path::{Path, PathBuf};
7
8#[cfg(feature = "download_ffmpeg")]
9fn keep_only_ffmpeg_from_env() -> bool {
10  keep_only_ffmpeg_from_value(std::env::var("KEEP_ONLY_FFMPEG").ok().as_deref())
11}
12
13#[cfg(feature = "download_ffmpeg")]
14fn keep_only_ffmpeg_from_value(value: Option<&str>) -> bool {
15  value
16    .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
17    .unwrap_or(false)
18}
19
20/// The default directory name for unpacking a downloaded FFmpeg release archive.
21pub const UNPACK_DIRNAME: &str = "ffmpeg_release_temp";
22
23/// URL of a manifest file containing the latest published build of FFmpeg. The
24/// correct URL for the target platform is baked in at compile time.
25pub fn ffmpeg_manifest_url() -> Result<&'static str> {
26  if cfg!(not(target_arch = "x86_64")) {
27    anyhow::bail!("Downloads must be manually provided for non-x86_64 architectures");
28  }
29
30  if cfg!(target_os = "windows") {
31    Ok("https://www.gyan.dev/ffmpeg/builds/release-version")
32  } else if cfg!(target_os = "macos") {
33    Ok("https://evermeet.cx/ffmpeg/info/ffmpeg/release")
34  } else if cfg!(target_os = "linux") {
35    Ok("https://johnvansickle.com/ffmpeg/release-readme.txt")
36  } else {
37    anyhow::bail!("Unsupported platform")
38  }
39}
40
41/// URL for the latest published FFmpeg release. The correct URL for the target
42/// platform is baked in at compile time.
43pub fn ffmpeg_download_url() -> Result<&'static str> {
44  if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
45    Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
46  } else if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
47    Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
48  } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
49    Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz")
50  } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
51    Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz")
52  } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
53    Ok("https://evermeet.cx/ffmpeg/getrelease/zip")
54  } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
55    Ok("https://www.osxexperts.net/ffmpeg80arm.zip") // Mac M1
56  } else {
57    anyhow::bail!("Unsupported platform; you can provide your own URL instead and call download_ffmpeg_package directly.")
58  }
59}
60
61/// Check if FFmpeg is installed, and if it's not, download and unpack it.
62/// Automatically selects the correct binaries for Windows, Linux, and MacOS.
63/// The binaries will be placed in the same directory as the Rust executable.
64///
65/// If FFmpeg is already installed, the method exits early without downloading
66/// anything.
67///
68/// Set the `KEEP_ONLY_FFMPEG` environment variable to `1` or `true` to skip
69/// installing `ffplay` and `ffprobe`.
70#[cfg(feature = "download_ffmpeg")]
71pub fn auto_download() -> Result<()> {
72  use crate::{command::ffmpeg_is_installed, paths::sidecar_dir};
73
74  if ffmpeg_is_installed() {
75    return Ok(());
76  }
77
78  let download_url = ffmpeg_download_url()?;
79  let destination = sidecar_dir()?;
80  let archive_path = download_ffmpeg_package(download_url, &destination)?;
81  if keep_only_ffmpeg_from_env() {
82    unpack_ffmpeg_without_extras(&archive_path, &destination)?;
83  } else {
84    unpack_ffmpeg(&archive_path, &destination)?;
85  }
86
87  if !ffmpeg_is_installed() {
88    anyhow::bail!("FFmpeg failed to install, please install manually.");
89  }
90
91  Ok(())
92}
93
94pub enum FfmpegDownloadProgressEvent {
95  Starting,
96  Downloading {
97    total_bytes: u64,
98    downloaded_bytes: u64,
99  },
100  UnpackingArchive,
101  Done,
102}
103
104/// Check if FFmpeg is installed, and if it's not, download and unpack it.
105/// Automatically selects the correct binaries for Windows, Linux, and MacOS.
106/// The binaries will be placed in the same directory as the Rust executable.
107///
108/// Provides progress tracking via callback.
109///
110/// If FFmpeg is already installed, the method exits early without downloading
111/// anything.
112///
113/// Set the `KEEP_ONLY_FFMPEG` environment variable to `1` or `true` to skip
114/// installing `ffplay` and `ffprobe`.
115#[cfg(feature = "download_ffmpeg")]
116pub fn auto_download_with_progress(
117  progress_callback: impl Fn(FfmpegDownloadProgressEvent),
118) -> Result<()> {
119  use crate::{command::ffmpeg_is_installed, paths::sidecar_dir};
120
121  if ffmpeg_is_installed() {
122    return Ok(());
123  }
124
125  progress_callback(FfmpegDownloadProgressEvent::Starting);
126  let download_url = ffmpeg_download_url()?;
127  let destination = sidecar_dir()?;
128  let archive_path = download_ffmpeg_package_with_progress(download_url, &destination, |e| progress_callback(e))?;
129  progress_callback(FfmpegDownloadProgressEvent::UnpackingArchive);
130  if keep_only_ffmpeg_from_env() {
131    unpack_ffmpeg_without_extras(&archive_path, &destination)?;
132  } else {
133    unpack_ffmpeg(&archive_path, &destination)?;
134  }
135  progress_callback(FfmpegDownloadProgressEvent::Done);
136
137  if !ffmpeg_is_installed() {
138    anyhow::bail!("FFmpeg failed to install, please install manually.");
139  }
140
141  Ok(())
142}
143
144/// Parse the the MacOS version number from a JSON string manifest file.
145///
146/// Example input: <https://evermeet.cx/ffmpeg/info/ffmpeg/release>
147///
148/// ```rust
149/// use ffmpeg_sidecar::download::parse_macos_version;
150/// let json_string = "{\"name\":\"ffmpeg\",\"type\":\"release\",\"version\":\"6.0\",...}";
151/// let parsed = parse_macos_version(&json_string).unwrap();
152/// assert_eq!(parsed, "6.0");
153/// ```
154pub fn parse_macos_version(version: &str) -> Option<String> {
155  version
156    .split("\"version\":")
157    .nth(1)?
158    .trim()
159    .split('\"')
160    .nth(1)
161    .map(|s| s.to_string())
162}
163
164/// Parse the the Linux version number from a long manifest text file.
165///
166/// Example input: <https://johnvansickle.com/ffmpeg/release-readme.txt>
167///
168/// ```rust
169/// use ffmpeg_sidecar::download::parse_linux_version;
170/// let json_string = "build: ffmpeg-5.1.1-amd64-static.tar.xz\nversion: 5.1.1\n\ngcc: 8.3.0";
171/// let parsed = parse_linux_version(&json_string).unwrap();
172/// assert_eq!(parsed, "5.1.1");
173/// ```
174pub fn parse_linux_version(version: &str) -> Option<String> {
175  version
176    .split("version:")
177    .nth(1)?
178    .split_whitespace()
179    .next()
180    .map(|s| s.to_string())
181}
182
183/// Makes an HTTP request to obtain the latest version available online,
184/// automatically choosing the correct URL for the current platform.
185#[cfg(feature = "download_ffmpeg")]
186pub fn check_latest_version() -> Result<String> {
187  use anyhow::Context;
188
189  // Mac M1 doesn't have a manifest URL, so match the version provided in `ffmpeg_download_url`
190  if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
191    return Ok("7.0".to_string());
192  }
193
194  let manifest_url = ffmpeg_manifest_url()?;
195  let string = ureq::get(manifest_url)
196    .call()
197    .context("Failed to GET the latest ffmpeg version")?
198    .body_mut()
199    .read_to_string()
200    .context("Failed to read response text")?;
201
202  if cfg!(target_os = "windows") {
203    Ok(string)
204  } else if cfg!(target_os = "macos") {
205    parse_macos_version(&string).context("failed to parse version number (macos variant)")
206  } else if cfg!(target_os = "linux") {
207    parse_linux_version(&string).context("failed to parse version number (linux variant)")
208  } else {
209    Err(anyhow::Error::msg("Unsupported platform"))
210  }
211}
212
213/// Make an HTTP request to download an archive from the latest published release online.
214#[cfg(feature = "download_ffmpeg")]
215pub fn download_ffmpeg_package(url: &str, download_dir: &Path) -> Result<PathBuf> {
216  use anyhow::Context;
217  use std::{fs::File, io::copy, path::Path};
218
219  let filename = Path::new(url)
220    .file_name()
221    .context("Failed to get filename")?;
222
223  let archive_path = download_dir.join(filename);
224
225  let mut response = ureq::get(url).call().context("Failed to download ffmpeg")?;
226
227  let mut file =
228    File::create(&archive_path).context("Failed to create file for ffmpeg download")?;
229
230  copy(&mut response.body_mut().as_reader(), &mut file)
231    .context("Failed to write ffmpeg download to file")?;
232
233  Ok(archive_path)
234}
235
236/// Make an HTTP request to download an archive from the latest published release online with progress tracking.
237#[cfg(feature = "download_ffmpeg")]
238pub fn download_ffmpeg_package_with_progress(
239  url: &str,
240  download_dir: &Path,
241  progress_callback: impl Fn(FfmpegDownloadProgressEvent),
242) -> Result<PathBuf> {
243  use anyhow::Context;
244  use std::{
245    fs::File,
246    io::{copy, Read},
247    path::Path,
248  };
249
250  let filename = Path::new(url)
251    .file_name()
252    .context("Failed to get filename")?;
253
254  let archive_path = download_dir.join(filename);
255
256  let mut response = ureq::get(url).call().context("Failed to download ffmpeg")?;
257
258  let total_size = response
259    .headers()
260    .get("Content-Length")
261    .and_then(|s| s.to_str().ok())
262    .and_then(|s| s.parse::<u64>().ok())
263    .unwrap_or(0);
264
265  let mut file =
266    File::create(&archive_path).context("Failed to create file for ffmpeg download")?;
267
268  // Wrapper to track progress during io::copy
269  struct ProgressReader<R, F> {
270    inner: R,
271    progress_callback: F,
272    downloaded: u64,
273    total: u64,
274  }
275
276  impl<R: Read, F: Fn(FfmpegDownloadProgressEvent)> Read for ProgressReader<R, F> {
277    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
278      let n = self.inner.read(buf)?;
279      self.downloaded += n as u64;
280      (self.progress_callback)(FfmpegDownloadProgressEvent::Downloading {
281        total_bytes: self.total,
282        downloaded_bytes: self.downloaded,
283      });
284      Ok(n)
285    }
286  }
287
288  let mut progress_reader = ProgressReader {
289    inner: response.body_mut().as_reader(),
290    progress_callback,
291    downloaded: 0,
292    total: total_size,
293  };
294
295  copy(&mut progress_reader, &mut file).context("Failed to write ffmpeg download to file")?;
296
297  Ok(archive_path)
298}
299
300/// After downloading, unpacks the archive to a folder, moves the binaries to
301/// their final location, and deletes the archive and temporary folder.
302#[cfg(feature = "download_ffmpeg")]
303pub fn unpack_ffmpeg(from_archive: &PathBuf, binary_folder: &Path) -> Result<()> {
304  unpack_ffmpeg_internal(from_archive, binary_folder, false)
305}
306
307/// Variant of [`unpack_ffmpeg`] that only installs the main `ffmpeg` binary,
308/// skipping `ffplay` and `ffprobe`.
309#[cfg(feature = "download_ffmpeg")]
310pub fn unpack_ffmpeg_without_extras(from_archive: &PathBuf, binary_folder: &Path) -> Result<()> {
311  unpack_ffmpeg_internal(from_archive, binary_folder, true)
312}
313
314#[cfg(feature = "download_ffmpeg")]
315fn unpack_ffmpeg_internal(
316  from_archive: &PathBuf,
317  binary_folder: &Path,
318  keep_only_ffmpeg: bool,
319) -> Result<()> {
320  use anyhow::Context;
321  use std::{
322    fs::{create_dir_all, read_dir, remove_dir_all, remove_file, rename, File},
323    path::Path,
324  };
325
326  let temp_dirname = UNPACK_DIRNAME;
327  let temp_folder = binary_folder.join(temp_dirname);
328  create_dir_all(&temp_folder)?;
329
330  let file = File::open(from_archive).context("Failed to open archive file")?;
331
332  #[cfg(target_os = "linux")]
333  {
334    // Extracts .tar.xz file
335    let tar_xz = xz2::read::XzDecoder::new(file);
336    let mut archive = tar::Archive::new(tar_xz);
337
338    archive
339      .unpack(&temp_folder)
340      .context("Failed to unpack ffmpeg")?;
341  }
342
343  #[cfg(not(target_os = "linux"))]
344  {
345    // Extracts .zip file
346    let mut archive = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?;
347    archive
348      .extract(&temp_folder)
349      .context("Failed to unpack ffmpeg")?;
350  }
351
352  // Move binaries
353  let (ffmpeg, ffplay, ffprobe) = if cfg!(target_os = "windows") {
354    let inner_folder = read_dir(&temp_folder)?
355      .next()
356      .context("Failed to get inner folder")??;
357    (
358      inner_folder.path().join("bin/ffmpeg.exe"),
359      inner_folder.path().join("bin/ffplay.exe"),
360      inner_folder.path().join("bin/ffprobe.exe"),
361    )
362  } else if cfg!(target_os = "linux") {
363    let inner_folder = read_dir(&temp_folder)?
364      .next()
365      .context("Failed to get inner folder")??;
366    (
367      inner_folder.path().join("./ffmpeg"),
368      inner_folder.path().join("./ffplay"), // <- no ffplay on linux
369      inner_folder.path().join("./ffprobe"),
370    )
371  } else if cfg!(target_os = "macos") {
372    (
373      temp_folder.join("ffmpeg"),
374      temp_folder.join("ffplay"),  // <-- no ffplay on mac
375      temp_folder.join("ffprobe"), // <-- no ffprobe on mac
376    )
377  } else {
378    anyhow::bail!("Unsupported platform");
379  };
380
381  // Move binaries
382  let move_bin = |path: &Path| {
383    let file_name = binary_folder.join(
384      path
385        .file_name()
386        .with_context(|| format!("Path {} does not have a file_name", path.to_string_lossy()))?,
387    );
388    rename(path, file_name)?;
389    anyhow::Ok(())
390  };
391
392  move_bin(&ffmpeg)?;
393
394  if !keep_only_ffmpeg && ffprobe.exists() {
395    move_bin(&ffprobe)?;
396  }
397
398  if !keep_only_ffmpeg && ffplay.exists() {
399    move_bin(&ffplay)?;
400  }
401
402  // Delete archive and unpacked files
403  if temp_folder.exists() && temp_folder.is_dir() {
404    remove_dir_all(&temp_folder)?;
405  }
406
407  if from_archive.exists() {
408    remove_file(from_archive)?;
409  }
410
411  Ok(())
412}
413
414#[cfg(all(test, feature = "download_ffmpeg"))]
415mod tests {
416  use super::keep_only_ffmpeg_from_value;
417
418  #[test]
419  fn keep_only_ffmpeg_value_defaults_to_false() {
420    assert!(!keep_only_ffmpeg_from_value(None));
421  }
422
423  #[test]
424  fn keep_only_ffmpeg_value_accepts_true_values() {
425    assert!(keep_only_ffmpeg_from_value(Some("true")));
426    assert!(keep_only_ffmpeg_from_value(Some("TRUE")));
427    assert!(keep_only_ffmpeg_from_value(Some("1")));
428  }
429
430  #[test]
431  fn keep_only_ffmpeg_value_rejects_other_values() {
432    assert!(!keep_only_ffmpeg_from_value(Some("false")));
433    assert!(!keep_only_ffmpeg_from_value(Some("0")));
434    assert!(!keep_only_ffmpeg_from_value(Some("yes")));
435  }
436}