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 fn parse_version_output(tool: &str, stdout: &str) -> Option<String> {
196    let first_line = stdout.lines().next().unwrap_or("");
197
198    if tool == "ffmpeg" || tool == "ffprobe" {
199        first_line.split_whitespace().nth(2).map(|s| s.to_string())
200    } else if tool == "yt-dlp" {
201        if first_line.trim().is_empty() {
202            None
203        } else {
204            Some(first_line.trim().to_string())
205        }
206    } else if tool == "aria2c" {
207        first_line.split_whitespace().nth(2).map(|s| s.to_string())
208    } else {
209        if first_line.trim().is_empty() {
210            None
211        } else {
212            Some(first_line.trim().to_string())
213        }
214    }
215}
216
217pub async fn check_version(tool: &str) -> Option<String> {
218    let _timer_start = std::time::Instant::now();
219    let path = find_tool(tool).await?;
220    let version_flag = version_flag_for(tool);
221    let output = {
222        let path = path.clone();
223        let vf = version_flag.to_string();
224        tokio::task::spawn_blocking(move || {
225            crate::core::process::std_command(&path)
226                .arg(&vf)
227                .stdout(Stdio::piped())
228                .stderr(Stdio::piped())
229                .output()
230        })
231        .await
232        .ok()?
233        .ok()?
234    };
235
236    if !output.status.success() {
237        tracing::debug!(
238            "[perf] check_version({}) took {:?}",
239            tool,
240            _timer_start.elapsed()
241        );
242        return None;
243    }
244
245    let stdout = String::from_utf8_lossy(&output.stdout);
246    let result = parse_version_output(tool, &stdout);
247
248    tracing::debug!(
249        "[perf] check_version({}) took {:?}",
250        tool,
251        _timer_start.elapsed()
252    );
253    result
254}
255
256pub async fn ensure_ffmpeg(
257    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
258) -> anyhow::Result<PathBuf> {
259    // Always ensure the managed binary exists — the standalone yt-dlp.exe
260    // cannot discover system FFmpeg from PATH.
261    if !is_flatpak() {
262        let managed = managed_bin_dir().map(|d| d.join(bin_name("ffmpeg")));
263        if managed.as_ref().is_none_or(|p| !p.exists()) {
264            if let Ok(path) = download_ffmpeg(_reporter).await {
265                crate::core::ytdlp::reset_ffmpeg_location_cache();
266                return Ok(path);
267            }
268        }
269    }
270
271    if let Some(path) = find_tool("ffmpeg").await {
272        return Ok(path);
273    }
274    if is_flatpak() {
275        return Err(anyhow!("FFmpeg not found in Flatpak sandbox"));
276    }
277    let path = download_ffmpeg(_reporter).await?;
278    crate::core::ytdlp::reset_ffmpeg_location_cache();
279    Ok(path)
280}
281
282async fn download_ffmpeg(
283    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
284) -> anyhow::Result<PathBuf> {
285    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
286    std::fs::create_dir_all(&bin_dir)?;
287
288    let ffmpeg_name = bin_name("ffmpeg");
289    let ffprobe_name = bin_name("ffprobe");
290    let ffmpeg_target = bin_dir.join(&ffmpeg_name);
291
292    let downloads = ffmpeg_download_urls();
293
294    for (url, archive_type) in downloads {
295        tracing::info!("Downloading FFmpeg component from {}", url);
296        let bytes = crate::core::http_client::download_with_progress(url, |percent| {
297            if let Some(r) = _reporter {
298                r.on_system_progress("ffmpeg", percent, "Downloading FFmpeg...");
299            }
300        })
301        .await?;
302
303        let temp_path = bin_dir.join(".ffmpeg_download.tmp");
304        let data = bytes.to_vec();
305        let temp_clone = temp_path.clone();
306        tokio::task::spawn_blocking(move || std::fs::write(&temp_clone, &data))
307            .await
308            .map_err(|e| anyhow!("spawn_blocking failed: {}", e))??;
309
310        let file_size = std::fs::metadata(&temp_path)?.len();
311        if file_size < 1_000_000 {
312            let _ = std::fs::remove_file(&temp_path);
313            return Err(anyhow!(
314                "Downloaded file from {} is too small ({}B) — likely an error page",
315                url,
316                file_size
317            ));
318        }
319
320        match archive_type {
321            ArchiveType::Zip => {
322                extract_zip_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
323            }
324            ArchiveType::TarXz => {
325                extract_tar_xz_ffmpeg(&temp_path, &bin_dir, &ffmpeg_name, &ffprobe_name).await?
326            }
327        }
328
329        let _ = std::fs::remove_file(&temp_path);
330    }
331
332    #[cfg(unix)]
333    {
334        use std::os::unix::fs::PermissionsExt;
335        let perms = std::fs::Permissions::from_mode(0o755);
336        let _ = std::fs::set_permissions(&ffmpeg_target, perms.clone());
337        let ffprobe_path = bin_dir.join(&ffprobe_name);
338        if ffprobe_path.exists() {
339            let _ = std::fs::set_permissions(&ffprobe_path, perms);
340        }
341    }
342
343    #[cfg(target_os = "macos")]
344    {
345        let ffmpeg_mac = ffmpeg_target.clone();
346        if let Err(e) = tokio::task::spawn_blocking(move || {
347            crate::core::process::std_command("xattr")
348                .args(["-d", "com.apple.quarantine"])
349                .arg(&ffmpeg_mac)
350                .output()
351        })
352        .await
353        .map_err(|e| std::io::Error::other(e.to_string()))
354        .and_then(|r| r)
355        {
356            tracing::warn!("Failed to remove quarantine from ffmpeg: {}", e);
357        }
358        let ffprobe_path = bin_dir.join(&ffprobe_name);
359        if ffprobe_path.exists() {
360            let ffprobe_mac = ffprobe_path.clone();
361            if let Err(e) = tokio::task::spawn_blocking(move || {
362                crate::core::process::std_command("xattr")
363                    .args(["-d", "com.apple.quarantine"])
364                    .arg(&ffprobe_mac)
365                    .output()
366            })
367            .await
368            .map_err(|e| std::io::Error::other(e.to_string()))
369            .and_then(|r| r)
370            {
371                tracing::warn!("Failed to remove quarantine from ffprobe: {}", e);
372            }
373        }
374    }
375
376    if !ffmpeg_target.exists() {
377        return Err(anyhow!("FFmpeg binary not found after extraction"));
378    }
379
380    let verify = {
381        let target = ffmpeg_target.clone();
382        tokio::task::spawn_blocking(move || {
383            crate::core::process::std_command(&target)
384                .arg("-version")
385                .stdout(Stdio::null())
386                .stderr(Stdio::null())
387                .status()
388        })
389        .await
390        .map_err(|e| anyhow!("spawn_blocking failed: {}", e))?
391    };
392    match verify {
393        Ok(s) if s.success() => {}
394        Ok(s) => {
395            return Err(anyhow!(
396                "FFmpeg installed but failed to execute (exit code {})",
397                s
398            ))
399        }
400        Err(e) => return Err(anyhow!("FFmpeg installed but failed to execute: {}", e)),
401    }
402
403    tracing::info!("FFmpeg installed to {}", ffmpeg_target.display());
404    Ok(ffmpeg_target)
405}
406
407enum ArchiveType {
408    Zip,
409    TarXz,
410}
411
412fn ffmpeg_download_urls() -> Vec<(&'static str, ArchiveType)> {
413    if cfg!(target_os = "windows") {
414        vec![(
415            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip",
416            ArchiveType::Zip,
417        )]
418    } else if cfg!(target_os = "macos") {
419        vec![
420            (
421                "https://evermeet.cx/ffmpeg/getrelease/zip",
422                ArchiveType::Zip,
423            ),
424            (
425                "https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip",
426                ArchiveType::Zip,
427            ),
428        ]
429    } else if cfg!(target_arch = "aarch64") {
430        vec![(
431            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linuxarm64-gpl.tar.xz",
432            ArchiveType::TarXz,
433        )]
434    } else {
435        vec![(
436            "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz",
437            ArchiveType::TarXz,
438        )]
439    }
440}
441
442async fn extract_zip_ffmpeg(
443    archive_path: &std::path::Path,
444    bin_dir: &std::path::Path,
445    ffmpeg_name: &str,
446    ffprobe_name: &str,
447) -> anyhow::Result<()> {
448    let archive_path = archive_path.to_path_buf();
449    let bin_dir = bin_dir.to_path_buf();
450    let ffmpeg_name = ffmpeg_name.to_string();
451    let ffprobe_name = ffprobe_name.to_string();
452
453    tokio::task::spawn_blocking(move || {
454        let file = std::fs::File::open(&archive_path)
455            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
456        let mut archive =
457            zip::ZipArchive::new(file).map_err(|e| anyhow!("Failed to open zip: {}", e))?;
458
459        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
460
461        for i in 0..archive.len() {
462            let mut entry = archive
463                .by_index(i)
464                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
465
466            let name = entry.name().to_string();
467            for target in &targets {
468                if name.ends_with(target) {
469                    let dest = bin_dir.join(target);
470                    let mut out = std::fs::File::create(&dest)?;
471                    std::io::copy(&mut entry, &mut out)?;
472                    break;
473                }
474            }
475        }
476
477        Ok::<(), anyhow::Error>(())
478    })
479    .await
480    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
481
482    Ok(())
483}
484
485async fn extract_tar_xz_ffmpeg(
486    archive_path: &std::path::Path,
487    bin_dir: &std::path::Path,
488    ffmpeg_name: &str,
489    ffprobe_name: &str,
490) -> anyhow::Result<()> {
491    let archive_path = archive_path.to_path_buf();
492    let bin_dir = bin_dir.to_path_buf();
493    let ffmpeg_name = ffmpeg_name.to_string();
494    let ffprobe_name = ffprobe_name.to_string();
495
496    tokio::task::spawn_blocking(move || {
497        let file = std::fs::File::open(&archive_path)
498            .map_err(|e| anyhow!("Failed to open archive: {}", e))?;
499        let decompressor = xz2::read::XzDecoder::new(file);
500        let mut archive = tar::Archive::new(decompressor);
501        let targets = [ffmpeg_name.as_str(), ffprobe_name.as_str()];
502
503        for entry_result in archive
504            .entries()
505            .map_err(|e| anyhow!("Failed to read tar entries: {}", e))?
506        {
507            let mut entry = entry_result.map_err(|e| anyhow!("Failed to read tar entry: {}", e))?;
508            let path = entry
509                .path()
510                .map_err(|e| anyhow!("Failed to read entry path: {}", e))?;
511            let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
512            for target in &targets {
513                if file_name == *target {
514                    let dest = bin_dir.join(target);
515                    let mut out = std::fs::File::create(&dest)?;
516                    std::io::copy(&mut entry, &mut out)?;
517                    break;
518                }
519            }
520        }
521        Ok::<(), anyhow::Error>(())
522    })
523    .await
524    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
525    Ok(())
526}
527
528// --- aria2c ---
529
530// --- deno (JS runtime for yt-dlp nsig challenge) ---
531
532/// Ensures a JavaScript runtime is available for yt-dlp's YouTube nsig
533/// challenge solver. Checks for any existing runtime first (Node.js, Deno,
534/// Bun), then auto-downloads Deno if none is found.
535pub async fn ensure_js_runtime(
536    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
537) -> Option<PathBuf> {
538    // Check system-installed runtimes first.
539    for tool in &["deno", "node", "bun"] {
540        if let Some(path) = find_tool(tool).await {
541            return Some(path);
542        }
543    }
544
545    // Check well-known install locations on Windows.
546    #[cfg(target_os = "windows")]
547    {
548        let candidates = [
549            r"C:\Program Files\nodejs\node.exe",
550            r"C:\Program Files (x86)\nodejs\node.exe",
551        ];
552        for path in &candidates {
553            let p = std::path::PathBuf::from(path);
554            if p.exists() {
555                return Some(p);
556            }
557        }
558    }
559
560    match download_deno(_reporter).await {
561        Ok(path) => Some(path),
562        Err(e) => {
563            tracing::warn!("Failed to download Deno JS runtime: {}", e);
564            None
565        }
566    }
567}
568
569async fn download_deno(
570    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
571) -> anyhow::Result<PathBuf> {
572    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
573    std::fs::create_dir_all(&bin_dir)?;
574
575    let deno_name = bin_name("deno");
576    let deno_target = bin_dir.join(&deno_name);
577
578    if deno_target.exists() {
579        return Ok(deno_target);
580    }
581
582    let url = if cfg!(target_os = "windows") {
583        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-pc-windows-msvc.zip"
584    } else if cfg!(target_os = "macos") {
585        if cfg!(target_arch = "aarch64") {
586            "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-apple-darwin.zip"
587        } else {
588            "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-apple-darwin.zip"
589        }
590    } else if cfg!(target_arch = "aarch64") {
591        "https://github.com/denoland/deno/releases/latest/download/deno-aarch64-unknown-linux-gnu.zip"
592    } else {
593        "https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip"
594    };
595
596    tracing::info!("Downloading Deno JS runtime from {}", url);
597
598    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
599        if let Some(r) = _reporter {
600            r.on_system_progress("deno", percent, "Downloading Deno...");
601        }
602    })
603    .await?;
604    let data = bytes.to_vec();
605    let bin_dir_clone = bin_dir.clone();
606    let deno_name_clone = deno_name.clone();
607
608    tokio::task::spawn_blocking(move || {
609        let cursor = std::io::Cursor::new(&data);
610        let mut archive =
611            zip::ZipArchive::new(cursor).map_err(|e| anyhow!("Failed to open Deno zip: {}", e))?;
612
613        for i in 0..archive.len() {
614            let mut file = archive
615                .by_index(i)
616                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
617
618            let name = file.name().to_string();
619            if name.ends_with(&deno_name_clone) || name == "deno" || name == "deno.exe" {
620                let dest = bin_dir_clone.join(&deno_name_clone);
621                let mut buf = Vec::new();
622                std::io::Read::read_to_end(&mut file, &mut buf)?;
623                std::fs::write(&dest, &buf)?;
624                break;
625            }
626        }
627
628        Ok::<(), anyhow::Error>(())
629    })
630    .await
631    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
632
633    if !deno_target.exists() {
634        return Err(anyhow!("Deno binary not found after extraction"));
635    }
636
637    #[cfg(unix)]
638    {
639        use std::os::unix::fs::PermissionsExt;
640        let _ = std::fs::set_permissions(&deno_target, std::fs::Permissions::from_mode(0o755));
641    }
642
643    #[cfg(target_os = "macos")]
644    {
645        let deno_mac = deno_target.clone();
646        let _ = tokio::task::spawn_blocking(move || {
647            crate::core::process::std_command("xattr")
648                .args(["-d", "com.apple.quarantine"])
649                .arg(&deno_mac)
650                .output()
651        })
652        .await;
653    }
654
655    tracing::info!("Deno installed to {}", deno_target.display());
656    Ok(deno_target)
657}
658
659pub async fn ensure_aria2c(
660    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
661) -> Option<PathBuf> {
662    if let Some(path) = find_tool("aria2c").await {
663        return Some(path);
664    }
665
666    // Auto-download only on Windows
667    #[cfg(target_os = "windows")]
668    {
669        match download_aria2c(_reporter).await {
670            Ok(path) => return Some(path),
671            Err(e) => {
672                tracing::warn!("Failed to download aria2c: {}", e);
673            }
674        }
675    }
676
677    None
678}
679
680#[cfg(target_os = "windows")]
681async fn download_aria2c(
682    _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
683) -> anyhow::Result<PathBuf> {
684    let bin_dir = managed_bin_dir().ok_or_else(|| anyhow!("Could not determine data directory"))?;
685    std::fs::create_dir_all(&bin_dir)?;
686
687    let aria2c_name = bin_name("aria2c");
688    let aria2c_target = bin_dir.join(&aria2c_name);
689
690    let url = "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip";
691
692    let bytes = crate::core::http_client::download_with_progress(url, |percent| {
693        if let Some(r) = _reporter {
694            r.on_system_progress("aria2c", percent, "Downloading aria2c...");
695        }
696    })
697    .await?;
698
699    let data = bytes.to_vec();
700    let bin_dir_clone = bin_dir.clone();
701    let aria2c_name_clone = aria2c_name.clone();
702
703    tokio::task::spawn_blocking(move || {
704        let cursor = std::io::Cursor::new(&data);
705        let mut archive = zip::ZipArchive::new(cursor)
706            .map_err(|e| anyhow!("Failed to open aria2c zip: {}", e))?;
707
708        for i in 0..archive.len() {
709            let mut file = archive
710                .by_index(i)
711                .map_err(|e| anyhow!("Failed to read zip entry: {}", e))?;
712
713            let name = file.name().to_string();
714            if name.ends_with(&aria2c_name_clone) {
715                let dest = bin_dir_clone.join(&aria2c_name_clone);
716                let mut buf = Vec::new();
717                std::io::Read::read_to_end(&mut file, &mut buf)?;
718                std::fs::write(&dest, &buf)?;
719                break;
720            }
721        }
722
723        Ok::<(), anyhow::Error>(())
724    })
725    .await
726    .map_err(|e| anyhow!("Spawn blocking failed: {}", e))??;
727
728    if !aria2c_target.exists() {
729        return Err(anyhow!("aria2c binary not found after extraction"));
730    }
731
732    Ok(aria2c_target)
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use crate::core::events::QueueItemProgress;
739    use crate::core::traits::DownloadReporter;
740    use crate::models::queue::QueueItemInfo;
741    use crate::models::settings::ProxySettings;
742    use std::sync::Arc;
743    use std::sync::Mutex;
744
745    static TEST_MUTEX: Mutex<()> = Mutex::new(());
746
747    struct MockReporter;
748
749    impl DownloadReporter for MockReporter {
750        fn on_progress(&self, _id: u64, _prog: QueueItemProgress) {}
751        fn on_complete(&self, _id: u64, _path: Option<String>, _size: Option<u64>) {}
752        fn on_error(&self, _id: u64, _msg: String) {}
753        fn on_retry(&self, _id: u64, _attempt: u32, _delay: u64) {}
754        fn on_phase_change(&self, _id: u64, _phase: String) {}
755        fn on_media_preview(
756            &self,
757            _u: String,
758            _t: String,
759            _a: String,
760            _th: Option<String>,
761            _d: Option<f64>,
762        ) {
763        }
764        fn on_queue_update(&self, _s: Vec<QueueItemInfo>) {}
765        fn on_system_progress(&self, _title: &str, _pct: f32, _msg: &str) {}
766    }
767
768    #[test]
769    fn test_bin_name() {
770        if cfg!(target_os = "windows") {
771            assert_eq!(bin_name("test-tool"), "test-tool.exe");
772            assert_eq!(bin_name("yt-dlp"), "yt-dlp.exe");
773        } else {
774            assert_eq!(bin_name("test-tool"), "test-tool");
775            assert_eq!(bin_name("yt-dlp"), "yt-dlp");
776        }
777    }
778
779    #[test]
780    fn test_is_flatpak() {
781        let _guard = TEST_MUTEX.lock().unwrap();
782
783        let original_val = std::env::var("FLATPAK_ID");
784
785        std::env::set_var("FLATPAK_ID", "org.mangofetch.App");
786        assert!(is_flatpak(), "Should be true when FLATPAK_ID is set");
787
788        std::env::remove_var("FLATPAK_ID");
789        let expected = std::path::Path::new("/.flatpak-info").exists();
790        assert_eq!(
791            is_flatpak(),
792            expected,
793            "When FLATPAK_ID is not set, it should match the existence of /.flatpak-info"
794        );
795
796        match original_val {
797            Ok(v) => std::env::set_var("FLATPAK_ID", v),
798            Err(_) => std::env::remove_var("FLATPAK_ID"),
799        }
800    }
801
802    #[test]
803    fn test_parse_version_output() {
804        // ffmpeg
805        assert_eq!(
806            parse_version_output("ffmpeg", "ffmpeg version 2024-05-13-git-93afb9c47c-full_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers"),
807            Some("2024-05-13-git-93afb9c47c-full_build-www.gyan.dev".to_string())
808        );
809        assert_eq!(
810            parse_version_output(
811                "ffmpeg",
812                "ffmpeg version N-111111-g1234567890 Copyright (c) 2000-2023 the FFmpeg developers"
813            ),
814            Some("N-111111-g1234567890".to_string())
815        );
816        assert_eq!(parse_version_output("ffmpeg", "ffmpeg version"), None);
817        assert_eq!(parse_version_output("ffmpeg", ""), None);
818
819        // ffprobe
820        assert_eq!(
821            parse_version_output("ffprobe", "ffprobe version 2024-05-13-git-93afb9c47c-full_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers"),
822            Some("2024-05-13-git-93afb9c47c-full_build-www.gyan.dev".to_string())
823        );
824        assert_eq!(parse_version_output("ffprobe", "ffprobe version"), None);
825        assert_eq!(parse_version_output("ffprobe", ""), None);
826
827        // yt-dlp
828        assert_eq!(
829            parse_version_output("yt-dlp", "2024.04.09\n"),
830            Some("2024.04.09".to_string())
831        );
832        assert_eq!(
833            parse_version_output("yt-dlp", "2023.11.16"),
834            Some("2023.11.16".to_string())
835        );
836        assert_eq!(
837            parse_version_output("yt-dlp", "  2024.04.09  "),
838            Some("2024.04.09".to_string())
839        );
840        assert_eq!(parse_version_output("yt-dlp", ""), None);
841        assert_eq!(parse_version_output("yt-dlp", "   \n"), None);
842
843        // aria2c
844        assert_eq!(
845            parse_version_output(
846                "aria2c",
847                "aria2 version 1.37.0\nCopyright (C) 2006, 2019 Tatsuhiro Tsujikawa"
848            ),
849            Some("1.37.0".to_string())
850        );
851        assert_eq!(
852            parse_version_output("aria2c", "aria2 version 1.36.0"),
853            Some("1.36.0".to_string())
854        );
855        assert_eq!(parse_version_output("aria2c", "aria2 version"), None);
856        assert_eq!(parse_version_output("aria2c", ""), None);
857
858        // other / default
859        assert_eq!(
860            parse_version_output("other", "1.2.3\n"),
861            Some("1.2.3".to_string())
862        );
863        assert_eq!(
864            parse_version_output("other", "  1.2.3  "),
865            Some("1.2.3".to_string())
866        );
867        assert_eq!(parse_version_output("other", ""), None);
868        assert_eq!(parse_version_output("other", "   \n"), None);
869    }
870
871    #[tokio::test]
872    async fn test_ensure_dependencies_force_error() {
873        let _guard = TEST_MUTEX.lock().unwrap();
874
875        let reporter: Arc<dyn DownloadReporter> = Arc::new(MockReporter);
876
877        // Set an invalid proxy to force download failures
878        crate::core::http_client::init_proxy(ProxySettings {
879            enabled: true,
880            proxy_type: "http".into(),
881            host: "0.0.0.0".into(), // Unroutable IP to simulate network failure
882            port: 1,
883            username: "".into(),
884            password: "".into(),
885        });
886
887        // Test with force=true
888        let result = ensure_dependencies(true, Some(reporter.clone())).await;
889
890        // ensure_dependencies itself should still succeed because it gracefully handles download errors
891        assert!(result.is_ok());
892        let deps = result.unwrap();
893
894        // However, the missing dependencies should not have been downloaded
895        assert!(
896            deps.ytdlp.is_none(),
897            "ytdlp should be none on network error"
898        );
899        assert!(
900            deps.ffmpeg.is_none(),
901            "ffmpeg should be none on network error"
902        );
903
904        // Restore global proxy setting
905        crate::core::http_client::init_proxy(ProxySettings::default());
906    }
907}