From 0fbb2c2c9354b89ea0763a886fbcef5f29a6191d Mon Sep 17 00:00:00 2001 From: Jonas Date: Thu, 14 Aug 2025 18:19:32 +0200 Subject: [PATCH] feat: replace embedded yt-dlp/ffmpeg with on-demand first-run download (slimmer binary) --- .vscode/tasks.json | 2 +- CONTRIBUTING.md | 2 +- Cargo.lock | 2 +- Cargo.toml | 8 +- README.md | 29 +++--- SECURITY.md | 2 +- build.rs | 242 +++++---------------------------------------- src/main.rs | 138 +++++--------------------- src/utils/mod.rs | 9 +- src/utils/stdu.rs | 7 ++ src/utils/tools.rs | 191 +++++++++++++++++++++++++++++++++++ 11 files changed, 264 insertions(+), 368 deletions(-) create mode 100644 src/utils/stdu.rs create mode 100644 src/utils/tools.rs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 15256b0..d3a398f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,7 +13,7 @@ { "label": "debug: cargo build", "type": "shell", - "command": "cargo build --debug", + "command": "cargo build", "problemMatcher": [ "$rustc" ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab39533..0bcd2b9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ Thanks for your interest in contributing! ## Development setup - Install Rust (stable) and `cargo`. -- Optional: `yt-dlp` in PATH if you want to test resolver behavior locally. +- Optional: `yt-dlp` in PATH if you want; otherwise the node will auto-download tools to `~/.resonix/bin` on first run. - Build: `cargo build` | Format: `cargo fmt` ## Coding guidelines diff --git a/Cargo.lock b/Cargo.lock index 43013b0..17051dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1749,7 +1749,7 @@ dependencies = [ [[package]] name = "resonix-node" -version = "0.2.0" +version = "0.2.5" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 2adc154..9d8f5bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "resonix-node" -version = "0.2.0" +version = "0.2.5" edition = "2021" description = "High-performance audio node. HTTP/WebSocket server that resolves and streams audio using Symphonia and yt-dlp." license = "BSD-3-Clause" @@ -57,6 +57,9 @@ rspotify = { version = "0.15", default-features = false, features = [ dotenvy = "0.15" base64 = "0.22" uuid = { version = "1", features = ["v4"] } +zip = "0.4" +tar = "0.4" +xz2 = "0.1" [badges] docsrs = {} @@ -65,9 +68,6 @@ maintenance = { status = "actively-developed" } [build-dependencies] winres = "0.1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } -zip = "0.4" -tar = "0.4" -xz2 = "0.1" [package.metadata.bundle] name = "Resonix" diff --git a/README.md b/README.md index 0c07ed0..2886acd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Features - Allow/block URL patterns via regex - Lightweight EQ and volume filters - Minimal authentication via static password header -- Self-contained (optional): embeds `yt-dlp` and `ffmpeg` binaries inside the executable +- First-run auto-download of `yt-dlp` and `ffmpeg` into a user cache (`~/.resonix/bin`) keeping the executable slim ### Status Early preview. APIs may evolve. See License (BSD-3-Clause). @@ -109,35 +109,28 @@ Environment overrides - `RESOLVE_TIMEOUT_MS=...` → override timeout - `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET` → fallback env vars if `[spotify]` section is omitted. - You can also set custom env var names and reference them from the config, e.g.: `client_id = "MY_APP_SPOTIFY_ID"` and then set `MY_APP_SPOTIFY_ID` in your `.env` or environment. -- `RESONIX_EMBED_EXTRACT_DIR=...` → directory to which embedded binaries are written (default: OS temp dir / `resonix-embedded`) +- (legacy) `RESONIX_EMBED_EXTRACT_DIR` is ignored now; tools are stored in `~/.resonix/bin` + (runtime export `RESONIX_TOOLS_DIR` shows the resolved directory) Runtime export (informational) - On startup the resolved paths are placed into `RESONIX_YTDLP_BIN` / `RESONIX_FFMPEG_BIN` env vars for child processes spawned by the node. Normally you do not need to set these manually. -### Embedded binaries (standalone mode) +### Tool management (yt-dlp / ffmpeg) -Resonix can bundle `yt-dlp` and `ffmpeg` directly into the executable: +On startup Resonix checks for `yt-dlp` and `ffmpeg`. -1. During build, `build.rs` downloads platform-appropriate binaries into `assets/bin` (if they are missing). -2. Those files are embedded with `include_bytes!` so the final `resonix-node` can run without the tools installed system-wide. -3. At runtime, if the configured / external tool paths fail validation, the embedded bytes are extracted to a writable folder (default temporary directory or `RESONIX_EMBED_EXTRACT_DIR`) and invoked from there. - -Selection order for each tool: +Resolution order (per tool): 1. Explicit env (`YTDLP_PATH` / `FFMPEG_PATH`) -2. Config value (`resolver.ytdlp_path` / `resolver.ffmpeg_path`) -3. Embedded binary fallback (if present) - -To force use of your own system tools, either: -- Set `YTDLP_PATH` / `FFMPEG_PATH` to the desired executables, or -- Provide paths in `Resonix.toml` and keep them working; embedded versions are only used if validation (`--version` / `-version`) fails. +2. Config (`resolver.ytdlp_path` / `resolver.ffmpeg_path`) +3. Auto-managed download to `~/.resonix/bin` (created if missing) -To update embedded versions, delete the corresponding file(s) in `assets/bin` and rebuild; they will be re-downloaded. +If only one tool is missing, only that one is downloaded. Existing executables are left untouched. Delete a file to force re-download of the latest release. -To ship a smaller binary without embedding, remove the files from `assets/bin` before building (they will not be embedded if absent) and rely on external paths/env values. +macOS: `ffmpeg` is not auto-downloaded (install via Homebrew: `brew install ffmpeg`). Notes - The resolver downloads temporary audio files using `yt-dlp`. Ensure sufficient disk space and legal use in your jurisdiction. -- For sources needing remux/extraction, `ffmpeg` is required. Embedded or external versions are acceptable. +- For sources needing remux/extraction, `ffmpeg` is required. --- diff --git a/SECURITY.md b/SECURITY.md index dd36ed0..2be1624 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,4 +15,4 @@ Handling and Disclosure Operational Guidance - If you enable authentication, keep your `server.password` secret and rotate it if leaked. - Avoid exposing the node directly to the public internet; prefer a private network or authentication proxy. -- Keep dependencies and `yt-dlp` up to date if you use the resolver. +- Keep dependencies and the auto-downloaded tools (`yt-dlp`, `ffmpeg`) up to date if you use the resolver. Delete them in `~/.resonix/bin` to force fresh download. diff --git a/build.rs b/build.rs index 3fa943b..c10795d 100644 --- a/build.rs +++ b/build.rs @@ -1,237 +1,39 @@ -use std::{ - env, fs, io, - path::{Path, PathBuf}, -}; - -fn download_to(url: &str, dest: &Path) -> Result<(), String> { - println!("cargo:warning=Downloading {} -> {}", url, dest.display()); - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let resp = reqwest::blocking::get(url).map_err(|e| e.to_string())?; - if !resp.status().is_success() { - return Err(format!("Request failed: {}", resp.status())); - } - let bytes = resp.bytes().map_err(|e| e.to_string())?; - fs::write(dest, &bytes).map_err(|e| e.to_string())?; - Ok(()) -} - -fn ensure_yt_dlp(target_os: &str, out_dir: &Path) -> Result { - let (url, filename) = match target_os { - "windows" => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe", "yt-dlp.exe"), - "macos" => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos", "yt-dlp"), - _ => ("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp", "yt-dlp"), // linux & others - }; - let dest = out_dir.join(filename); - if !dest.exists() { - download_to(url, &dest)?; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(&dest) { - let mut perm = meta.permissions(); - perm.set_mode(0o755); - let _ = fs::set_permissions(&dest, perm); - } - } - Ok(dest) -} - -fn ensure_ffmpeg(target_os: &str, out_dir: &Path) -> Result { - if target_os == "macos" { - return Err("Skipping ffmpeg embed on macOS".into()); - } - - let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_else(|_| "x86_64".into()); - let (platform_tag, is_zip) = match (target_os, target_arch.as_str()) { - ("windows", "aarch64") => ("winarm64", true), - ("windows", _) => ("win64", true), - ("linux", "aarch64") => ("linuxarm64", false), - ("linux", _) => ("linux64", false), - (other, _) => return Err(format!("Unsupported OS for ffmpeg embedding: {other}")), - }; - let archive_name = if is_zip { "ffmpeg.zip" } else { "ffmpeg.tar.xz" }; - let archive_url = format!( - "https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-{platform_tag}-gpl.{}", - if is_zip { "zip" } else { "tar.xz" } - ); - let bin_subpath = if target_os == "windows" { - format!("ffmpeg-master-latest-{platform_tag}-gpl/bin/ffmpeg.exe") - } else { - format!("ffmpeg-master-latest-{platform_tag}-gpl/bin/ffmpeg") - }; - let ffmpeg_bin = out_dir.join(if target_os == "windows" { "ffmpeg.exe" } else { "ffmpeg" }); - if ffmpeg_bin.exists() { - // Assume prior full extraction already happened. - return Ok(ffmpeg_bin); - } - - let archive_path = out_dir.join(archive_name); - download_to(&archive_url, &archive_path)?; - if is_zip { - let file = fs::File::open(&archive_path).map_err(|e| e.to_string())?; - let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; - for i in 0..zip.len() { - let mut f = zip.by_index(i).map_err(|e| e.to_string())?; - let name = f.name().to_string(); - if let Some(bin_prefix) = bin_subpath.rsplit_once('/') { - // (dir, file) - let bin_dir_prefix = bin_prefix.0.to_string(); - if name.ends_with('/') { - continue; - } - if name.contains(&bin_dir_prefix) { - if let Some(filename) = name.split('/').last() { - let out_path = out_dir.join(filename); - let mut out_f = fs::File::create(&out_path).map_err(|e| e.to_string())?; - io::copy(&mut f, &mut out_f).map_err(|e| e.to_string())?; - } - } - } - } - } else { - // tar.xz - let file = fs::File::open(&archive_path).map_err(|e| e.to_string())?; - let decompressor = xz2::read::XzDecoder::new(file); - let mut archive = tar::Archive::new(decompressor); - for entry in archive.entries().map_err(|e| e.to_string())? { - let mut entry = entry.map_err(|e| e.to_string())?; - if let Ok(path) = entry.path() { - if let Some(path_str) = path.to_str() { - if let Some((bin_dir_prefix, _file)) = bin_subpath.rsplit_once('/') { - if path_str.contains(bin_dir_prefix) && !path_str.ends_with('/') { - if let Some(filename) = path.file_name() { - let out_path = out_dir.join(filename); - entry.unpack(&out_path).map_err(|e| e.to_string())?; - } - } - } - } - } - } - } - let _ = fs::remove_file(&archive_path); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(&ffmpeg_bin) { - let mut perm = meta.permissions(); - perm.set_mode(0o755); - let _ = fs::set_permissions(&ffmpeg_bin, perm); - } - } - Ok(ffmpeg_bin) -} +use std::env; +use std::path::Path; fn main() { - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown")); - println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS"); println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rustc-check-cfg=cfg(has_embedded_bins)"); - + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_else(|_| String::from("unknown")); if target_os == "windows" { println!("cargo:rerun-if-changed=assets/app/avatar.ico"); println!("cargo:rerun-if-changed=assets/app/icon.ico"); let mut res = winres::WindowsResource::new(); + for path in ["assets/app/icon.ico", "assets/app/avatar.ico"] { if Path::new(path).exists() { res.set_icon(path); - let _ = res.compile(); break; } } - } - - let bin_dir = Path::new("assets").join("bin"); - fs::create_dir_all(&bin_dir).expect("create bin dir"); - - if let Err(e) = ensure_yt_dlp(&target_os, &bin_dir) { - println!("cargo:warning=Failed to ensure yt-dlp: {e}"); - } - if let Err(e) = ensure_ffmpeg(&target_os, &bin_dir) { - println!("cargo:warning=Failed to ensure ffmpeg: {e}"); - } - - println!("cargo:rerun-if-changed={}", bin_dir.display()); - - println!("cargo:rustc-env=RESONIX_EMBED_OS_DIR={}", bin_dir.display()); - - let out_dir_fs = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR set")); - let gen_path = out_dir_fs.join("embedded_bins.rs"); - let mut gen = String::new(); - gen.push_str("// @generated by build.rs\n"); - let yt = match target_os.as_str() { - "windows" => bin_dir.join("yt-dlp.exe"), - _ => bin_dir.join("yt-dlp"), - }; - if yt.exists() { - let rel = yt.strip_prefix(".").unwrap_or(&yt); // best-effort - let rel_str = rel.to_string_lossy().replace('\\', "/"); - gen.push_str(&format!("pub const YT_DLP_PATH: &str = r#\"{}\"#;\n", yt.display())); - gen.push_str(&format!( - "pub const YT_DLP: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n", - rel_str - )); - } else { - gen.push_str("pub const YT_DLP_PATH: &str = \"\";\npub const YT_DLP: &[u8] = &[];\n"); - } - let ff = if target_os == "windows" { bin_dir.join("ffmpeg.exe") } else { bin_dir.join("ffmpeg") }; - if ff.exists() { - let rel = ff.strip_prefix(".").unwrap_or(&ff); - let rel_str = rel.to_string_lossy().replace('\\', "/"); - gen.push_str(&format!("pub const FFMPEG_PATH: &str = r#\"{}\"#;\n", ff.display())); - gen.push_str(&format!( - "pub const FFMPEG: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n", - rel_str - )); - } else { - gen.push_str("pub const FFMPEG_PATH: &str = \"\";\npub const FFMPEG: &[u8] = &[];\n"); - } - // Generic embedding of every file present in assets/bin for completeness (so ffprobe, dlls, etc. are shipped). - let mut embedded_list_entries = String::new(); - embedded_list_entries - .push_str("pub struct EmbeddedFile { pub name: &'static str, pub bytes: &'static [u8] }\n"); - let mut array_items = Vec::new(); - if let Ok(read_dir) = fs::read_dir(&bin_dir) { - for entry in read_dir.flatten() { - if let Ok(ft) = entry.file_type() { - if ft.is_dir() { - continue; - } - } - let path = entry.path(); - if let Some(fname) = path.file_name().and_then(|s| s.to_str()) { - let rel = path.strip_prefix(".").unwrap_or(&path); - let rel_str = rel.to_string_lossy().replace('\\', "/"); - // Sanitize identifier - let mut ident = fname - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect::(); - if !ident.chars().next().map(|c| c.is_ascii_alphabetic() || c == '_').unwrap_or(false) { - ident = format!("_{}", ident); - } - ident = ident.to_ascii_uppercase(); - embedded_list_entries.push_str(&format!("pub const EMBED_FILE_{ident}: &[u8] = include_bytes!(concat!(env!(\"CARGO_MANIFEST_DIR\"), r#\"/{}\"#));\n", rel_str)); - array_items - .push(format!("EmbeddedFile {{ name: r#\"{fname}\"#, bytes: EMBED_FILE_{ident} }}")); - } + let company = env::var("RESONIX_COMPANY").unwrap_or_else(|_| "Resonix OSS Team".into()); + let product = env::var("RESONIX_PRODUCT").unwrap_or_else(|_| "Resonix".into()); + let copyright = env::var("RESONIX_COPYRIGHT") + .unwrap_or_else(|_| "© 2025 Resonix OSS".into()); + + let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".into()); + res.set("CompanyName", &company); + res.set("FileDescription", "High-performance audio node"); + res.set("ProductName", &product); + res.set("ProductVersion", &version); + res.set("FileVersion", &version); + res.set("OriginalFilename", "resonix-node.exe"); + res.set("InternalName", "resonix-node"); + res.set("LegalCopyright", ©right); + + if let Err(e) = res.compile() { + eprintln!("Failed to embed Windows resources: {e}"); } } - embedded_list_entries.push_str("pub const EMBEDDED_FILES: &[EmbeddedFile] = &[\n"); - for item in &array_items { - embedded_list_entries.push_str(" "); - embedded_list_entries.push_str(item); - embedded_list_entries.push_str(",\n"); - } - embedded_list_entries.push_str("];"); - gen.push_str(&embedded_list_entries); - if fs::write(&gen_path, gen).is_err() { - println!("cargo:warning=Failed to write embedded_bins.rs"); - } - println!("cargo:rustc-env=RESONIX_EMBED_BINS_RS={}", gen_path.display()); - println!("cargo:rustc-cfg=has_embedded_bins"); + // If not Windows or no icons, nothing else to do. } diff --git a/src/main.rs b/src/main.rs index 513bba7..078305c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,8 @@ use crate::api::handlers::{ use crate::config::load_config; use crate::middleware::auth::auth_middleware; use crate::state::AppState; -use crate::utils::format_ram_mb; +use crate::utils::stdu::format_ram_mb; +use crate::utils::tools::{ensure_all, tools_home_dir}; #[tokio::main] async fn main() -> Result<()> { @@ -128,141 +129,48 @@ async fn main() -> Result<()> { Ok(()) } -fn embedded_bin_dir() -> std::path::PathBuf { - if let Ok(p) = std::env::var("RESONIX_EMBED_EXTRACT_DIR") { - return std::path::PathBuf::from(p); - } - std::env::temp_dir().join("resonix-embedded") -} - -fn ensure_extracted(bin_name: &str, bytes: &[u8]) -> Option { - if bytes.is_empty() { - return None; - } - let dir = embedded_bin_dir(); - let _ = std::fs::create_dir_all(&dir); - let mut path = dir.join(bin_name); - #[cfg(windows)] - { - if path.extension().is_none() { - path.set_extension("exe"); - } - } - let write = match std::fs::metadata(&path) { - Ok(m) => (m.len() as usize) != bytes.len() || m.len() == 0, - Err(_) => true, - }; - if write { - if let Err(e) = std::fs::write(&path, bytes) { - tracing::warn!(?e, path=%path.display(), "failed to write embedded binary"); - return None; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(&path) { - let mut perm = meta.permissions(); - perm.set_mode(0o755); - let _ = std::fs::set_permissions(&path, perm); - } - } - } - Some(path) -} - async fn check_startup_dependencies(cfg: &crate::config::EffectiveConfig) -> Result<()> { use tokio::process::Command; use tokio::time::{timeout, Duration}; let mut ytdlp_path = cfg.ytdlp_path.clone(); let mut ffmpeg_path = cfg.ffmpeg_path.clone(); - #[allow(unused)] - fn validate(cmd_path: &str, arg: &str) -> bool { - let mut c = std::process::Command::new(cmd_path); - c.arg(arg).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); - c.status().map(|s| s.success()).unwrap_or(false) + async fn validate(bin: &str, arg: &str) -> bool { + let mut cmd = Command::new(bin); + cmd.arg(arg).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + match timeout(Duration::from_secs(5), cmd.status()).await { + Ok(Ok(st)) => st.success(), + _ => false, + } } - #[cfg(has_embedded_bins)] - { - mod embedded_bins { - include!(env!("RESONIX_EMBED_BINS_RS")); - } - use embedded_bins::*; - if (!validate(&ytdlp_path, "--version")) && !YT_DLP.is_empty() { - if let Some(p) = ensure_extracted(if cfg!(windows) { "yt-dlp.exe" } else { "yt-dlp" }, YT_DLP) { - ytdlp_path = p.to_string_lossy().to_string(); - } + let need_ytdlp_download = !validate(&ytdlp_path, "--version").await; + let need_ffmpeg_download = !validate(&ffmpeg_path, "-version").await; + + if need_ytdlp_download || need_ffmpeg_download { + let (ytdlp_dl, ffmpeg_dl) = ensure_all(need_ytdlp_download, need_ffmpeg_download).await?; + if let Some(p) = ytdlp_dl { + ytdlp_path = p.to_string_lossy().to_string(); } - if (!validate(&ffmpeg_path, "-version")) && !FFMPEG.is_empty() { - if let Some(files) = std::option::Option::Some(EMBEDDED_FILES) { - let dir = embedded_bin_dir(); - let _ = std::fs::create_dir_all(&dir); - for ef in files { - if ef.name.eq_ignore_ascii_case("yt-dlp") || ef.name.eq_ignore_ascii_case("yt-dlp.exe") { - continue; - } - let out_path = dir.join(ef.name); - #[cfg(windows)] - { - // Keep provided extension; do not auto add .exe here. - } - let write = match std::fs::metadata(&out_path) { - Ok(m) => (m.len() as usize) != ef.bytes.len() || m.len() == 0, - Err(_) => true, - }; - if write { - if let Err(e) = std::fs::write(&out_path, ef.bytes) { - tracing::warn!(?e, path=%out_path.display(), "failed to write embedded support file"); - } else { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = std::fs::metadata(&out_path) { - let mut perm = meta.permissions(); - perm.set_mode(0o755); - let _ = std::fs::set_permissions(&out_path, perm); - } - } - } - } - } - } - if let Some(p) = ensure_extracted(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }, FFMPEG) { - ffmpeg_path = p.to_string_lossy().to_string(); - } + if let Some(p) = ffmpeg_dl { + ffmpeg_path = p.to_string_lossy().to_string(); } } - let ytdlp_ok = { - let mut cmd = Command::new(&ytdlp_path); - cmd.arg("--version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); - match timeout(Duration::from_secs(5), cmd.status()).await { - Ok(Ok(st)) => st.success(), - _ => false, - } - }; + let ytdlp_ok = validate(&ytdlp_path, "--version").await; if !ytdlp_ok { log_install_help("yt-dlp"); - anyhow::bail!("yt-dlp not found or not working (path: {})", ytdlp_path); + anyhow::bail!("yt-dlp missing; attempted path {}", ytdlp_path); } - - let ffmpeg_ok = { - let mut cmd = Command::new(&ffmpeg_path); - cmd.arg("-version").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); - match timeout(Duration::from_secs(5), cmd.status()).await { - Ok(Ok(st)) => st.success(), - _ => false, - } - }; + let ffmpeg_ok = validate(&ffmpeg_path, "-version").await; if !ffmpeg_ok { log_install_help("ffmpeg"); - anyhow::bail!("ffmpeg not found or not working (path: {})", ffmpeg_path); + anyhow::bail!("ffmpeg missing; attempted path {}", ffmpeg_path); } std::env::set_var("RESONIX_FFMPEG_BIN", &ffmpeg_path); std::env::set_var("RESONIX_YTDLP_BIN", &ytdlp_path); - + std::env::set_var("RESONIX_TOOLS_DIR", tools_home_dir()); Ok(()) } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index ce5fc49..fce30fb 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,7 +1,2 @@ -pub fn format_ram_mb(ram_mb: u64) -> String { - if ram_mb < 1024 { - format!("{} MB", ram_mb) - } else { - format!("{:.1} GB", ram_mb as f64 / 1024.0) - } -} +pub mod stdu; +pub mod tools; diff --git a/src/utils/stdu.rs b/src/utils/stdu.rs new file mode 100644 index 0000000..ce5fc49 --- /dev/null +++ b/src/utils/stdu.rs @@ -0,0 +1,7 @@ +pub fn format_ram_mb(ram_mb: u64) -> String { + if ram_mb < 1024 { + format!("{} MB", ram_mb) + } else { + format!("{:.1} GB", ram_mb as f64 / 1024.0) + } +} diff --git a/src/utils/tools.rs b/src/utils/tools.rs new file mode 100644 index 0000000..55890a9 --- /dev/null +++ b/src/utils/tools.rs @@ -0,0 +1,191 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::time::Instant; +use tracing::{debug, info, warn}; + +#[derive(Debug, Clone, Copy)] +pub enum ToolKind { + YtDlp, + Ffmpeg, +} + +impl ToolKind { + pub fn filename(self) -> &'static str { + match self { + ToolKind::YtDlp => { + if cfg!(windows) { + "yt-dlp.exe" + } else { + "yt-dlp" + } + } + ToolKind::Ffmpeg => { + if cfg!(windows) { + "ffmpeg.exe" + } else { + "ffmpeg" + } + } + } + } + pub fn url(self) -> &'static str { + match self { + ToolKind::YtDlp => { + if cfg!(target_os = "windows") { + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" + } else if cfg!(target_os = "macos") { + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos" + } else { + "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" + } + } + ToolKind::Ffmpeg => { + // Use BtbN static builds for now (GPL). Windows & Linux; macOS users should install via brew (we still attempt download for parity except Mac). + if cfg!(target_os = "windows") { + // We pick win64 gpl build; for arm64 fallback also works via winarm64 but keep simple. + "https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip" + } else if cfg!(target_os = "linux") { + "https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz" + } else { + // macOS: we do not auto download (brew preferred); return empty to skip. + "" + } + } + } + } +} + +pub fn tools_home_dir() -> PathBuf { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }) + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + home.join(".resonix").join("bin") +} + +pub async fn ensure_tool(kind: ToolKind) -> Result> { + let dir = tools_home_dir(); + tokio::fs::create_dir_all(&dir).await.ok(); + let path = dir.join(kind.filename()); + if path.exists() { + debug!(tool=?kind, installed_path=%path.display(), "Tool already present; skipping download"); + return Ok(Some(path)); + } + let url = kind.url(); + if url.is_empty() { + debug!(tool=?kind, "No download URL defined for platform; skipping"); + return Ok(None); + } + info!(tool=?kind, %url, dest=%path.display(), "Downloading tool (first run)"); + let started = Instant::now(); + + if matches!(kind, ToolKind::Ffmpeg) { + let required_bins: &[&str] = if cfg!(windows) { + &["ffmpeg.exe", "ffplay.exe", "ffprobe.exe"] + } else { + &["ffmpeg", "ffplay", "ffprobe"] + }; + let mut extracted: Vec = Vec::new(); + if url.ends_with(".zip") { + let resp = reqwest::get(url).await.context("download ffmpeg zip")?; + let status = resp.status(); + if !status.is_success() { anyhow::bail!("ffmpeg zip request failed {status}"); } + let bytes = resp.bytes().await?; + info!(tool=?kind, size_bytes=bytes.len(), "Archive downloaded; extracting (zip)"); + let reader = std::io::Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(reader).context("open ffmpeg zip")?; + let total = zip.len(); + debug!(entries=total, tool=?kind, "Scanning zip entries for binary"); + for i in 0..zip.len() { + let mut file = zip.by_index(i).context("zip entry")?; + let entry_name = file.name().to_string(); + if entry_name.ends_with('/') { continue; } + if let Some(fname) = entry_name.rsplit('/').next() { + if required_bins.contains(&fname) { + let out_path = dir.join(fname); + let mut out = std::fs::File::create(&out_path).context("create ffmpeg related bin")?; + std::io::copy(&mut file, &mut out).context("write ffmpeg related bin")?; + extracted.push(fname.to_string()); + debug!(tool=?kind, matched_entry=%entry_name, dest=%out_path.display(), "Extracted binary from zip"); + } + } + } + } else if url.ends_with(".tar.xz") { + let resp = reqwest::get(url).await.context("download ffmpeg tar.xz")?; + let status = resp.status(); + if !status.is_success() { anyhow::bail!("ffmpeg tar.xz request failed {status}"); } + let bytes = resp.bytes().await?; + info!(tool=?kind, size_bytes=bytes.len(), "Archive downloaded; extracting (tar.xz)"); + let cursor = std::io::Cursor::new(bytes); + let xz = xz2::read::XzDecoder::new(cursor); + let mut archive = tar::Archive::new(xz); + for entry in archive.entries().context("tar entries")? { + let mut entry = entry.context("tar entry")?; + let mut target: Option = None; + if let Ok(p) = entry.path() { + if let Some(fname) = p.file_name().and_then(|s| s.to_str()) { + if required_bins.contains(&fname) { target = Some(fname.to_string()); } + } + } + if let Some(fname) = target { + let out_path = dir.join(&fname); + entry.unpack(&out_path).context("unpack ffmpeg related bin")?; + extracted.push(fname.clone()); + debug!(tool=?kind, matched_entry=%fname, dest=%out_path.display(), "Extracted binary from tar.xz"); + } + } + } else { + warn!(%url, "Unsupported ffmpeg archive format; skipping"); + return Ok(None); + } + if !extracted.iter().any(|e| e.starts_with("ffmpeg")) { + warn!(tool=?kind, extracted=?extracted, "Archive processed but 'ffmpeg' binary not found"); + } + } else { + let resp = reqwest::get(url).await.context("download yt-dlp")?; + let status = resp.status(); + if !status.is_success() { + anyhow::bail!("yt-dlp request failed {status}"); + } + let bytes = resp.bytes().await?; + info!(tool=?kind, size_bytes=bytes.len(), "Binary downloaded; writing to disk"); + tokio::fs::write(&path, &bytes).await.context("write yt-dlp")?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(&path) { + let mut perm = meta.permissions(); + perm.set_mode(0o755); + let _ = std::fs::set_permissions(&path, perm); + } + } + + let elapsed = started.elapsed(); + if path.exists() { + if let Ok(meta) = std::fs::metadata(&path) { + info!(tool=?kind, installed_path=%path.display(), size_bytes=meta.len(), took_ms=elapsed.as_millis(), "Tool installed successfully"); + } else { + info!(tool=?kind, installed_path=%path.display(), took_ms=elapsed.as_millis(), "Tool installed (metadata unavailable)"); + } + Ok(Some(path)) + } else { + warn!(tool=?kind, took_ms=elapsed.as_millis(), "Download/extraction finished but file missing"); + Ok(None) + } +} + +pub async fn ensure_all( + manage_ytdlp: bool, + manage_ffmpeg: bool, +) -> Result<(Option, Option)> { + let mut ytdlp = None; + let mut ffmpeg = None; + if manage_ytdlp { + ytdlp = ensure_tool(ToolKind::YtDlp).await?; + } + if manage_ffmpeg { + ffmpeg = ensure_tool(ToolKind::Ffmpeg).await?; + } + Ok((ytdlp, ffmpeg)) +}