Skip to main content

mangofetch_core/core/
dependencies.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4use std::process::Stdio;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct ResolvedDependencies {
8    pub ytdlp: Option<PathBuf>,
9    pub ffmpeg: Option<PathBuf>,
10}
11
12pub async fn ensure_dependencies(
13    force: bool,
14    reporter: Option<crate::core::traits::SharedReporter>,
15) -> Result<ResolvedDependencies> {
16    let rep_ref = reporter.as_ref().map(|r| r.as_ref());
17
18    if let Some(r) = rep_ref {
19        r.on_system_progress("Checking dependencies", 0.0, "Starting...");
20    }
21
22    if force {
23        tracing::info!("Force updating dependencies...");
24        // Reset caches to ensure we don't return old paths
25        crate::core::ytdlp::reset_ytdlp_cache();
26        crate::core::ytdlp::reset_ffmpeg_location_cache();
27        crate::core::ytdlp::reset_js_runtime_cache();
28
29        // Re-download yt-dlp
30        let ytdlp = crate::core::ytdlp::force_update_ytdlp(rep_ref).await.ok();
31
32        // Re-download ffmpeg
33        let ffmpeg = download_ffmpeg(rep_ref).await.ok();
34
35        if let Some(r) = rep_ref {
36            r.on_system_progress("Update complete", 100.0, "Ready");
37        }
38
39        return Ok(ResolvedDependencies { ytdlp, ffmpeg });
40    }
41
42    let ytdlp = crate::core::ytdlp::ensure_ytdlp(rep_ref).await.ok();
43    let ffmpeg = ensure_ffmpeg(rep_ref).await.ok();
44
45    if let Some(r) = rep_ref {
46        r.on_system_progress("Dependencies ready", 100.0, "Ready");
47    }
48
49    Ok(ResolvedDependencies { ytdlp, ffmpeg })
50}
51
52use anyhow::anyhow;
53
54pub fn is_flatpak() -> bool {
55    std::path::Path::new("/.flatpak-info").exists() || std::env::var("FLATPAK_ID").is_ok()
56}
57
58fn managed_bin_dir() -> Option<PathBuf> {
59    Some(crate::core::paths::app_data_dir()?.join("bin"))
60}
61
62pub fn bin_name(tool: &str) -> String {
63    if cfg!(target_os = "windows") {
64        format!("{}.exe", tool)
65    } else {
66        tool.to_string()
67    }
68}
69
70pub async fn find_tool(tool: &str) -> Option<PathBuf> {
71    let _timer_start = std::time::Instant::now();
72    let name = bin_name(tool);
73    let version_flag = version_flag_for(tool);
74
75    #[cfg(target_os = "linux")]
76    {
77        let flatpak_path = PathBuf::from("/app/bin").join(&name);
78        if flatpak_path.exists() {
79            tracing::debug!(
80                "[perf] find_tool({}) took {:?}",
81                tool,
82                _timer_start.elapsed()
83            );
84            return Some(flatpak_path);
85        }
86    }
87
88    // Check managed bin dir first — managed binaries are known-good.
89    let managed = managed_bin_dir().map(|d| d.join(&name));
90    if let Some(ref managed_path) = managed {
91        if managed_path.exists() {
92            let check = {
93                let managed = managed_path.clone();
94                let vf = version_flag.to_string();
95                tokio::task::spawn_blocking(move || {
96                    crate::core::process::std_command(&managed)
97                        .arg(&vf)
98                        .stdout(Stdio::null())
99                        .stderr(Stdio::null())
100                        .status()
101                        .ok()
102                        .filter(|s| s.success())
103                })
104                .await
105                .ok()
106                .flatten()
107            };
108
109            if check.is_some() {
110                tracing::debug!(
111                    "[perf] find_tool({}) took {:?}",
112                    tool,
113                    _timer_start.elapsed()
114                );
115                return Some(managed_path.clone());
116            }
117            tracing::warn!(
118                "find_tool({}): binary exists at {} but failed to execute",
119                tool,
120                managed_path.display()
121            );
122        }
123    }
124
125    // Fall back to system PATH. Resolve to an absolute path so callers
126    // (e.g. find_ffmpeg_location) can derive the parent directory.
127    let result = {
128        let name = name.clone();
129        let vf = version_flag.to_string();
130        tokio::task::spawn_blocking(move || {
131            crate::core::process::std_command(&name)
132                .arg(&vf)
133                .stdout(Stdio::null())
134                .stderr(Stdio::null())
135                .status()
136                .ok()
137                .filter(|s| s.success())
138        })
139        .await
140        .ok()
141        .flatten()
142    };
143
144    if result.is_some() {
145        let abs = resolve_absolute_path(&name);
146        tracing::debug!(
147            "[perf] find_tool({}) took {:?}",
148            tool,
149            _timer_start.elapsed()
150        );
151        return Some(abs);
152    }
153
154    tracing::debug!(
155        "[perf] find_tool({}) took {:?}",
156        tool,
157        _timer_start.elapsed()
158    );
159    None
160}
161
162/// Resolve a bare binary name to its absolute path via `where` (Windows)
163/// or `which` (Unix). Returns the original name as fallback.
164fn resolve_absolute_path(bin_name: &str) -> PathBuf {
165    let finder = if cfg!(target_os = "windows") {
166        "where"
167    } else {
168        "which"
169    };
170    if let Ok(output) = crate::core::process::std_command(finder)
171        .arg(bin_name)
172        .stdout(std::process::Stdio::piped())
173        .stderr(std::process::Stdio::null())
174        .output()
175    {
176        if output.status.success() {
177            if let Some(line) = String::from_utf8_lossy(&output.stdout).lines().next() {
178                let path = line.trim();
179                if !path.is_empty() {
180                    return PathBuf::from(path);
181                }
182            }
183        }
184    }
185    PathBuf::from(bin_name)
186}
187
188fn version_flag_for(tool: &str) -> &'static str {
189    match tool {
190        "ffmpeg" | "ffprobe" => "-version",
191        _ => "--version",
192    }
193}
194
195pub async fn check_version(tool: &str) -> Option<String> {
196    let _timer_start = std::time::Instant::now();
197    let path = find_tool(tool).await?;
198    let version_flag = version_flag_for(tool);
199    let output = {
200        let path = path.clone();
201        let vf = version_flag.to_string();
202        tokio::task::spawn_blocking(move || {
203            crate::core::process::std_command(&path)
204                .arg(&vf)
205                .stdout(Stdio::piped())
206                .stderr(Stdio::piped())
207                .output()
208        })
209        .await
210        .ok()?
211        .ok()?
212    };
213
214    if !output.status.success() {
215        tracing::debug!(
216            "[perf] check_version({}) took {:?}",
217            tool,
218            _timer_start.elapsed()
219        );
220        return None;
221    }
222
223    let stdout = String::from_utf8_lossy(&output.stdout);
224    let first_line = stdout.lines().next().unwrap_or("");
225
226    let result = if tool == "ffmpeg" || tool == "ffprobe" {
227        first_line.split_whitespace().nth(2).map(|s| s.to_string())
228    } else if tool == "yt-dlp" {
229        Some(first_line.trim().to_string())
230    } else if tool == "aria2c" {
231        first_line.split_whitespace().nth(2).map(|s| s.to_string())
232    } else {
233        Some(first_line.trim().to_string())
234    };
235
236    tracing::debug!(
237        "[perf] check_version({}) took {:?}",
238        tool,
239        _timer_start.elapsed()
240    );
241    result
242}
243
244pub async fn ensure_ffmpeg(
245    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
246) -> anyhow::Result<PathBuf> {
247    // Always ensure the managed binary exists — the standalone yt-dlp.exe
248    // cannot discover system FFmpeg from PATH.
249    if !is_flatpak() {
250        let managed = managed_bin_dir().map(|d| d.join(bin_name("ffmpeg")));
251        if managed.as_ref().is_none_or(|p| !p.exists()) {
252            if let Ok(path) = download_ffmpeg(reporter).await {
253                crate::core::ytdlp::reset_ffmpeg_location_cache();
254                return Ok(path);
255            }
256        }
257    }
258
259    if let Some(path) = find_tool("ffmpeg").await {
260        return Ok(path);
261    }
262    if is_flatpak() {
263        return Err(anyhow!("FFmpeg not found in Flatpak sandbox"));
264    }
265    let path = download_ffmpeg(reporter).await?;
266    crate::core::ytdlp::reset_ffmpeg_location_cache();
267    Ok(path)
268}
269
270async fn download_ffmpeg(
271    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
272) -> anyhow::Result<PathBuf> {
273    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
274    std::fs::create_dir_all(&bin_dir)?;
275
276    let ffmpeg_name = bin_name("ffmpeg");
277    let ffprobe_name = bin_name("ffprobe");
278    let ffmpeg_target = bin_dir.join(&ffmpeg_name);
279
280    let downloads = ffmpeg_download_urls();
281
282    for (url, archive_type) in downloads {
283        tracing::info!("Downloading FFmpeg component from {}", url);
284        let bytes = crate::core::http_client::download_with_progress(url, |percent| {
285            if let Some(r) = reporter {
286                r.on_system_progress("ffmpeg", percent, "Downloading FFmpeg...");
287            }
288        })
289        .await?;
290
291        let temp_path = bin_dir.join(".ffmpeg_download.tmp");
292        let data = bytes.to_vec();
293        let temp_clone = temp_path.clone();
294        tokio::task::spawn_blocking(move || std::fs::write(&temp_clone, &data))
295            .await
296            .map_err(|e| anyhow!("spawn_blocking failed: {}", e))??;
297
298        let file_size = std::fs::metadata(&temp_path)?.len();
299        if file_size < 1_000_000 {
300            let _ = std::fs::remove_file(&temp_path);
301            return Err(anyhow!(
302                "Downloaded file from {} is too small ({}B) — likely an error page",
303                url,
304                file_size
305            ));
306        }
307
308        match archive_type {
309            ArchiveType::Zip => {
310                extract_zip_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
311            }
312            ArchiveType::TarXz => {
313                extract_tar_xz_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
314            }
315        }
316
317        let _ = std::fs::remove_file(&temp_path);
318    }
319
320    #[cfg(unix)]
321    {
322        use std::os::unix::fs::PermissionsExt;
323        let perms = std::fs::Permissions::from_mode(0o755);
324        let _ = std::fs::set_permissions(&ffmpeg_target, perms.clone());
325        let ffprobe_path = bin_dir.join(&ffprobe_name);
326        if ffprobe_path.exists() {
327            let _ = std::fs::set_permissions(&ffprobe_path, perms);
328        }
329    }
330
331    #[cfg(target_os = "macos")]
332    {
333        let ffmpeg_mac = ffmpeg_target.clone();
334        if let Err(e) = tokio::task::spawn_blocking(move || {
335            crate::core::process::std_command("xattr")
336                .args(["-d", "com.apple.quarantine"])
337                .arg(&ffmpeg_mac)
338                .output()
339        })
340        .await
341        .map_err(|e| std::io::Error::other(e.to_string()))
342        .and_then(|r| r)
343        {
344            tracing::warn!("Failed to remove quarantine from ffmpeg: {}", e);
345        }
346        let ffprobe_path = bin_dir.join(&ffprobe_name);
347        if ffprobe_path.exists() {
348            let ffprobe_mac = ffprobe_path.clone();
349            if let Err(e) = tokio::task::spawn_blocking(move || {
350                crate::core::process::std_command("xattr")
351                    .args(["-d", "com.apple.quarantine"])
352                    .arg(&ffprobe_mac)
353                    .output()
354            })
355            .await
356            .map_err(|e| std::io::Error::other(e.to_string()))
357            .and_then(|r| r)
358            {
359                tracing::warn!("Failed to remove quarantine from ffprobe: {}", e);
360            }
361        }
362    }
363
364    if !ffmpeg_target.exists() {
365        return Err(anyhow!("FFmpeg binary not found after extraction"));
366    }
367
368    let verify = {
369        let target = ffmpeg_target.clone();
370        tokio::task::spawn_blocking(move || {
371            crate::core::process::std_command(&target)
372                .arg("-version")
373                .stdout(Stdio::null())
374                .stderr(Stdio::null())
375                .status()
376        })
377        .await
378        .map_err(|e| anyhow!("spawn_blocking failed: {}", e))?
379    };
380    match verify {
381        Ok(s) if s.success() => {}
382        Ok(s) => {
383            return Err(anyhow!(
384                "FFmpeg installed but failed to execute (exit code {})",
385                s
386            ))
387        }
388        Err(e) => return Err(anyhow!("FFmpeg installed but failed to execute: {}", e)),
389    }
390
391    tracing::info!("FFmpeg installed to {}", ffmpeg_target.display());
392    Ok(ffmpeg_target)
393}
394
395enum ArchiveType {
396    Zip,
397    TarXz,
398}
399
400fn ffmpeg_download_urls() -> Vec<(&'static str, ArchiveType)> {
401    if cfg!(target_os = "windows") {
402        vec![(
403            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
404            ArchiveType::Zip,
405        )]
406    } else if cfg!(target_os = "macos") {
407        vec![
408            (
409                "https://evermeet.cx/ffmpeg/getrelease/zip",
410                ArchiveType::Zip,
411            ),
412            (
413                "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip",
414                ArchiveType::Zip,
415            ),
416        ]
417    } else if cfg!(target_arch = "aarch64") {
418        vec![(
419            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
420            ArchiveType::TarXz,
421        )]
422    } else {
423        vec![(
424            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
425            ArchiveType::TarXz,
426        )]
427    }
428}
429
430async fn extract_zip_ffmpeg(
431    archive_path: &std::path::Path,
432    bin_dir: &std::path::Path,
433    ffmpeg_name: &str,
434    ffprobe_name: &str,
435) -> anyhow::Result<()> {
436    let archive_path = archive_path.to_path_buf();
437    let bin_dir = bin_dir.to_path_buf();
438    let ffmpeg_name = ffmpeg_name.to_string();
439    let ffprobe_name = ffprobe_name.to_string();
440
441    tokio::task::spawn_blocking(move || {
442        let file = std::fs::File::open(&archive_path)
443            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
444        let mut archive =
445            zip::ZipArchive::new(file).map_err(|e| anyhow!("Failed to open zip: {}", e))?;
446
447        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
448
449        for i in 0..archive.len() {
450            let mut entry = archive
451                .by_index(i)
452                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
453
454            let name = entry.name().to_string();
455            for target in &targets {
456                if name.ends_with(target) {
457                    let dest = bin_dir.join(target);
458                    let mut out = std::fs::File::create(&dest)?;
459                    std::io::copy(&mut entry, &mut out)?;
460                    break;
461                }
462            }
463        }
464
465        Ok::<(), anyhow::Error>(())
466    })
467    .await
468    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
469
470    Ok(())
471}
472
473async fn extract_tar_xz_ffmpeg(
474    archive_path: &std::path::Path,
475    bin_dir: &std::path::Path,
476    ffmpeg_name: &str,
477    ffprobe_name: &str,
478) -> anyhow::Result<()> {
479    let archive_path = archive_path.to_path_buf();
480    let bin_dir = bin_dir.to_path_buf();
481    let ffmpeg_name = ffmpeg_name.to_string();
482    let ffprobe_name = ffprobe_name.to_string();
483
484    tokio::task::spawn_blocking(move || {
485        let file = std::fs::File::open(&archive_path)
486            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
487        let decompressor = xz2::read::XzDecoder::new(file);
488        let mut archive = tar::Archive::new(decompressor);
489        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
490
491        for entry_result in archive
492            .entries()
493            .map_err(|e| anyhow!("Failed to read tar entries: {}", e))?
494        {
495            let mut entry = entry_result.map_err(|e| anyhow!("Failed to read tar entry: {}", e))?;
496            let path = entry
497                .path()
498                .map_err(|e| anyhow!("Failed to read entry path: {}", e))?;
499            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
500            for target in &targets {
501                if file_name == *target {
502                    let dest = bin_dir.join(target);
503                    let mut out = std::fs::File::create(&dest)?;
504                    std::io::copy(&mut entry, &mut out)?;
505                    break;
506                }
507            }
508        }
509        Ok::<(), anyhow::Error>(())
510    })
511    .await
512    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
513    Ok(())
514}
515
516// --- aria2c ---
517
518// --- deno (JS runtime for yt-dlp nsig challenge) ---
519
520/// Ensures a JavaScript runtime is available for yt-dlp's YouTube nsig
521/// challenge solver. Checks for any existing runtime first (Node.js, Deno,
522/// Bun), then auto-downloads Deno if none is found.
523pub async fn ensure_js_runtime(
524    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
525) -> Option<PathBuf> {
526    // Check system-installed runtimes first.
527    for tool in &["deno", "node", "bun"] {
528        if let Some(path) = find_tool(tool).await {
529            return Some(path);
530        }
531    }
532
533    // Check well-known install locations on Windows.
534    #[cfg(target_os = "windows")]
535    {
536        let candidates = [
537            r"C:\Program Files\nodejs\node.exe",
538            r"C:\Program Files (x86)\nodejs\node.exe",
539        ];
540        for path in &candidates {
541            let p = std::path::PathBuf::from(path);
542            if p.exists() {
543                return Some(p);
544            }
545        }
546    }
547
548    match download_deno(reporter).await {
549        Ok(path) => Some(path),
550        Err(e) => {
551            tracing::warn!("Failed to download Deno JS runtime: {}", e);
552            None
553        }
554    }
555}
556
557async fn download_deno(
558    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
559) -> anyhow::Result<PathBuf> {
560    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
561    std::fs::create_dir_all(&bin_dir)?;
562
563    let deno_name = bin_name("deno");
564    let deno_target = bin_dir.join(&deno_name);
565
566    if deno_target.exists() {
567        return Ok(deno_target);
568    }
569
570    let url = if cfg!(target_os = "windows") {
571        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip"
572    } else if cfg!(target_os = "macos") {
573        if cfg!(target_arch = "aarch64") {
574            "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-apple-darwin.zip"
575        } else {
576            "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-apple-darwin.zip"
577        }
578    } else if cfg!(target_arch = "aarch64") {
579        "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-unknown-linux-gnu.zip"
580    } else {
581        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip"
582    };
583
584    tracing::info!("Downloading Deno JS runtime from {}", url);
585
586    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
587        if let Some(r) = reporter {
588            r.on_system_progress("deno", percent, "Downloading Deno...");
589        }
590    })
591    .await?;
592    let data = bytes.to_vec();
593    let bin_dir_clone = bin_dir.clone();
594    let deno_name_clone = deno_name.clone();
595
596    tokio::task::spawn_blocking(move || {
597        let cursor = std::io::Cursor::new(&data);
598        let mut archive =
599            zip::ZipArchive::new(cursor).map_err(|e| anyhow!("Failed to open Deno zip: {}", e))?;
600
601        for i in 0..archive.len() {
602            let mut file = archive
603                .by_index(i)
604                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
605
606            let name = file.name().to_string();
607            if name.ends_with(&deno_name_clone) || name == "deno" || name == "deno.exe" {
608                let dest = bin_dir_clone.join(&deno_name_clone);
609                let mut buf = Vec::new();
610                std::io::Read::read_to_end(&mut file, &mut buf)?;
611                std::fs::write(&dest, &buf)?;
612                break;
613            }
614        }
615
616        Ok::<(), anyhow::Error>(())
617    })
618    .await
619    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
620
621    if !deno_target.exists() {
622        return Err(anyhow!("Deno binary not found after extraction"));
623    }
624
625    #[cfg(unix)]
626    {
627        use std::os::unix::fs::PermissionsExt;
628        let _ = std::fs::set_permissions(&deno_target, std::fs::Permissions::from_mode(0o755));
629    }
630
631    #[cfg(target_os = "macos")]
632    {
633        let deno_mac = deno_target.clone();
634        let _ = tokio::task::spawn_blocking(move || {
635            crate::core::process::std_command("xattr")
636                .args(["-d", "com.apple.quarantine"])
637                .arg(&deno_mac)
638                .output()
639        })
640        .await;
641    }
642
643    tracing::info!("Deno installed to {}", deno_target.display());
644    Ok(deno_target)
645}
646
647pub async fn ensure_aria2c(
648    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
649) -> Option<PathBuf> {
650    if let Some(path) = find_tool("aria2c").await {
651        return Some(path);
652    }
653
654    // Auto-download only on Windows
655    #[cfg(target_os = "windows")]
656    {
657        match download_aria2c(reporter).await {
658            Ok(path) => return Some(path),
659            Err(e) => {
660                tracing::warn!("Failed to download aria2c: {}", e);
661            }
662        }
663    }
664
665    None
666}
667
668#[cfg(target_os = "windows")]
669async fn download_aria2c(
670    reporter: Option<&dyn crate::core::traits::DownloadReporter>,
671) -> anyhow::Result<PathBuf> {
672    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
673    std::fs::create_dir_all(&bin_dir)?;
674
675    let aria2c_name = bin_name("aria2c");
676    let aria2c_target = bin_dir.join(&aria2c_name);
677
678    let url = "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip";
679
680    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
681        if let Some(r) = reporter {
682            r.on_system_progress("aria2c", percent, "Downloading aria2c...");
683        }
684    })
685    .await?;
686
687    let data = bytes.to_vec();
688    let bin_dir_clone = bin_dir.clone();
689    let aria2c_name_clone = aria2c_name.clone();
690
691    tokio::task::spawn_blocking(move || {
692        let cursor = std::io::Cursor::new(&data);
693        let mut archive = zip::ZipArchive::new(cursor)
694            .map_err(|e| anyhow!("Failed to open aria2c zip: {}", e))?;
695
696        for i in 0..archive.len() {
697            let mut file = archive
698                .by_index(i)
699                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
700
701            let name = file.name().to_string();
702            if name.ends_with(&aria2c_name_clone) {
703                let dest = bin_dir_clone.join(&aria2c_name_clone);
704                let mut buf = Vec::new();
705                std::io::Read::read_to_end(&mut file, &mut buf)?;
706                std::fs::write(&dest, &buf)?;
707                break;
708            }
709        }
710
711        Ok::<(), anyhow::Error>(())
712    })
713    .await
714    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
715
716    if !aria2c_target.exists() {
717        return Err(anyhow!("aria2c binary not found after extraction"));
718    }
719
720    Ok(aria2c_target)
721}