commit 631b3ac8e165b3188315672bac371fb3fe7d2b3e
parent 4f468627b15ee9b52521068e86bd6b2bc94e655c
Author: egor-achkasov <eaachkasov@gmail.com>
Date: Thu, 12 Mar 2026 22:03:52 +0000
Add progress percentage
Diffstat:
3 files changed, 77 insertions(+), 21 deletions(-)
diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs
@@ -47,15 +47,31 @@ fn parse_args() -> Result<Config> {
})
}
+struct SlotInfo {
+ name: String,
+ downloaded: u64,
+ total: Option<u64>,
+}
+
+impl SlotInfo {
+ fn format_line(&self) -> String {
+ match self.total {
+ Some(total) if total > 0 => {
+ let pct = (self.downloaded * 100 / total) as u8;
+ format!("{pct:3}% {}", self.name)
+ }
+ _ => format!(" {}", self.name),
+ }
+ }
+}
+
#[derive(Default)]
struct ProgressState {
total: usize,
completed: usize,
failed: usize,
- /// Each slot holds the display name of an active download, or None if free.
- slots: Vec<Option<String>>,
+ slots: Vec<Option<SlotInfo>>,
id_to_slot: HashMap<usize, usize>,
- /// How many lines the progress block currently occupies on screen.
lines_printed: usize,
}
@@ -65,10 +81,19 @@ impl ProgressState {
self.slots.push(None);
self.slots.len() - 1
});
- self.slots[slot] = Some(name);
+ self.slots[slot] = Some(SlotInfo { name, downloaded: 0, total: None });
self.id_to_slot.insert(id, slot);
}
+ fn update_progress(&mut self, id: usize, downloaded: u64, total: Option<u64>) {
+ if let Some(&slot) = self.id_to_slot.get(&id) {
+ if let Some(Some(info)) = self.slots.get_mut(slot) {
+ info.downloaded = downloaded;
+ info.total = total;
+ }
+ }
+ }
+
fn free_slot(&mut self, id: usize) {
if let Some(&slot) = self.id_to_slot.get(&id) {
self.slots[slot] = None;
@@ -95,7 +120,7 @@ impl ProgressState {
for slot in &self.slots {
let _ = execute!(stdout, Clear(ClearType::CurrentLine));
match slot {
- Some(name) => println!(" {name}"),
+ Some(info) => println!(" {}", info.format_line()),
None => println!(),
}
}
@@ -117,6 +142,10 @@ fn handle_event(event: Event, state: &mut ProgressState) {
state.alloc_slot(id, name);
state.render();
}
+ Event::DlProgress { id, downloaded, total } => {
+ state.update_progress(id, downloaded, total);
+ state.render();
+ }
Event::DlCompleted { id } => {
state.completed += 1;
state.free_slot(id);
@@ -125,7 +154,7 @@ fn handle_event(event: Event, state: &mut ProgressState) {
Event::DlFailed { id, error } => {
state.failed += 1;
state.free_slot(id);
- // Print error above the progress block by moving up, printing, then re-rendering.
+ // Print error above the progress block
if state.lines_printed > 0 {
let _ = execute!(
std::io::stdout(),
diff --git a/src/lib/event.rs b/src/lib/event.rs
@@ -4,6 +4,7 @@ pub enum Event {
TotalDownloads(usize),
DlStarted { id: usize, name: String },
+ DlProgress { id: usize, downloaded: u64, total: Option<u64> },
DlCompleted { id: usize },
DlFailed { id: usize, error: anyhow::Error },
}
diff --git a/src/lib/lib.rs b/src/lib/lib.rs
@@ -134,19 +134,6 @@ async fn download(
tx: Sender<Event>,
flac: bool,
) -> Result<()> {
- async fn dl(client: reqwest::Client, url: &Url, dest_dir: std::path::PathBuf) -> Result<()> {
- let filename = url
- .path_segments()
- .and_then(|s| s.last())
- .ok_or(anyhow::anyhow!("Failed to get filename from url"))?;
- // khinsider double-encodes URLs (%20 → %2520), so decode twice
- let filename = percent_decode(&percent_decode(filename));
- let bytes = client.get(url.clone()).send().await?.bytes().await?;
- tokio::fs::write(dest_dir.join(&filename), &bytes).await?;
-
- Ok(())
- }
-
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let name = url
.path_segments()
@@ -168,8 +155,47 @@ async fn download(
url.clone()
};
- match dl(client, &download_url, dest_dir).await {
- Err(e) => tx.send(Event::DlFailed { id, error: e })?,
+ let mut response = match client.get(download_url.clone()).send().await {
+ Ok(r) => r,
+ Err(e) => {
+ tx.send(Event::DlFailed { id, error: e.into() })?;
+ return Ok(());
+ }
+ };
+
+ let total = response.content_length();
+ let mut downloaded: u64 = 0;
+ let mut file_bytes = Vec::new();
+
+ loop {
+ match response.chunk().await {
+ Ok(Some(chunk)) => {
+ downloaded += chunk.len() as u64;
+ file_bytes.extend_from_slice(&chunk);
+ let _ = tx.send(Event::DlProgress { id, downloaded, total });
+ }
+ Ok(None) => break,
+ Err(e) => {
+ tx.send(Event::DlFailed { id, error: e.into() })?;
+ return Ok(());
+ }
+ }
+ }
+
+ // khinsider double-encodes URLs (%20 → %2520), so decode twice
+ let filename = match download_url.path_segments().and_then(|s| s.last()) {
+ Some(s) => percent_decode(&percent_decode(s)),
+ None => {
+ tx.send(Event::DlFailed {
+ id,
+ error: anyhow::anyhow!("Failed to get filename from url"),
+ })?;
+ return Ok(());
+ }
+ };
+
+ match tokio::fs::write(dest_dir.join(&filename), &file_bytes).await {
+ Err(e) => tx.send(Event::DlFailed { id, error: e.into() })?,
Ok(()) => tx.send(Event::DlCompleted { id })?,
};