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 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 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
516pub async fn ensure_js_runtime(
524 reporter: Option<&dyn crate::core::traits::DownloadReporter>,
525) -> Option<PathBuf> {
526 for tool in &["deno", "node", "bun"] {
528 if let Some(path) = find_tool(tool).await {
529 return Some(path);
530 }
531 }
532
533 #[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 #[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}