From c8ca15fc545fd2e72ad480ffa44c26356cbc7319 Mon Sep 17 00:00:00 2001 From: epiphany Date: Wed, 30 Jul 2025 15:54:35 +0200 Subject: [PATCH] fu: handle downloaded bytes, download speed, eta --- Cargo.lock | 1 + bin/fud/fu/Cargo.toml | 1 + bin/fud/fu/src/main.rs | 299 ++++++++++++++++++++++++++++------------- bin/fud/fu/src/util.rs | 85 ++++++++++-- 4 files changed, 280 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6f122435..53612f2eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3048,6 +3048,7 @@ version = "0.5.0" dependencies = [ "clap 4.5.38", "darkfi", + "fud", "log", "simplelog", "smol", diff --git a/bin/fud/fu/Cargo.toml b/bin/fud/fu/Cargo.toml index 52fa7a92a..d6ba566df 100644 --- a/bin/fud/fu/Cargo.toml +++ b/bin/fud/fu/Cargo.toml @@ -10,6 +10,7 @@ repository = "https://codeberg.org/darkrenaissance/darkfi" [dependencies] darkfi = {path = "../../../", features = ["util", "rpc"]} +fud = {path = "../fud/"} # Async smol = "2.0.2" diff --git a/bin/fud/fu/src/main.rs b/bin/fud/fu/src/main.rs index 39d7ffbae..2bfa8e725 100644 --- a/bin/fud/fu/src/main.rs +++ b/bin/fud/fu/src/main.rs @@ -40,8 +40,15 @@ use darkfi::{ Error, Result, }; +use fud::{ + resource::{Resource, ResourceStatus}, + util::hash_to_string, +}; + mod util; -use crate::util::{status_to_colorspec, type_to_colorspec}; +use crate::util::{ + format_bytes, format_duration, format_progress_bytes, status_to_colorspec, type_to_colorspec, +}; #[derive(Parser)] #[clap(name = "fu", about = cli_desc!(), version)] @@ -111,15 +118,15 @@ struct Fu { impl Fu { async fn get( &self, - file_hash: String, - file_path: Option, + hash: String, + path: Option, files: Option>, ex: ExecutorPtr, ) -> Result<()> { let publisher = Publisher::new(); let subscription = Arc::new(publisher.clone().subscribe().await); let subscriber_task = StoppableTask::new(); - let file_hash_ = file_hash.clone(); + let hash_ = hash.clone(); let publisher_ = publisher.clone(); let rpc_client_ = self.rpc_client.clone(); subscriber_task.clone().start( @@ -150,36 +157,71 @@ impl Fu { let mut started = false; let mut tstdout = StandardStream::stdout(ColorChoice::Auto); - let mut print_progress_bar = |info: &HashMap| { + let mut print_progress = |info: &HashMap| { started = true; - let resource = - info.get("resource").unwrap().get::>().unwrap(); - let chunks_downloaded = - *resource.get("chunks_downloaded").unwrap().get::().unwrap() as usize; - let chunks_total = - *resource.get("chunks_target").unwrap().get::().unwrap() as usize; - let mut status = resource.get("status").unwrap().get::().unwrap().clone(); - let percent = match chunks_total { - 0 => 0f64, - _ => chunks_downloaded as f64 / chunks_total as f64, + let rs: Resource = info.get("resource").unwrap().clone().into(); + + print!("\x1B[2K\r"); // Clear current line + + // Progress bar + let percent = if rs.target_bytes_downloaded > rs.target_bytes_size { + 1.0 + } else if rs.target_bytes_size > 0 { + rs.target_bytes_downloaded as f64 / rs.target_bytes_size as f64 + } else { + 0.0 }; let completed = (percent * progress_bar_width as f64) as usize; - let remaining = progress_bar_width - completed; + let remaining = match progress_bar_width > completed { + true => progress_bar_width - completed, + false => 0, + }; let bar = "=".repeat(completed) + &" ".repeat(remaining); - print!( - "\x1B[2K\r[{bar}] {:.1}% | {chunks_downloaded}/{chunks_total} chunks | ", - percent * 100.0 - ); - if remaining == 0 { - status = "seeding".to_string(); + print!("[{bar}] {:.1}% | ", percent * 100.0); + + // Downloaded / Total (in bytes) + if rs.target_bytes_size > 0 { + if rs.target_bytes_downloaded == rs.target_bytes_size { + print!("{} | ", format_bytes(rs.target_bytes_size)); + } else { + print!( + "{} | ", + format_progress_bytes(rs.target_bytes_downloaded, rs.target_bytes_size) + ); + } } + + // Download speed (in bytes/sec) + if !rs.speeds.is_empty() && rs.target_chunks_downloaded < rs.target_chunks_count { + print!("{}/s | ", format_bytes(*rs.speeds.last().unwrap() as u64)); + } + + // Downloaded / Total (in chunks) + if rs.target_chunks_count > 0 { + let s = if rs.target_chunks_count > 1 { "s" } else { "" }; + if rs.target_chunks_downloaded == rs.target_chunks_count { + print!("{} chunk{s} | ", rs.target_chunks_count); + } else { + print!( + "{}/{} chunk{s} | ", + rs.target_chunks_downloaded, rs.target_chunks_count + ); + } + } + + // ETA + if !rs.speeds.is_empty() && rs.target_chunks_downloaded < rs.target_chunks_count { + print!("ETA: {} | ", format_duration(rs.get_eta())); + } + + // Status + let is_done = rs.target_chunks_downloaded == rs.target_chunks_count && + rs.status.as_str() == "incomplete"; + let status = if is_done { ResourceStatus::Seeding } else { rs.status }; tstdout.set_color(&status_to_colorspec(&status)).unwrap(); print!( "{}", - match status.as_str() { - "seeding" => "done", - s => s, - } + if let ResourceStatus::Seeding = status { "done" } else { status.as_str() } ); tstdout.reset().unwrap(); stdout().flush().unwrap(); @@ -188,8 +230,8 @@ impl Fu { let req = JsonRequest::new( "get", JsonValue::Array(vec![ - JsonValue::String(file_hash_.clone()), - JsonValue::String(file_path.unwrap_or_default()), + JsonValue::String(hash_.clone()), + JsonValue::String(path.unwrap_or_default()), match files { Some(files) => { JsonValue::Array(files.into_iter().map(JsonValue::String).collect()) @@ -209,7 +251,7 @@ impl Fu { let info = params.get("info").unwrap().get::>().unwrap(); let hash = info.get("hash").unwrap().get::().unwrap(); - if *hash != file_hash_ { + if *hash != hash_ { continue; } match params.get("event").unwrap().get::().unwrap().as_str() { @@ -217,22 +259,22 @@ impl Fu { "metadata_download_completed" | "chunk_download_completed" | "resource_updated" => { - print_progress_bar(info); + print_progress(info); } "download_completed" => { - let resource = info + let resource_json = info .get("resource") .unwrap() .get::>() .unwrap(); - let file_path = resource.get("path").unwrap().get::().unwrap(); - print_progress_bar(info); - println!("\nDownload completed:\n{file_path}"); + let path = resource_json.get("path").unwrap().get::().unwrap(); + print_progress(info); + println!("\nDownload completed:\n{path}"); return Ok(()); } "metadata_not_found" => { println!(); - return Err(Error::Custom(format!("Could not find {file_hash}"))); + return Err(Error::Custom(format!("Could not find {hash}"))); } "chunk_not_found" => { // A seeder does not have a chunk we are looking for, @@ -285,37 +327,52 @@ impl Fu { let req = JsonRequest::new("list_resources", JsonValue::Array(vec![])); let rep = self.rpc_client.request(req).await?; - let resources: Vec = rep.clone().try_into().unwrap(); + let resources_json: Vec = rep.clone().try_into().unwrap(); + let resources: Vec = resources_json.into_iter().map(|v| v.into()).collect(); let mut tstdout = StandardStream::stdout(ColorChoice::Auto); - for rs in resources.iter() { - let resource = rs.get::>().unwrap(); - let path = resource.get("path").unwrap().get::().unwrap(); - let hash = resource.get("hash").unwrap().get::().unwrap().as_str(); - let rtype = resource.get("type").unwrap().get::().unwrap(); - let chunks_downloaded = - *resource.get("chunks_downloaded").unwrap().get::().unwrap() as usize; - let chunks_total = - *resource.get("chunks_total").unwrap().get::().unwrap() as usize; - let status = resource.get("status").unwrap().get::().unwrap(); + for resource in resources.iter() { tstdout.set_color(ColorSpec::new().set_bold(true)).unwrap(); - println!("{path}"); + println!("{}", resource.path.to_string_lossy()); tstdout.reset().unwrap(); - println!(" ID: {hash}"); + println!(" ID: {}", hash_to_string(&resource.hash)); print!(" Type: "); - tstdout.set_color(&type_to_colorspec(rtype)).unwrap(); - println!("{rtype}"); + tstdout.set_color(&type_to_colorspec(&resource.rtype)).unwrap(); + println!("{}", resource.rtype.as_str()); tstdout.reset().unwrap(); print!(" Status: "); - tstdout.set_color(&status_to_colorspec(status)).unwrap(); - println!("{status}"); + tstdout.set_color(&status_to_colorspec(&resource.status)).unwrap(); + println!("{}", resource.status.as_str()); tstdout.reset().unwrap(); println!( - " Chunks: {chunks_downloaded}/{}", - match chunks_total { + " Chunks: {}/{} ({}/{})", + resource.total_chunks_downloaded, + match resource.total_chunks_count { 0 => "?".to_string(), - _ => chunks_total.to_string(), + _ => resource.total_chunks_count.to_string(), + }, + resource.target_chunks_downloaded, + match resource.target_chunks_count { + 0 => "?".to_string(), + _ => resource.target_chunks_count.to_string(), + } + ); + println!( + " Bytes: {} ({})", + match resource.total_bytes_size { + 0 => "?".to_string(), + _ => format_progress_bytes( + resource.total_bytes_downloaded, + resource.total_bytes_size + ), + }, + match resource.target_bytes_size { + 0 => "?".to_string(), + _ => format_progress_bytes( + resource.target_bytes_downloaded, + resource.target_bytes_size + ), } ); } @@ -359,13 +416,13 @@ impl Fu { let req = JsonRequest::new("list_seeders", JsonValue::Array(vec![])); let rep = self.rpc_client.request(req).await?; - let files: HashMap = rep["seeders"].clone().try_into().unwrap(); + let resources: HashMap = rep["seeders"].clone().try_into().unwrap(); - if files.is_empty() { + if resources.is_empty() { println!("No known seeders"); } else { - for (file_hash, node_ids) in files { - println!("{file_hash}"); + for (hash, node_ids) in resources { + println!("{hash}"); let node_ids: Vec = node_ids.try_into().unwrap(); for node_id in node_ids { let node_id: String = node_id.try_into().unwrap(); @@ -382,7 +439,7 @@ impl Fu { let rep = self.rpc_client.request(req).await?; let resources_json: Vec = rep.clone().try_into().unwrap(); - let resources: Arc>>> = Arc::new(RwLock::new(vec![])); + let resources: Arc>> = Arc::new(RwLock::new(vec![])); let publisher = Publisher::new(); let subscription = Arc::new(publisher.clone().subscribe().await); @@ -415,13 +472,9 @@ impl Fu { let mut tstdout = StandardStream::stdout(ColorChoice::Auto); - let mut update_resource = async |resource: &HashMap| { - let hash = resource.get("hash").unwrap().get::().unwrap(); + let mut update_resource = async |resource: &Resource| { let mut resources_write = resources.write().await; - let i = match resources_write - .iter() - .position(|r| r.get("hash").unwrap().get::().unwrap() == hash) - { + let i = match resources_write.iter().position(|r| r.hash == resource.hash) { Some(i) => { resources_write.remove(i); resources_write.insert(i, resource.clone()); @@ -436,32 +489,92 @@ impl Fu { // Move the cursor to the i-th line and clear it print!("\x1b[{};1H\x1B[2K", i + 2); - let hash = resource.get("hash").unwrap().get::().unwrap(); - print!("\r{hash:>44} "); + // Hash + print!("\r{:>44} ", hash_to_string(&resource.hash)); - let rtype = resource.get("type").unwrap().get::().unwrap(); - tstdout.set_color(&type_to_colorspec(rtype)).unwrap(); - print!("{rtype:>9} "); + // Type + tstdout.set_color(&type_to_colorspec(&resource.rtype)).unwrap(); + print!( + "{:>4} ", + match resource.rtype.as_str() { + "unknown" => "?", + "directory" => "dir", + _ => resource.rtype.as_str(), + } + ); tstdout.reset().unwrap(); - let status = resource.get("status").unwrap().get::().unwrap(); - tstdout.set_color(&status_to_colorspec(status)).unwrap(); - print!("{status:>11} "); + // Status + tstdout.set_color(&status_to_colorspec(&resource.status)).unwrap(); + print!("{:>11} ", resource.status.as_str()); tstdout.reset().unwrap(); - let chunks_downloaded = - *resource.get("chunks_downloaded").unwrap().get::().unwrap() as usize; - let chunks_total = - *resource.get("chunks_total").unwrap().get::().unwrap() as usize; - match chunks_total { + // Downloaded / Total (in bytes) + match resource.total_bytes_size { 0 => { - print!("{:>5.1} {:>9}", 0.0, format!("{chunks_downloaded}/?")); + print!("{:>5.1} {:>16} ", 0.0, "?"); } _ => { - let percent = chunks_downloaded as f64 / chunks_total as f64 * 100.0; - print!("{:>5.1} {:>9}", percent, format!("{chunks_downloaded}/{chunks_total}")); + let percent = resource.total_bytes_downloaded as f64 / + resource.total_bytes_size as f64 * + 100.0; + if resource.total_bytes_downloaded == resource.total_bytes_size { + print!("{:>5.1} {:>16} ", percent, format_bytes(resource.total_bytes_size)); + } else { + print!( + "{:>5.1} {:>16} ", + percent, + format_progress_bytes( + resource.total_bytes_downloaded, + resource.total_bytes_size + ) + ); + } } }; + + // Downloaded / Total (in chunks) + match resource.total_chunks_count { + 0 => { + print!("{:>9} ", format!("{}/?", resource.total_chunks_downloaded)); + } + _ => { + if resource.total_chunks_downloaded == resource.total_chunks_count { + print!("{:>9} ", resource.total_chunks_count.to_string()); + } else { + print!( + "{:>9} ", + format!( + "{}/{}", + resource.total_chunks_downloaded, resource.total_chunks_count + ) + ); + } + } + }; + + // Download speed (in bytes/sec) + let speed_available = resource.total_bytes_downloaded < resource.total_bytes_size && + resource.status.as_str() == "downloading" && + !resource.speeds.is_empty(); + print!( + "{:>12} ", + match speed_available { + false => "-".to_string(), + true => format!("{}/s", format_bytes(*resource.speeds.last().unwrap() as u64)), + } + ); + + // ETA + let eta = resource.get_eta(); + print!( + "{:>6}", + match eta { + 0 => "-".to_string(), + _ => format_duration(eta), + } + ); + println!(); // Move the cursor to end @@ -475,8 +588,8 @@ impl Fu { // Print column headers println!( - "\x1b[4m{:>44} {:>9} {:>11} {:>5} {:>9}\x1b[0m", - "Hash", "Type", "Status", "%", "Chunks" + "\x1b[4m{:>44} {:>4} {:>11} {:>5} {:>16} {:>9} {:>12} {:>6}\x1b[0m", + "Hash", "Type", "Status", "%", "Bytes", "Chunks", "Speed", "ETA" ); }; @@ -485,8 +598,8 @@ impl Fu { println!("No known resources"); } else { for resource in resources_json.iter() { - let resource = resource.get::>().unwrap(); - update_resource(resource).await; + let rs: Resource = resource.clone().into(); + update_resource(&rs).await; } } @@ -504,20 +617,16 @@ impl Fu { "missing_chunks" | "metadata_not_found" | "resource_updated" => { - let resource = info - .get("resource") - .unwrap() - .get::>() - .unwrap(); - update_resource(resource).await; + let resource: Resource = info.get("resource").unwrap().clone().into(); + update_resource(&resource).await; } "resource_removed" => { { let hash = info.get("hash").unwrap().get::().unwrap(); let mut resources_write = resources.write().await; - let i = resources_write.iter().position(|r| { - r.get("hash").unwrap().get::().unwrap() == hash - }); + let i = resources_write + .iter() + .position(|r| hash_to_string(&r.hash) == *hash); if let Some(i) = i { resources_write.remove(i); } diff --git a/bin/fud/fu/src/util.rs b/bin/fud/fu/src/util.rs index 85578e273..a34c392ed 100644 --- a/bin/fud/fu/src/util.rs +++ b/bin/fud/fu/src/util.rs @@ -18,27 +18,90 @@ use termcolor::{Color, ColorSpec}; -pub fn status_to_colorspec(status: &str) -> ColorSpec { +use fud::resource::{ResourceStatus, ResourceType}; + +const UNITS: [&str; 7] = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]; + +pub fn status_to_colorspec(status: &ResourceStatus) -> ColorSpec { ColorSpec::new() .set_fg(match status { - "downloading" => Some(Color::Blue), - "seeding" => Some(Color::Green), - "discovering" => Some(Color::Magenta), - "incomplete" => Some(Color::Red), - "verifying" => Some(Color::Yellow), - _ => None, + ResourceStatus::Downloading => Some(Color::Blue), + ResourceStatus::Seeding => Some(Color::Green), + ResourceStatus::Discovering => Some(Color::Magenta), + ResourceStatus::Incomplete => Some(Color::Red), + ResourceStatus::Verifying => Some(Color::Yellow), }) .set_bold(true) .clone() } -pub fn type_to_colorspec(rtype: &str) -> ColorSpec { +pub fn type_to_colorspec(rtype: &ResourceType) -> ColorSpec { ColorSpec::new() .set_fg(match rtype { - "file" => Some(Color::Blue), - "directory" => Some(Color::Magenta), - _ => None, + ResourceType::File => Some(Color::Blue), + ResourceType::Directory => Some(Color::Magenta), + ResourceType::Unknown => None, }) .set_bold(true) .clone() } + +pub fn format_bytes(bytes: u64) -> String { + let mut size = bytes as f64; + let mut unit_index = 0; + + while size >= 1024.0 && unit_index < UNITS.len() - 1 { + size /= 1024.0; + unit_index += 1; + } + + format!("{size:.1} {}", UNITS[unit_index]) +} + +pub fn format_progress_bytes(current: u64, total: u64) -> String { + let mut total = total as f64; + let mut unit_index = 0; + + while total >= 1024.0 && unit_index < UNITS.len() - 1 { + total /= 1024.0; + unit_index += 1; + } + + let current = (current as f64) / 1024_f64.powi(unit_index as i32); + + format!("{current:.1}/{total:.1} {}", UNITS[unit_index]) +} + +/// Returns a formated string from the duration. +/// - 1 -> 1s +/// - 60 -> 1m +/// - 90 -> 1m30s +pub fn format_duration(seconds: u64) -> String { + if seconds == 0 { + return "0s".to_string(); + } + + let units = [ + (86400, "d"), // days + (3600, "h"), // hours + (60, "m"), // minutes + (1, "s"), // seconds + ]; + + for (i, (unit_seconds, unit_symbol)) in units.iter().enumerate() { + if seconds >= *unit_seconds { + let first = seconds / unit_seconds; + let remaining = seconds % unit_seconds; + + if remaining > 0 && i < units.len() - 1 { + let (next_unit_seconds, next_unit_symbol) = units[i + 1]; + let second = remaining / next_unit_seconds; + return format!("{first}{unit_symbol}{second}{next_unit_symbol}"); + } + + return format!("{first}{unit_symbol}"); + } + } + + "0s".to_string() +}