async_ffmpeg_sidecar/
download.rs1use anyhow::Result;
4
5#[cfg(feature = "download_ffmpeg")]
6use std::path::{Path, PathBuf};
7#[cfg(feature = "download_ffmpeg")]
8use tokio::fs::File;
9
10pub const UNPACK_DIRNAME: &str = "ffmpeg_release_temp";
12
13pub 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
31pub 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") } else {
43 anyhow::bail!("Unsupported platform; you can provide your own URL instead and call download_ffmpeg_package directly.")
44 }
45}
46
47#[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
74pub 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
94pub 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#[cfg(feature = "download_ffmpeg")]
116pub async fn check_latest_version() -> Result<String> {
117 use anyhow::Context;
118
119 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#[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#[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"), inner_folder.path().join("./ffprobe"),
219 )
220 } else if cfg!(target_os = "macos") {
221 (
222 temp_folder.join("ffmpeg"),
223 temp_folder.join("ffplay"), temp_folder.join("ffprobe"), )
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 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 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 if !path.exists() {
322 create_dir_all(&path)
323 .await
324 .expect("Failed to create extracted directory");
325 }
326 } else {
327 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 }
349 }
350
351 Ok(())
352}
353
354#[cfg(all(feature = "download_ffmpeg", not(target_os = "linux")))]
356fn sanitize_file_path(path: &str) -> PathBuf {
357 path
359 .replace('\\', "/")
360 .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}