ffmpeg_sidecar/
download.rs1use 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
20pub const UNPACK_DIRNAME: &str = "ffmpeg_release_temp";
22
23pub 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
41pub 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") } else {
57 anyhow::bail!("Unsupported platform; you can provide your own URL instead and call download_ffmpeg_package directly.")
58 }
59}
60
61#[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#[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
144pub 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
164pub 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#[cfg(feature = "download_ffmpeg")]
186pub fn check_latest_version() -> Result<String> {
187 use anyhow::Context;
188
189 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#[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#[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 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#[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#[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 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 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 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"), inner_folder.path().join("./ffprobe"),
370 )
371 } else if cfg!(target_os = "macos") {
372 (
373 temp_folder.join("ffmpeg"),
374 temp_folder.join("ffplay"), temp_folder.join("ffprobe"), )
377 } else {
378 anyhow::bail!("Unsupported platform");
379 };
380
381 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 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}