diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index addd925..c289102 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -115,6 +115,15 @@ impl ClientContext { ) -> Option<&mut Download> { self.downloads.iter_mut().find(|d| d.token == token) } + pub fn get_download_by_file_mut( + &mut self, + username: &str, + filename: &str, + ) -> Option<&mut Download> { + self.downloads + .iter_mut() + .find(|d| d.username == username && d.filename == filename) + } pub fn get_download_tokens(&self) -> Vec { self.downloads.iter().map(|d| d.token).collect() } @@ -131,6 +140,23 @@ impl ClientContext { download.status = status; } } + + pub fn remove_queued_download_by_file( + &mut self, + username: &str, + filename: &str, + ) -> bool { + let Some(index) = self.downloads.iter().position(|download| { + download.username == username + && download.filename == filename + && matches!(download.status, DownloadStatus::Queued) + }) else { + return false; + }; + + self.downloads.remove(index); + true + } } #[test] fn test_client_context_downloads() { @@ -161,6 +187,104 @@ fn test_client_context_downloads() { assert!(context.get_download_by_token(1234).is_none()); } +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + let download = Download { + username: "peer".to_string(), + filename: "song.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: download_sender, + }; + + client.context.write().unwrap().add_download(download); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + let queued_download = Download { + username: "peer".to_string(), + filename: "queued.mp3".to_string(), + token: 123, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::Queued, + sender: mpsc::channel().0, + }; + let active_download = Download { + username: "peer".to_string(), + filename: "active.mp3".to_string(), + token: 456, + size: 100, + download_directory: "test".to_string(), + status: DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + sender: mpsc::channel().0, + }; + + { + let mut context = client.context.write().unwrap(); + context.add_download(queued_download); + context.add_download(active_download); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + impl ClientContext { #[must_use] pub fn new() -> Self { @@ -388,6 +512,69 @@ impl Client { self.context.read().unwrap().get_downloads().clone() } + pub fn pause_download(&self, username: &str, filename: &str) -> bool { + let mut context = self.context.write().unwrap(); + let Some(download) = + context.get_download_by_file_mut(username, filename) + else { + return false; + }; + + let paused_status = match &download.status { + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + } => DownloadStatus::Paused { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + }, + DownloadStatus::Paused { .. } => return true, + _ => return false, + }; + + download.status = paused_status.clone(); + let _ = download.sender.send(paused_status); + true + } + + pub fn resume_download(&self, username: &str, filename: &str) -> bool { + let mut context = self.context.write().unwrap(); + let Some(download) = + context.get_download_by_file_mut(username, filename) + else { + return false; + }; + + let resumed_status = match &download.status { + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => DownloadStatus::InProgress { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + speed_bytes_per_sec: 0.0, + }, + DownloadStatus::InProgress { .. } => return true, + _ => return false, + }; + + download.status = resumed_status.clone(); + let _ = download.sender.send(resumed_status); + true + } + + pub fn remove_queued_download( + &self, + username: &str, + filename: &str, + ) -> bool { + self.context + .write() + .unwrap() + .remove_queued_download_by_file(username, filename) + } + pub fn download( &self, filename: String, diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 914670e..3e3056c 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -5,6 +5,7 @@ use std::net::TcpStream; use std::net::ToSocketAddrs; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; +use std::thread; use std::time::{Duration, Instant}; use crate::client::ClientContext; @@ -268,9 +269,24 @@ impl DownloadPeer { stream .write_all(&START_DOWNLOAD) .map_err(DownloadError::StreamWriteError)?; + if let Some(ref dl) = download { + Self::send_download_status( + client_context, + dl, + DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: dl.size, + speed_bytes_per_sec: 0.0, + }, + ); + } } loop { + if let Some(ref dl) = download { + self.wait_while_paused(client_context, dl)?; + } + match stream.read(&mut read_buffer) { Ok(0) => { trace!( @@ -295,6 +311,17 @@ impl DownloadPeer { ); download = Some(new_download); processor.received = true; + if let Some(ref dl) = download { + Self::send_download_status( + client_context, + dl, + DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: dl.size, + speed_bytes_per_sec: 0.0, + }, + ); + } continue; } @@ -318,11 +345,7 @@ impl DownloadPeer { total_bytes: dl.size, speed_bytes_per_sec: speed, }; - let _ = dl.sender.send(status.clone()); - client_context - .write() - .unwrap() - .update_download_with_status(dl.token, status); + Self::send_download_status(client_context, dl, status); last_update_time = Instant::now(); } @@ -353,6 +376,39 @@ impl DownloadPeer { Ok((processor.buffer, download)) } + fn send_download_status( + client_context: &Arc>, + download: &Download, + status: DownloadStatus, + ) { + let _ = download.sender.send(status.clone()); + client_context + .write() + .unwrap() + .update_download_with_status(download.token, status); + } + + fn wait_while_paused( + &self, + client_context: &Arc>, + download: &Download, + ) -> Result<(), DownloadError> { + loop { + let status = client_context + .read() + .map_err(|_| DownloadError::LockPoisoned)? + .get_download_by_token(download.token) + .map(|download| download.status.clone()) + .ok_or(DownloadError::TokenNotFound(download.token))?; + + if !matches!(status, DownloadStatus::Paused { .. }) { + return Ok(()); + } + + thread::sleep(Duration::from_millis(200)); + } + } + fn resolve_download_path( &self, download: &Download, diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 3aefcfe..4ddec64 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -112,6 +112,9 @@ impl Download { match &self.status { DownloadStatus::InProgress { bytes_downloaded, .. + } + | DownloadStatus::Paused { + bytes_downloaded, .. } => *bytes_downloaded, DownloadStatus::Completed => self.size, _ => 0, @@ -138,6 +141,10 @@ pub enum DownloadStatus { total_bytes: u64, speed_bytes_per_sec: f64, }, + Paused { + bytes_downloaded: u64, + total_bytes: u64, + }, Completed, Failed, TimedOut, diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 27078b8..294795f 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -2,8 +2,8 @@ use crate::models::DownloadEntry; use crate::ui::{ COLOR_PRIMARY, HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes_progress, format_progress_bar, format_shortcuts_styled, - format_speed, header_style, highlight_style, inactive_style, primary_style, - warning_style, + format_speed, header_style, highlight_style, inactive_style, info_style, + primary_style, warning_style, }; use color_eyre::Result; use ratatui::{ @@ -229,6 +229,7 @@ impl MultiDownloadProgress { let status_icon = match download.status { DownloadStatus::Queued => "⋯", DownloadStatus::InProgress { .. } => "⧗", + DownloadStatus::Paused { .. } => "⏸", DownloadStatus::Completed => "✓", DownloadStatus::Failed => "✗", DownloadStatus::TimedOut => "⏱", @@ -269,6 +270,7 @@ impl MultiDownloadProgress { let style = match download.status { DownloadStatus::Queued => inactive_style(), DownloadStatus::InProgress { .. } => warning_style(), + DownloadStatus::Paused { .. } => info_style(), DownloadStatus::Completed => primary_style(), DownloadStatus::Failed | DownloadStatus::TimedOut => { error_style() @@ -352,6 +354,10 @@ pub fn render_download_stats( .iter() .filter(|d| matches!(d.download.status, DownloadStatus::Queued)) .count(); + let paused = downloads + .iter() + .filter(|d| matches!(d.download.status, DownloadStatus::Paused { .. })) + .count(); let total_downloaded: u64 = downloads .iter() @@ -430,6 +436,14 @@ pub fn render_download_stats( .add_modifier(Modifier::BOLD), ), Span::raw(" queued"), + Span::raw(", "), + Span::styled( + paused.to_string(), + Style::default() + .fg(COLOR_PRIMARY) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" paused"), ]); let stats_paragraph = Paragraph::new(stats_line); diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index 32f75c7..543fa32 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -238,6 +238,8 @@ impl MainTui { vec![ ("1-3", "focus pane"), ("↑↓", "navigate"), + ("p", "pause/resume"), + ("d", "delete queued"), ("q", "quit"), ] } @@ -617,11 +619,73 @@ impl MainTui { self.state.downloads_table_state.select(Some(new)); } } + KeyCode::Char('p') => { + self.toggle_selected_download_pause(); + } + KeyCode::Char('d') => { + self.remove_selected_queued_download(); + } _ => {} } Ok(()) } + fn toggle_selected_download_pause(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + match download.status { + DownloadStatus::InProgress { .. } => { + self.client + .pause_download(&download.username, &download.filename); + } + DownloadStatus::Paused { .. } => { + self.client + .resume_download(&download.username, &download.filename); + } + _ => {} + } + } + + fn remove_selected_queued_download(&mut self) { + let Some(index) = self.state.downloads_table_state.selected() else { + return; + }; + let Some(download_entry) = self.state.downloads.get(index) else { + return; + }; + + let download = &download_entry.download; + if !matches!(download.status, DownloadStatus::Queued) { + return; + } + + if !self + .client + .remove_queued_download(&download.username, &download.filename) + { + return; + } + + self.state.downloads.remove(index); + self.select_download_after_removal(index); + } + + fn select_download_after_removal(&mut self, removed_index: usize) { + if self.state.downloads.is_empty() { + self.state.downloads_table_state.select(None); + return; + } + + let next_index = removed_index.min(self.state.downloads.len() - 1); + self.state.downloads_table_state.select(Some(next_index)); + } + fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<()> { if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { let (col, row) = (mouse.column, mouse.row); diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index db1b074..f339971 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -1,8 +1,8 @@ use crate::models::DownloadEntry; use crate::ui::{ HIGHLIGHT_SYMBOL, border_style, border_type, error_style, format_bytes, - format_speed, header_style, highlight_style, inactive_style, success_style, - warning_style, + format_speed, header_style, highlight_style, inactive_style, info_style, + success_style, warning_style, }; use ratatui::{ Frame, @@ -52,6 +52,7 @@ pub fn render_downloads_pane( let (status_icon, status_style) = match &download.status { DownloadStatus::Queued => ("⋯", inactive_style()), DownloadStatus::InProgress { .. } => ("⧗", warning_style()), + DownloadStatus::Paused { .. } => ("⏸", info_style()), DownloadStatus::Completed => ("✓", success_style()), DownloadStatus::Failed => ("✗", error_style()), DownloadStatus::TimedOut => ("⏱", error_style()), @@ -74,6 +75,21 @@ pub fn render_downloads_pane( percent ) } + DownloadStatus::Paused { .. } => { + let percent = if download.size > 0 { + (download.bytes_downloaded() as f64 + / download.size as f64 + * 100.0) as u8 + } else { + 0 + }; + format!( + "Paused {}/{} ({}%)", + format_bytes(download.bytes_downloaded()), + format_bytes(download.size), + percent + ) + } DownloadStatus::Completed => "Completed".to_string(), DownloadStatus::Failed => "Failed".to_string(), DownloadStatus::TimedOut => "Timed out".to_string(), diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 150dd59..9171547 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -139,6 +139,7 @@ pub fn status_color(status: &str) -> Color { match status { "queued" => COLOR_INACTIVE, "in_progress" => COLOR_WARNING, + "paused" => COLOR_INFO, "completed" => COLOR_PRIMARY, "failed" | "timed_out" => COLOR_ERROR, _ => Color::default(),