async_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#[cfg(feature = "download_ffmpeg")]
8use tokio::fs::File;
9
10/// The default directory name for unpacking a downloaded FFmpeg release archive.
11pub const UNPACK_DIRNAME: &str = "ffmpeg_release_temp";
12
13/// URL of a manifest file containing the latest published build of FFmpeg. The
14/// correct URL for the target platform is baked in at compile time.
15pub fn ffmpeg_manifest_url() -> Result<&'static str> {
16  if cfg!(not(target_arch = "x86_64")) {
17    anyhow::bail!("Downloads must be manually provided for non-x86_64 architectures");
18  }
19
20  if cfg!(target_os = "windows") {
21    Ok("https://www.gyan.dev/ffmpeg/builds/release-version")
22  } else if cfg!(target_os = "macos") {
23    Ok("https://evermeet.cx/ffmpeg/info/ffmpeg/release")
24  } else if cfg!(target_os = "linux") {
25    Ok("https://johnvansickle.com/ffmpeg/release-readme.txt")
26  } else {
27    anyhow::bail!("Unsupported platform")
28  }
29}
30
31/// URL for the latest published FFmpeg release. The correct URL for the target
32/// platform is baked in at compile time.
33pub fn ffmpeg_download_url() -> Result<&'static str> {
34  if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
35    Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
36  } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
37    Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz")
38  } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
39    Ok("https://evermeet.cx/ffmpeg/getrelease/zip")
40  } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
41    Ok("https://www.osxexperts.net/ffmpeg7arm.zip") // Mac M1
42  } else {
43    anyhow::bail!("Unsupported platform; you can provide your own URL instead and call download_ffmpeg_package directly.")
44  }
45}
46
47/// Check if FFmpeg is installed, and if it's not, download and unpack it.
48/// Automatically selects the correct binaries for Windows, Linux, and MacOS.
49/// The binaries will be placed in the same directory as the Rust executable.
50///
51/// If FFmpeg is already installed, the method exits early without downloading
52/// anything.
53#[cfg(feature = "download_ffmpeg")]
54pub async fn auto_download() -> Result<()> {
55  use crate::{command::ffmpeg_is_installed, paths::sidecar_dir};
56
57  if ffmpeg_is_installed().await {
58    return Ok(());
59  }
60
61  let download_url = ffmpeg_download_url()?;
62  let destination = sidecar_dir()?;
63  tokio::fs::create_dir_all(&destination).await?;
64  let archive_path = download_ffmpeg_package(download_url, &destination).await?;
65  unpack_ffmpeg(&archive_path, &destination).await?;
66
67  if !(ffmpeg_is_installed().await) {
68    anyhow::bail!("Ffmpeg failed to install, please install manually")
69  }
70
71  Ok(())
72}
73
74/// Parse the macOS version number from a JSON string manifest file.
75///
76/// Example input: <https://evermeet.cx/ffmpeg/info/ffmpeg/release>
77///
78/// ```rust
79/// use async_ffmpeg_sidecar::download::parse_macos_version;
80/// let json_string = "{\"name\":\"ffmpeg\",\"type\":\"release\",\"version\":\"6.0\",...}";
81/// let parsed = parse_macos_version(&json_string).unwrap();
82/// assert_eq!(parsed, "6.0");
83/// ```
84pub fn parse_macos_version(version: &str) -> Option<String> {
85  version
86    .split("\"version\":")
87    .nth(1)?
88    .trim()
89    .split('\"')
90    .nth(1)
91    .map(|s| s.to_string())
92}
93
94/// Parse the Linux version number from a long manifest text file.
95///
96/// Example input: <https://johnvansickle.com/ffmpeg/release-readme.txt>
97///
98/// ```rust
99/// use async_ffmpeg_sidecar::download::parse_linux_version;
100/// let json_string = "build: ffmpeg-5.1.1-amd64-static.tar.xz\nversion: 5.1.1\n\ngcc: 8.3.0";
101/// let parsed = parse_linux_version(&json_string).unwrap();
102/// assert_eq!(parsed, "5.1.1");
103/// ```
104pub fn parse_linux_version(version: &str) -> Option<String> {
105  version
106    .split("version:")
107    .nth(1)?
108    .split_whitespace()
109    .next()
110    .map(|s| s.to_string())
111}
112
113/// Makes an HTTP request to obtain the latest version available online,
114/// automatically choosing the correct URL for the current platform.
115#[cfg(feature = "download_ffmpeg")]
116pub async fn check_latest_version() -> Result<String> {
117  use anyhow::Context;
118
119  // Mac M1 doesn't have a manifest URL, so match version provided
120  if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
121    return Ok("7.0".to_string());
122  }
123
124  let manifest_url = ffmpeg_manifest_url()?;
125  let version_string = reqwest::get(manifest_url)
126    .await?
127    .error_for_status()?
128    .text()
129    .await?;
130
131  if cfg!(target_os = "windows") {
132    Ok(version_string)
133  } else if cfg!(target_os = "macos") {
134    parse_macos_version(&version_string).context("failed to parse version number (macos variant)")
135  } else if cfg!(target_os = "linux") {
136    parse_linux_version(&version_string).context("failed to parse version number (linux variant)")
137  } else {
138    anyhow::bail!("unsupported platform")
139  }
140}
141
142/// Make an HTTP request to download an archive from the latest published release online
143#[cfg(feature = "download_ffmpeg")]
144pub async fn download_ffmpeg_package(url: &str, download_dir: &Path) -> Result<PathBuf> {
145  use anyhow::Context;
146  use tokio::fs::File;
147  use tokio::io::AsyncWriteExt;
148  use futures_util::StreamExt;
149
150  let filename = Path::new(url)
151    .file_name()
152    .context("Failed to get filename")?;
153
154  let archive_path = download_dir.join(filename);
155
156  let response = reqwest::get(url)
157    .await
158    .context("failed to download ffmpeg")?
159    .error_for_status()
160    .context("server returned error")?;
161
162  let mut file = File::create(&archive_path)
163    .await
164    .context("failed to create file for ffmpeg download")?;
165
166  let mut stream = response.bytes_stream();
167
168  while let Some(chunk) = stream.next().await {
169    let data = chunk?;
170    file.write_all(&data).await?
171  }
172
173  Ok(archive_path)
174}
175
176/// After downloading unpacks the archive to a folder, moves the binaries to
177/// their final location, and deletes the archive and temporary folder.
178#[cfg(feature = "download_ffmpeg")]
179pub async fn unpack_ffmpeg(from_archive: &PathBuf, binary_folder: &Path) -> Result<()> {
180  use anyhow::Context;
181  use tokio::fs::{create_dir_all, read_dir, remove_dir_all, remove_file, File};
182
183  let temp_folder = binary_folder.join(UNPACK_DIRNAME);
184  create_dir_all(&temp_folder)
185    .await
186    .context("failed creating temp dir")?;
187
188  let file = File::open(from_archive)
189    .await
190    .context("failed to open archive")?;
191
192  #[cfg(target_os = "linux")]
193  {
194    untar_file(file, &temp_folder).await?
195  }
196
197  #[cfg(not(target_os = "linux"))]
198  {
199    unzip_file(file, &temp_folder).await?
200  }
201
202  let inner_folder = read_dir(&temp_folder)
203    .await?
204    .next_entry()
205    .await
206    .context("Failed to get inner folder")?
207    .unwrap();
208  let (ffmpeg, ffplay, ffprobe) = if cfg!(target_os = "windows") {
209    (
210      inner_folder.path().join("bin/ffmpeg.exe"),
211      inner_folder.path().join("bin/ffplay.exe"),
212      inner_folder.path().join("bin/ffprobe.exe"),
213    )
214  } else if cfg!(target_os = "linux") {
215    (
216      inner_folder.path().join("./ffmpeg"),
217      inner_folder.path().join("./ffplay"), // <- no ffplay on linux
218      inner_folder.path().join("./ffprobe"),
219    )
220  } else if cfg!(target_os = "macos") {
221    (
222      temp_folder.join("ffmpeg"),
223      temp_folder.join("ffplay"),  // <- no ffplay on mac
224      temp_folder.join("ffprobe"), // <- no ffprobe on mac
225    )
226  } else {
227    anyhow::bail!("Unsupported platform");
228  };
229
230  set_executable_permission(&ffmpeg).await?;
231  move_bin(&ffmpeg, binary_folder).await?;
232
233  if ffprobe.exists() {
234    set_executable_permission(&ffprobe).await?;
235    move_bin(&ffprobe, binary_folder).await?;
236  }
237
238  if ffplay.exists() {
239    set_executable_permission(&ffplay).await?;
240    move_bin(&ffplay, binary_folder).await?;
241  }
242
243  // Delete archive and unpacked files
244  if temp_folder.exists() && temp_folder.is_dir() {
245    remove_dir_all(&temp_folder).await?;
246  }
247
248  if from_archive.exists() {
249    remove_file(from_archive).await?;
250  }
251
252  Ok(())
253}
254
255#[cfg(feature = "download_ffmpeg")]
256async fn move_bin(path: &Path, binary_folder: &Path) -> Result<()> {
257  use tokio::fs::rename;
258  use anyhow::Context;
259  let file_name = binary_folder.join(
260    path
261      .file_name()
262      .with_context(|| format!("Path {} does not have a file_name", path.to_string_lossy()))?,
263  );
264
265  rename(path, file_name).await?;
266  anyhow::Ok(())
267}
268#[cfg(all(feature = "download_ffmpeg", target_family = "unix"))]
269async fn set_executable_permission(path: &Path) -> Result<()> {
270  #[cfg(target_family = "unix")]
271  {
272    use tokio::fs::set_permissions;
273
274    use std::os::unix::fs::PermissionsExt;
275    let mut perms = path.metadata()?.permissions();
276
277    perms.set_mode(perms.mode() | 0o100);
278
279    set_permissions(path, perms).await?;
280  }
281
282  Ok(())
283}
284
285#[cfg(all(feature = "download_ffmpeg", not(target_family = "unix")))]
286async fn set_executable_permission(_path: &Path) -> Result<()> {
287  Ok(())
288}
289
290#[cfg(all(feature = "download_ffmpeg", not(target_os = "linux")))]
291async fn unzip_file(archive: File, out_dir: &Path) -> Result<()> {
292  use async_zip::base::read::seek::ZipFileReader;
293  use tokio::fs::create_dir_all;
294  use tokio::fs::OpenOptions;
295  use tokio::io::BufReader;
296  use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
297  use anyhow::Context;
298
299  let archive = BufReader::new(archive).compat();
300
301  let mut reader = ZipFileReader::new(archive)
302    .await
303    .context("Failed to read zip file")?;
304
305  for index in 0..reader.file().entries().len() {
306    let entry = reader.file().entries().get(index).unwrap();
307    let path = out_dir.join(sanitize_file_path(entry.filename().as_str()?));
308    // If the filename of the entry ends with '/', it is treated as a directory.
309    // This is implemented by previous versions of this crate and the Python Standard Library.
310    // https://docs.rs/async_zip/0.0.8/src/async_zip/read/mod.rs.html#63-65
311    // https://github.com/python/cpython/blob/820ef62833bd2d84a141adedd9a05998595d6b6d/Lib/zipfile.py#L528
312    let entry_is_dir = entry.dir()?;
313
314    let mut entry_reader = reader
315      .reader_without_entry(index)
316      .await
317      .expect("Failed to read ZipEntry");
318
319    if entry_is_dir {
320      // The directory may have been created if iteration is out of order.
321      if !path.exists() {
322        create_dir_all(&path)
323          .await
324          .expect("Failed to create extracted directory");
325      }
326    } else {
327      // Creates parent directories. They may not exist if iteration is out of order
328      // or the archive does not contain directory entries.
329      let parent = path
330        .parent()
331        .expect("A file entry should have parent directories");
332      if !parent.is_dir() {
333        create_dir_all(parent)
334          .await
335          .expect("Failed to create parent directories");
336      }
337      let writer = OpenOptions::new()
338        .write(true)
339        .create_new(true)
340        .open(&path)
341        .await
342        .expect("Failed to create extracted file");
343      futures_util::io::copy(&mut entry_reader, &mut writer.compat_write())
344        .await
345        .expect("Failed to copy to extracted file");
346
347      // Closes the file and manipulates its metadata here if you wish to preserve its metadata from the archive.
348    }
349  }
350
351  Ok(())
352}
353
354/// Returns a relative path without reserved names, redundant separators, ".", or "..".
355#[cfg(all(feature = "download_ffmpeg", not(target_os = "linux")))]
356fn sanitize_file_path(path: &str) -> PathBuf {
357  // Replaces backwards slashes
358  path
359    .replace('\\', "/")
360    // Sanitizes each component
361    .split('/')
362    .map(sanitize_filename::sanitize)
363    .collect()
364}
365
366#[cfg(all(feature = "download_ffmpeg", target_os = "linux"))]
367async fn untar_file(archive: File, out_dir: &Path) -> Result<()> {
368  use async_compression::tokio::bufread::XzDecoder;
369  use tokio::io::BufReader;
370  use tokio_tar::Archive;
371  let archive = BufReader::new(archive);
372  let archive = XzDecoder::new(archive);
373  let mut archive = Archive::new(archive);
374
375  archive.unpack(out_dir).await?;
376
377  Ok(())
378}