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 631b3ac8e165b3188315672bac371fb3fe7d2b3e
parent 4f468627b15ee9b52521068e86bd6b2bc94e655c
Author: egor-achkasov <eaachkasov@gmail.com>
Date:   Thu, 12 Mar 2026 22:03:52 +0000

Add progress percentage

Diffstat:
Msrc/bin/cli/main.rs | 41+++++++++++++++++++++++++++++++++++------
Msrc/lib/event.rs | 1+
Msrc/lib/lib.rs | 56+++++++++++++++++++++++++++++++++++++++++---------------
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 })?, };