downloads-khinsider-com-dl

Download all songs at once from downloads.khinsider.com
git clone https://git.ea.contact/downloads-khinsider-com-dl
Log | Files | Refs | README

commit 4f468627b15ee9b52521068e86bd6b2bc94e655c
parent b43351e5afe73c57a917be308645939439eb14b3
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Thu, 12 Mar 2026 22:00:13 +0000

Improve cli progress

Diffstat:
MCargo.toml | 1+
Msrc/bin/cli/main.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/lib/event.rs | 10+++++-----
Msrc/lib/lib.rs | 23+++++++++++++++++++----
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(())