use anyhow::Result;
#[cfg(feature = "download_ffmpeg")]
use std::path::{Path, PathBuf};
#[cfg(feature = "download_ffmpeg")]
fn keep_only_ffmpeg_from_env() -> bool {
keep_only_ffmpeg_from_value(std::env::var("KEEP_ONLY_FFMPEG").ok().as_deref())
}
#[cfg(feature = "download_ffmpeg")]
fn keep_only_ffmpeg_from_value(value: Option<&str>) -> bool {
value
.map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
.unwrap_or(false)
}
pub const UNPACK_DIRNAME: &str = "ffmpeg_release_temp";
pub fn ffmpeg_manifest_url() -> Result<&'static str> {
if cfg!(not(target_arch = "x86_64")) {
anyhow::bail!("Downloads must be manually provided for non-x86_64 architectures");
}
if cfg!(target_os = "windows") {
Ok("https://www.gyan.dev/ffmpeg/builds/release-version")
} else if cfg!(target_os = "macos") {
Ok("https://evermeet.cx/ffmpeg/info/ffmpeg/release")
} else if cfg!(target_os = "linux") {
Ok("https://johnvansickle.com/ffmpeg/release-readme.txt")
} else {
anyhow::bail!("Unsupported platform")
}
}
pub fn ffmpeg_download_url() -> Result<&'static str> {
if cfg!(all(target_os = "windows", target_arch = "x86_64")) {
Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
} else if cfg!(all(target_os = "windows", target_arch = "aarch64")) {
Ok("https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip")
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz")
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
Ok("https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz")
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
Ok("https://evermeet.cx/ffmpeg/getrelease/zip")
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
Ok("https://www.osxexperts.net/ffmpeg80arm.zip") } else {
anyhow::bail!("Unsupported platform; you can provide your own URL instead and call download_ffmpeg_package directly.")
}
}
#[cfg(feature = "download_ffmpeg")]
pub fn auto_download() -> Result<()> {
use crate::{command::ffmpeg_is_installed, paths::sidecar_dir};
if ffmpeg_is_installed() {
return Ok(());
}
let download_url = ffmpeg_download_url()?;
let destination = sidecar_dir()?;
let archive_path = download_ffmpeg_package(download_url, &destination)?;
if keep_only_ffmpeg_from_env() {
unpack_ffmpeg_without_extras(&archive_path, &destination)?;
} else {
unpack_ffmpeg(&archive_path, &destination)?;
}
if !ffmpeg_is_installed() {
anyhow::bail!("FFmpeg failed to install, please install manually.");
}
Ok(())
}
pub enum FfmpegDownloadProgressEvent {
Starting,
Downloading {
total_bytes: u64,
downloaded_bytes: u64,
},
UnpackingArchive,
Done,
}
#[cfg(feature = "download_ffmpeg")]
pub fn auto_download_with_progress(
progress_callback: impl Fn(FfmpegDownloadProgressEvent),
) -> Result<()> {
use crate::{command::ffmpeg_is_installed, paths::sidecar_dir};
if ffmpeg_is_installed() {
return Ok(());
}
progress_callback(FfmpegDownloadProgressEvent::Starting);
let download_url = ffmpeg_download_url()?;
let destination = sidecar_dir()?;
let archive_path = download_ffmpeg_package_with_progress(download_url, &destination, |e| progress_callback(e))?;
progress_callback(FfmpegDownloadProgressEvent::UnpackingArchive);
if keep_only_ffmpeg_from_env() {
unpack_ffmpeg_without_extras(&archive_path, &destination)?;
} else {
unpack_ffmpeg(&archive_path, &destination)?;
}
progress_callback(FfmpegDownloadProgressEvent::Done);
if !ffmpeg_is_installed() {
anyhow::bail!("FFmpeg failed to install, please install manually.");
}
Ok(())
}
pub fn parse_macos_version(version: &str) -> Option<String> {
version
.split("\"version\":")
.nth(1)?
.trim()
.split('\"')
.nth(1)
.map(|s| s.to_string())
}
pub fn parse_linux_version(version: &str) -> Option<String> {
version
.split("version:")
.nth(1)?
.split_whitespace()
.next()
.map(|s| s.to_string())
}
#[cfg(feature = "download_ffmpeg")]
pub fn check_latest_version() -> Result<String> {
use anyhow::Context;
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
return Ok("7.0".to_string());
}
let manifest_url = ffmpeg_manifest_url()?;
let string = ureq::get(manifest_url)
.call()
.context("Failed to GET the latest ffmpeg version")?
.body_mut()
.read_to_string()
.context("Failed to read response text")?;
if cfg!(target_os = "windows") {
Ok(string)
} else if cfg!(target_os = "macos") {
parse_macos_version(&string).context("failed to parse version number (macos variant)")
} else if cfg!(target_os = "linux") {
parse_linux_version(&string).context("failed to parse version number (linux variant)")
} else {
Err(anyhow::Error::msg("Unsupported platform"))
}
}
#[cfg(feature = "download_ffmpeg")]
pub fn download_ffmpeg_package(url: &str, download_dir: &Path) -> Result<PathBuf> {
use anyhow::Context;
use std::{fs::File, io::copy, path::Path};
let filename = Path::new(url)
.file_name()
.context("Failed to get filename")?;
let archive_path = download_dir.join(filename);
let mut response = ureq::get(url).call().context("Failed to download ffmpeg")?;
let mut file =
File::create(&archive_path).context("Failed to create file for ffmpeg download")?;
copy(&mut response.body_mut().as_reader(), &mut file)
.context("Failed to write ffmpeg download to file")?;
Ok(archive_path)
}
#[cfg(feature = "download_ffmpeg")]
pub fn download_ffmpeg_package_with_progress(
url: &str,
download_dir: &Path,
progress_callback: impl Fn(FfmpegDownloadProgressEvent),
) -> Result<PathBuf> {
use anyhow::Context;
use std::{
fs::File,
io::{copy, Read},
path::Path,
};
let filename = Path::new(url)
.file_name()
.context("Failed to get filename")?;
let archive_path = download_dir.join(filename);
let mut response = ureq::get(url).call().context("Failed to download ffmpeg")?;
let total_size = response
.headers()
.get("Content-Length")
.and_then(|s| s.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
let mut file =
File::create(&archive_path).context("Failed to create file for ffmpeg download")?;
struct ProgressReader<R, F> {
inner: R,
progress_callback: F,
downloaded: u64,
total: u64,
}
impl<R: Read, F: Fn(FfmpegDownloadProgressEvent)> Read for ProgressReader<R, F> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
let n = self.inner.read(buf)?;
self.downloaded += n as u64;
(self.progress_callback)(FfmpegDownloadProgressEvent::Downloading {
total_bytes: self.total,
downloaded_bytes: self.downloaded,
});
Ok(n)
}
}
let mut progress_reader = ProgressReader {
inner: response.body_mut().as_reader(),
progress_callback,
downloaded: 0,
total: total_size,
};
copy(&mut progress_reader, &mut file).context("Failed to write ffmpeg download to file")?;
Ok(archive_path)
}
#[cfg(feature = "download_ffmpeg")]
pub fn unpack_ffmpeg(from_archive: &PathBuf, binary_folder: &Path) -> Result<()> {
unpack_ffmpeg_internal(from_archive, binary_folder, false)
}
#[cfg(feature = "download_ffmpeg")]
pub fn unpack_ffmpeg_without_extras(from_archive: &PathBuf, binary_folder: &Path) -> Result<()> {
unpack_ffmpeg_internal(from_archive, binary_folder, true)
}
#[cfg(feature = "download_ffmpeg")]
fn unpack_ffmpeg_internal(
from_archive: &PathBuf,
binary_folder: &Path,
keep_only_ffmpeg: bool,
) -> Result<()> {
use anyhow::Context;
use std::{
fs::{create_dir_all, read_dir, remove_dir_all, remove_file, rename, File},
path::Path,
};
let temp_dirname = UNPACK_DIRNAME;
let temp_folder = binary_folder.join(temp_dirname);
create_dir_all(&temp_folder)?;
let file = File::open(from_archive).context("Failed to open archive file")?;
#[cfg(target_os = "linux")]
{
let tar_xz = xz2::read::XzDecoder::new(file);
let mut archive = tar::Archive::new(tar_xz);
archive
.unpack(&temp_folder)
.context("Failed to unpack ffmpeg")?;
}
#[cfg(not(target_os = "linux"))]
{
let mut archive = zip::ZipArchive::new(file).context("Failed to read ZIP archive")?;
archive
.extract(&temp_folder)
.context("Failed to unpack ffmpeg")?;
}
let (ffmpeg, ffplay, ffprobe) = if cfg!(target_os = "windows") {
let inner_folder = read_dir(&temp_folder)?
.next()
.context("Failed to get inner folder")??;
(
inner_folder.path().join("bin/ffmpeg.exe"),
inner_folder.path().join("bin/ffplay.exe"),
inner_folder.path().join("bin/ffprobe.exe"),
)
} else if cfg!(target_os = "linux") {
let inner_folder = read_dir(&temp_folder)?
.next()
.context("Failed to get inner folder")??;
(
inner_folder.path().join("./ffmpeg"),
inner_folder.path().join("./ffplay"), inner_folder.path().join("./ffprobe"),
)
} else if cfg!(target_os = "macos") {
(
temp_folder.join("ffmpeg"),
temp_folder.join("ffplay"), temp_folder.join("ffprobe"), )
} else {
anyhow::bail!("Unsupported platform");
};
let move_bin = |path: &Path| {
let file_name = binary_folder.join(
path
.file_name()
.with_context(|| format!("Path {} does not have a file_name", path.to_string_lossy()))?,
);
rename(path, file_name)?;
anyhow::Ok(())
};
move_bin(&ffmpeg)?;
if !keep_only_ffmpeg && ffprobe.exists() {
move_bin(&ffprobe)?;
}
if !keep_only_ffmpeg && ffplay.exists() {
move_bin(&ffplay)?;
}
if temp_folder.exists() && temp_folder.is_dir() {
remove_dir_all(&temp_folder)?;
}
if from_archive.exists() {
remove_file(from_archive)?;
}
Ok(())
}
#[cfg(all(test, feature = "download_ffmpeg"))]
mod tests {
use super::keep_only_ffmpeg_from_value;
#[test]
fn keep_only_ffmpeg_value_defaults_to_false() {
assert!(!keep_only_ffmpeg_from_value(None));
}
#[test]
fn keep_only_ffmpeg_value_accepts_true_values() {
assert!(keep_only_ffmpeg_from_value(Some("true")));
assert!(keep_only_ffmpeg_from_value(Some("TRUE")));
assert!(keep_only_ffmpeg_from_value(Some("1")));
}
#[test]
fn keep_only_ffmpeg_value_rejects_other_values() {
assert!(!keep_only_ffmpeg_from_value(Some("false")));
assert!(!keep_only_ffmpeg_from_value(Some("0")));
assert!(!keep_only_ffmpeg_from_value(Some("yes")));
}
}