commit 4f468627b15ee9b52521068e86bd6b2bc94e655c
parent b43351e5afe73c57a917be308645939439eb14b3
Author: egor-achkasov <eaachkasov@gmail.com>
Date: Thu, 12 Mar 2026 22:00:13 +0000
Improve cli progress
Diffstat:
4 files changed, 113 insertions(+), 18 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -17,6 +17,7 @@ path = "src/bin/cli/main.rs"
[dependencies]
anyhow = "1.0.102"
clap = { version = "4.6.0", features = ["derive"] }
+crossterm = "0.28"
reqwest = { version = "0.13.2" }
scraper = "0.25.0"
tokio = { version = "1.50.0", features = ["rt", "rt-multi-thread", "fs", "macros"] }
diff --git a/src/bin/cli/main.rs b/src/bin/cli/main.rs
@@ -3,6 +3,8 @@ use lib::{config::Config, event::Event};
use anyhow::Result;
use clap::Parser;
+use crossterm::{cursor, execute, terminal::{Clear, ClearType}};
+use std::collections::HashMap;
use std::io::Write;
#[derive(Parser)]
@@ -25,8 +27,9 @@ async fn main() -> Result<()> {
let config = parse_args()?;
let (tx, rx) = std::sync::mpsc::channel::<Event>();
let handle = tokio::task::spawn_blocking(move || {
+ let mut state = ProgressState::default();
for event in rx {
- render_event(&event);
+ handle_event(event, &mut state);
}
});
@@ -44,18 +47,94 @@ fn parse_args() -> Result<Config> {
})
}
-fn render_event(event: &Event) {
+#[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>>,
+ id_to_slot: HashMap<usize, usize>,
+ /// How many lines the progress block currently occupies on screen.
+ lines_printed: usize,
+}
+
+impl ProgressState {
+ fn alloc_slot(&mut self, id: usize, name: String) {
+ let slot = self.slots.iter().position(|s| s.is_none()).unwrap_or_else(|| {
+ self.slots.push(None);
+ self.slots.len() - 1
+ });
+ self.slots[slot] = Some(name);
+ self.id_to_slot.insert(id, slot);
+ }
+
+ fn free_slot(&mut self, id: usize) {
+ if let Some(&slot) = self.id_to_slot.get(&id) {
+ self.slots[slot] = None;
+ self.id_to_slot.remove(&id);
+ }
+ }
+
+ fn render(&mut self) {
+ let mut stdout = std::io::stdout();
+
+ if self.lines_printed > 0 {
+ let _ = execute!(stdout, cursor::MoveUp(self.lines_printed as u16));
+ }
+
+ // Summary line
+ let _ = execute!(stdout, Clear(ClearType::CurrentLine));
+ if self.failed > 0 {
+ println!("[{}/{}] complete, {} failed", self.completed, self.total, self.failed);
+ } else {
+ println!("[{}/{}] complete", self.completed, self.total);
+ }
+
+ // One line per slot
+ for slot in &self.slots {
+ let _ = execute!(stdout, Clear(ClearType::CurrentLine));
+ match slot {
+ Some(name) => println!(" {name}"),
+ None => println!(),
+ }
+ }
+
+ let _ = stdout.flush();
+ self.lines_printed = 1 + self.slots.len();
+ }
+}
+
+fn handle_event(event: Event, state: &mut ProgressState) {
match event {
Event::GetPageStarted => println!("Fetching page..."),
Event::GetPageCompleted => println!("Page fetched."),
- Event::DlStarted { url } => {
- print!("\rDownloading: {url}");
- let _ = std::io::stdout().flush();
+ Event::TotalDownloads(n) => {
+ state.total = n;
+ state.render();
+ }
+ Event::DlStarted { id, name } => {
+ state.alloc_slot(id, name);
+ state.render();
+ }
+ Event::DlCompleted { id } => {
+ state.completed += 1;
+ state.free_slot(id);
+ state.render();
}
- Event::DlCompleted { url } => {
- print!("\rDownloaded: {url}\n");
- let _ = std::io::stdout().flush();
+ Event::DlFailed { id, error } => {
+ state.failed += 1;
+ state.free_slot(id);
+ // Print error above the progress block by moving up, printing, then re-rendering.
+ if state.lines_printed > 0 {
+ let _ = execute!(
+ std::io::stdout(),
+ cursor::MoveUp(state.lines_printed as u16)
+ );
+ state.lines_printed = 0;
+ }
+ println!("Failed: {error}");
+ state.render();
}
- Event::DlFailed { error } => eprintln!("Failed: {error}"),
}
}
diff --git a/src/lib/event.rs b/src/lib/event.rs
@@ -2,7 +2,8 @@ pub enum Event {
GetPageStarted,
GetPageCompleted,
- DlStarted { url: String },
- DlCompleted { url: String },
- DlFailed { error: anyhow::Error },
-}
-\ No newline at end of file
+ TotalDownloads(usize),
+ DlStarted { id: usize, name: String },
+ DlCompleted { id: usize },
+ DlFailed { id: usize, error: anyhow::Error },
+}
diff --git a/src/lib/lib.rs b/src/lib/lib.rs
@@ -7,8 +7,11 @@ use event::Event;
use anyhow::{Context, Result};
use reqwest::Url;
+use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::Sender;
+static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
+
pub async fn run(config: Config, tx: Sender<Event>) -> Result<()> {
let client = reqwest::Client::new();
@@ -20,6 +23,9 @@ pub async fn run(config: Config, tx: Sender<Event>) -> Result<()> {
let dest_dir = std::path::Path::new(&name).to_path_buf();
std::fs::create_dir_all(&dest_dir)?;
+ let image_count = if config.images { image_urls.len() } else { 0 };
+ tx.send(Event::TotalDownloads(track_urls.len() + image_count))?;
+
let mut joinset = tokio::task::JoinSet::new();
for url in track_urls {
@@ -141,21 +147,30 @@ async fn download(
Ok(())
}
- tx.send(Event::DlStarted { url: url.to_string() })?;
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
+ let name = url
+ .path_segments()
+ .and_then(|s| s.last())
+ .map(|s| percent_decode(&percent_decode(s)))
+ .unwrap_or_else(|| url.to_string());
+
+ tx.send(Event::DlStarted { id, name })?;
+
let download_url = if flac {
match resolve_flac_url(&client, &url).await {
Ok(u) => u,
Err(e) => {
- tx.send(Event::DlFailed { error: e })?;
+ tx.send(Event::DlFailed { id, error: e })?;
return Ok(());
}
}
} else {
url.clone()
};
+
match dl(client, &download_url, dest_dir).await {
- Err(e) => tx.send(Event::DlFailed { error: e })?,
- Ok(()) => tx.send(Event::DlCompleted { url: url.to_string() })?
+ Err(e) => tx.send(Event::DlFailed { id, error: e })?,
+ Ok(()) => tx.send(Event::DlCompleted { id })?,
};
Ok(())