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 crate::core::ytdlp::reset_ytdlp_cache();
26 crate::core::ytdlp::reset_ffmpeg_location_cache();
27 crate::core::ytdlp::reset_js_runtime_cache();
28
29 let ytdlp = crate::core::ytdlp::force_update_ytdlp(rep_ref).await.ok();
31
32 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 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 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
162fn 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 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
528pub async fn ensure_js_runtime(
536 _reporter: Option<&dyn crate::core::traits::DownloadReporter>,
537) -> Option<PathBuf> {
538 for tool in &["deno", "node", "bun"] {
540 if let Some(path) = find_tool(tool).await {
541 return Some(path);
542 }
543 }
544
545 #[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 #[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 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 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 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 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 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 crate::core::http_client::init_proxy(ProxySettings {
879 enabled: true,
880 proxy_type: "http".into(),
881 host: "0.0.0.0".into(), port: 1,
883 username: "".into(),
884 password: "".into(),
885 });
886
887 let result = ensure_dependencies(true, Some(reporter.clone())).await;
889
890 assert!(result.is_ok());
892 let deps = result.unwrap();
893
894 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 crate::core::http_client::init_proxy(ProxySettings::default());
906 }
907}