diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index f81fec2..3935982 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -245,6 +245,18 @@ impl PeerActor { "[peer:{}] Place in queue response - file: {}, place: {}", username, filename, place ); + if let Err(e) = + self.client_channel.send(ClientOperation::PlaceInQueueUpdate { + username: username.clone(), + filename, + place, + }) + { + error!( + "[peer:{}] failed to forward PlaceInQueueUpdate: {}", + username, e + ); + } } PeerMessage::SetUsername(username) => { trace!( diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index 0f6f064..b4d9d00 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -1,7 +1,7 @@ use crate::actor::ActorHandle; use crate::actor::server_actor::{PeerAddress, ServerActor, ServerMessage}; use crate::download_store::{DownloadStore, collect_failed_tokens}; -use crate::types::DownloadStatus; +use crate::types::{DownloadMetadata, DownloadStatus}; use crate::utils::logger; use crate::{ Transfer, @@ -84,6 +84,11 @@ pub enum ClientOperation { obfuscated_port: u16, }, UploadFailed(String, String), + PlaceInQueueUpdate { + username: String, + filename: String, + place: u32, + }, SetServerSender(Sender), } pub struct ClientContext { @@ -159,6 +164,8 @@ fn test_client_context_downloads() { download_directory: "test".to_string(), status: DownloadStatus::Queued, sender: mpsc::channel().0, + queue_position: None, + metadata: Default::default(), }; context.add_download(download); assert!(context.get_download_by_token(123).is_some()); @@ -191,6 +198,8 @@ fn test_client_pause_and_resume_download() { speed_bytes_per_sec: 10.0, }, sender: download_sender, + queue_position: None, + metadata: Default::default(), }; client.context.write().unwrap().add_download(download); @@ -245,6 +254,8 @@ fn test_client_removes_only_queued_downloads() { download_directory: "test".to_string(), status: DownloadStatus::Queued, sender: mpsc::channel().0, + queue_position: None, + metadata: Default::default(), }; let active_download = Download { username: "peer".to_string(), @@ -258,6 +269,8 @@ fn test_client_removes_only_queued_downloads() { speed_bytes_per_sec: 10.0, }, sender: mpsc::channel().0, + queue_position: None, + metadata: Default::default(), }; { @@ -556,6 +569,23 @@ impl Client { username: String, size: u64, download_directory: String, + ) -> Result<(Download, Receiver)> { + self.download_with_metadata( + filename, + username, + size, + download_directory, + DownloadMetadata::default(), + ) + } + + pub fn download_with_metadata( + &self, + filename: String, + username: String, + size: u64, + download_directory: String, + metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { info!("[client] Downloading {} from {}", filename, username); @@ -575,6 +605,8 @@ impl Client { download_directory, status: DownloadStatus::Queued, sender: download_sender, + queue_position: None, + metadata, }; let mut context = self.context.write_safe()?; @@ -1025,6 +1057,8 @@ impl Client { .download_directory, status: download.status.clone(), sender: download.sender.clone(), + queue_position: download.queue_position, + metadata: download.metadata.clone(), }); context.remove_download(old_token); } @@ -1039,6 +1073,29 @@ impl Client { Some(&filename), ); } + ClientOperation::PlaceInQueueUpdate { + username, + filename, + place, + } => match client_context.write_safe() { + Ok(mut ctx) => { + let updated = ctx + .downloads + .update_queue_position( + &username, &filename, place, + ); + if !updated { + debug!( + "[client] PlaceInQueueUpdate: no matching download for {}/{}", + username, filename + ); + } + } + Err(e) => error!( + "[client] PlaceInQueueUpdate write: {}", + e + ), + }, ClientOperation::SetServerSender(sender) => { match client_context.write_safe() { Ok(mut ctx) => { diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs index cfba0a6..63992f9 100644 --- a/soulseek-rs-lib/src/download_store.rs +++ b/soulseek-rs-lib/src/download_store.rs @@ -50,6 +50,19 @@ impl DownloadStore { } } + pub fn update_queue_position( + &mut self, + username: &str, + filename: &str, + position: u32, + ) -> bool { + let Some(download) = self.get_by_file_mut(username, filename) else { + return false; + }; + download.queue_position = Some(position); + true + } + pub fn remove_queued_by_file( &mut self, username: &str, @@ -150,6 +163,8 @@ mod tests { download_directory: "test".to_string(), status, sender: mpsc::channel().0, + queue_position: None, + metadata: Default::default(), } } @@ -167,6 +182,24 @@ mod tests { assert!(store.list().is_empty()); } + #[test] + fn update_queue_position_sets_field_when_match() { + let mut store = DownloadStore::new(); + let mut download = make_download(1, DownloadStatus::Queued); + download.username = "peer".to_string(); + download.filename = "song.mp3".to_string(); + store.add(download); + + assert!(store.update_queue_position("peer", "song.mp3", 42)); + assert_eq!( + store.get_by_token(1).unwrap().queue_position, + Some(42) + ); + + assert!(!store.update_queue_position("peer", "missing.mp3", 1)); + assert!(!store.update_queue_position("other", "song.mp3", 1)); + } + #[test] fn pause_then_resume_in_progress_download() { let mut store = DownloadStore::new(); diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 3e3056c..2b2623d 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -1,4 +1,3 @@ -use std::env; use std::fs; use std::io::{self, Read, Write}; use std::net::TcpStream; @@ -12,6 +11,7 @@ use crate::client::ClientContext; use crate::message::server::MessageFactory; use crate::trace; use crate::types::{Download, DownloadStatus}; +use crate::utils::path::expand_tilde; const START_DOWNLOAD: [u8; 8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; @@ -72,18 +72,6 @@ impl From for DownloadError { struct FileManager; impl FileManager { - fn expand_path(path: &str) -> PathBuf { - if let Some(stripped) = path.strip_prefix('~') { - if let Ok(home) = env::var("HOME") { - PathBuf::from(home).join(stripped.trim_start_matches('/')) - } else { - PathBuf::from(path) - } - } else { - PathBuf::from(path) - } - } - fn extract_filename_from_path(full_path: &str) -> &str { full_path .split(['/', '\\']) @@ -414,7 +402,7 @@ impl DownloadPeer { download: &Download, ) -> Result { let download_directory = &download.download_directory; - let mut expanded_path = FileManager::expand_path(download_directory); + let mut expanded_path = expand_tilde(download_directory); if !expanded_path.is_dir() { expanded_path = expanded_path diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 4ddec64..e69c9ec 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -87,6 +87,14 @@ pub struct Transfer { pub filename: String, pub size: u64, } +#[derive(Debug, Clone, Default)] +pub struct DownloadMetadata { + pub bitrate: Option, + pub length_seconds: Option, + pub peer_upload_speed: Option, + pub peer_free_slots: Option, +} + #[derive(Debug, Clone)] pub struct Download { pub username: String, @@ -96,6 +104,8 @@ pub struct Download { pub download_directory: String, pub status: DownloadStatus, pub sender: Sender, + pub queue_position: Option, + pub metadata: DownloadMetadata, } impl Download { diff --git a/soulseek-rs-lib/src/utils/mod.rs b/soulseek-rs-lib/src/utils/mod.rs index bd215a2..d684ac7 100644 --- a/soulseek-rs-lib/src/utils/mod.rs +++ b/soulseek-rs-lib/src/utils/mod.rs @@ -2,6 +2,7 @@ pub mod logger; pub mod lock; pub mod md5; +pub mod path; pub mod thread_pool; pub mod zlib; diff --git a/soulseek-rs-lib/src/utils/path.rs b/soulseek-rs-lib/src/utils/path.rs new file mode 100644 index 0000000..baf073d --- /dev/null +++ b/soulseek-rs-lib/src/utils/path.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +pub fn expand_tilde(path: &str) -> PathBuf { + if let Some(stripped) = path.strip_prefix('~') + && let Ok(home) = std::env::var("HOME") + { + return PathBuf::from(home).join(stripped.trim_start_matches('/')); + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_tilde_returned_as_is() { + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); + } + + #[test] + fn tilde_expanded_when_home_set() { + // SAFETY: tests run single-threaded by default in this crate; if you + // ever run with --test-threads >1 this needs serialization. + unsafe { + std::env::set_var("HOME", "/home/test"); + } + assert_eq!(expand_tilde("~"), PathBuf::from("/home/test")); + assert_eq!( + expand_tilde("~/Downloads"), + PathBuf::from("/home/test/Downloads") + ); + } +} diff --git a/soulseek-rs/src/models/file_display_data.rs b/soulseek-rs/src/models/file_display_data.rs index d8b76a4..c3d5d06 100644 --- a/soulseek-rs/src/models/file_display_data.rs +++ b/soulseek-rs/src/models/file_display_data.rs @@ -6,4 +6,5 @@ pub struct FileDisplayData { pub speed: u32, pub slots: u8, pub bitrate: Option, + pub length_seconds: Option, } diff --git a/soulseek-rs/src/ui/download_selector.rs b/soulseek-rs/src/ui/download_selector.rs index b70bb02..60aaca6 100644 --- a/soulseek-rs/src/ui/download_selector.rs +++ b/soulseek-rs/src/ui/download_selector.rs @@ -139,6 +139,7 @@ impl FileSelector { speed: result.speed, slots: result.slots, bitrate: get_bitrate(&file.attribs), + length_seconds: file.attribs.get(&1).copied(), }); } } diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index 56d7073..6127ca0 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -3,8 +3,8 @@ use crate::models::{ SearchStatus, }; use crate::ui::panes::{ - ResultsPaneParams, render_downloads_pane, render_results_pane, - render_searches_pane, + ResultsPaneParams, render_download_info_pane, render_downloads_pane, + render_results_pane, render_searches_pane, }; use crate::ui::{ border_style, border_type, format_shortcuts_styled, render_download_stats, @@ -151,9 +151,16 @@ impl MainTui { ]) .split(content_chunks[1]); + // Split bottom-right into Downloads list and Info pane + let downloads_chunks = Layout::horizontal([ + Constraint::Percentage(60), // Downloads list + Constraint::Percentage(40), // Info pane + ]) + .split(right_chunks[1]); + // Store results and downloads pane areas self.state.results_pane_area = Some(right_chunks[0]); - self.state.downloads_pane_area = Some(right_chunks[1]); + self.state.downloads_pane_area = Some(downloads_chunks[0]); // Render Results pane let results_items = if self.state.results_filter_query.is_empty() { @@ -185,12 +192,25 @@ impl MainTui { // Render Downloads pane render_downloads_pane( frame, - right_chunks[1], + downloads_chunks[0], &self.state.downloads, &mut self.state.downloads_table_state, self.state.focused_pane == FocusedPane::Downloads, ); + // Render Info pane next to downloads + let selected_download = self + .state + .downloads_table_state + .selected() + .and_then(|idx| self.state.downloads.get(idx)); + render_download_info_pane( + frame, + downloads_chunks[1], + selected_download, + self.state.focused_pane == FocusedPane::Downloads, + ); + // Render command bar if active (vim-style, above shortcuts) if self.state.command_bar_active { self.render_command_bar(frame, main_chunks[2]); @@ -906,6 +926,7 @@ impl MainTui { speed: result.speed, slots: result.slots, bitrate: file.attribs.get(&0).copied(), + length_seconds: file.attribs.get(&1).copied(), }); } } @@ -933,14 +954,12 @@ impl MainTui { } fn queue_selected_downloads(&mut self) { - let selected_files: Vec<(String, String, u64)> = self + let selected_files: Vec = self .state .results_selected_indices .iter() .filter_map(|&idx| self.state.results_items.get(idx)) - .map(|file| { - (file.filename.clone(), file.username.clone(), file.size) - }) + .cloned() .collect(); if selected_files.is_empty() { @@ -958,12 +977,19 @@ impl MainTui { let sender = self.state.downloads_sender_channel.clone().unwrap(); thread::spawn(move || { - for (filename, username, size) in selected_files.into_iter() { - match client.download( - filename.clone(), - username.clone(), - size, + for file in selected_files.into_iter() { + let metadata = soulseek_rs::types::DownloadMetadata { + bitrate: file.bitrate, + length_seconds: file.length_seconds, + peer_upload_speed: Some(file.speed), + peer_free_slots: Some(file.slots), + }; + match client.download_with_metadata( + file.filename.clone(), + file.username.clone(), + file.size, download_dir.clone(), + metadata, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); @@ -971,7 +997,7 @@ impl MainTui { Err(e) => { eprintln!( "Failed to start download for {}: {}", - filename, e + file.filename, e ); } } diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs new file mode 100644 index 0000000..9c5c537 --- /dev/null +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -0,0 +1,259 @@ +use crate::models::DownloadEntry; +use crate::ui::{ + border_style, border_type, dimmed_style, error_style, format_bytes, + format_progress_bar, format_speed, info_style, inactive_style, + primary_style, success_style, warning_style, +}; +use ratatui::{ + Frame, + layout::Rect, + style::Style, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, +}; +use soulseek_rs::DownloadStatus; +use soulseek_rs::utils::path::expand_tilde; + +const LABEL_WIDTH: usize = 20; + +pub fn render_download_info_pane( + frame: &mut Frame, + area: Rect, + selected: Option<&DownloadEntry>, + focused: bool, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style(focused)) + .border_type(border_type(focused)) + .title("[Info]"); + + let Some(entry) = selected else { + let paragraph = Paragraph::new( + Line::from(Span::styled( + "Select a download for details.", + dimmed_style(), + )), + ) + .block(block); + frame.render_widget(paragraph, area); + return; + }; + + let lines = build_info_lines(&entry.download); + + let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + frame.render_widget(paragraph, area); +} + +fn build_info_lines( + download: &soulseek_rs::types::Download, +) -> Vec> { + let mut lines: Vec> = Vec::new(); + + let (basename, parent_dir) = split_filename(&download.filename); + + lines.push(Line::from(Span::styled( + basename, + primary_style().add_modifier(ratatui::style::Modifier::BOLD), + ))); + if !parent_dir.is_empty() { + lines.push(Line::from(Span::styled(parent_dir, dimmed_style()))); + } + lines.push(Line::from("")); + + lines.push(label_value("User", &download.username)); + lines.push(label_value("Size", &format_bytes(download.size))); + + let (status_text, status_style) = match &download.status { + DownloadStatus::Queued => ("Queued".to_string(), inactive_style()), + DownloadStatus::InProgress { .. } => { + ("In progress".to_string(), warning_style()) + } + DownloadStatus::Paused { .. } => ("Paused".to_string(), info_style()), + DownloadStatus::Completed => ("Completed".to_string(), success_style()), + DownloadStatus::Failed => ("Failed".to_string(), error_style()), + DownloadStatus::TimedOut => ("Timed out".to_string(), error_style()), + }; + lines.push(label_value_styled("Status", status_text, status_style)); + + let save_path = expand_tilde(&download.download_directory) + .to_string_lossy() + .to_string(); + lines.push(label_value("Save to", &save_path)); + + if let Some(bitrate) = download.metadata.bitrate { + lines.push(label_value("Bitrate", &format!("{} kbps", bitrate))); + } + if let Some(length) = download.metadata.length_seconds { + lines.push(label_value("Length", &format_duration(length))); + } + + match &download.status { + DownloadStatus::Queued => { + lines.push(Line::from("")); + let position_text = match download.queue_position { + Some(p) => format!("#{}", p), + None => "unknown".to_string(), + }; + lines.push(label_value("Queue position", &position_text)); + + if let Some(slots) = download.metadata.peer_free_slots { + let slot_text = if slots > 0 { + format!("{} available", slots) + } else { + "all busy".to_string() + }; + lines.push(label_value("Peer slots", &slot_text)); + } + + if let Some(speed) = download.metadata.peer_upload_speed { + lines.push(label_value( + "Peer upload speed", + &format_speed(speed as f64), + )); + } + } + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => { + lines.push(Line::from("")); + push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes); + lines.push(label_value("Speed", &format_speed(*speed_bytes_per_sec))); + + if *speed_bytes_per_sec > 0.0 && *total_bytes > *bytes_downloaded { + let remaining = *total_bytes - *bytes_downloaded; + let eta_secs = (remaining as f64 / speed_bytes_per_sec) as u32; + lines.push(label_value("ETA", &format_duration(eta_secs))); + } + } + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => { + lines.push(Line::from("")); + push_progress_lines(&mut lines, *bytes_downloaded, *total_bytes); + } + DownloadStatus::Completed + | DownloadStatus::Failed + | DownloadStatus::TimedOut => {} + } + + lines +} + +fn label_value(label: &str, value: &str) -> Line<'static> { + Line::from(vec![ + Span::styled( + format!("{: Line<'static> { + Line::from(vec![ + Span::styled( + format!("{:>, + bytes_downloaded: u64, + total_bytes: u64, +) { + let (progress, percent) = if total_bytes > 0 { + let ratio = bytes_downloaded as f64 / total_bytes as f64; + (ratio, (ratio * 100.0) as u8) + } else { + (0.0, 0) + }; + lines.push(format_progress_bar(progress, 20, percent)); + lines.push(label_value( + "Downloaded", + &format!( + "{} / {}", + format_bytes(bytes_downloaded), + format_bytes(total_bytes) + ), + )); +} + +fn split_filename(path: &str) -> (String, String) { + let normalized = path.replace('\\', "/"); + if let Some(idx) = normalized.rfind('/') { + let (parent, basename) = normalized.split_at(idx); + (basename.trim_start_matches('/').to_string(), parent.to_string()) + } else { + (normalized, String::new()) + } +} + +fn format_duration(seconds: u32) -> String { + let hours = seconds / 3600; + let minutes = (seconds % 3600) / 60; + let secs = seconds % 60; + if hours > 0 { + format!("{}h {:02}m {:02}s", hours, minutes, secs) + } else if minutes > 0 { + format!("{}m {:02}s", minutes, secs) + } else { + format!("{}s", secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_seconds_only() { + assert_eq!(format_duration(0), "0s"); + assert_eq!(format_duration(45), "45s"); + } + + #[test] + fn format_duration_minutes_and_seconds() { + assert_eq!(format_duration(60), "1m 00s"); + assert_eq!(format_duration(125), "2m 05s"); + } + + #[test] + fn format_duration_hours_minutes_seconds() { + assert_eq!(format_duration(3600), "1h 00m 00s"); + assert_eq!(format_duration(3725), "1h 02m 05s"); + } + + #[test] + fn split_filename_with_backslash_path() { + let (basename, parent) = + split_filename("@@drive\\Music\\Album\\track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, "@@drive/Music/Album"); + } + + #[test] + fn split_filename_with_forward_slash_path() { + let (basename, parent) = split_filename("/home/user/track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, "/home/user"); + } + + #[test] + fn split_filename_with_no_separator() { + let (basename, parent) = split_filename("track.mp3"); + assert_eq!(basename, "track.mp3"); + assert_eq!(parent, ""); + } +} diff --git a/soulseek-rs/src/ui/panes/mod.rs b/soulseek-rs/src/ui/panes/mod.rs index 0b81308..109efb9 100644 --- a/soulseek-rs/src/ui/panes/mod.rs +++ b/soulseek-rs/src/ui/panes/mod.rs @@ -1,7 +1,9 @@ +mod download_info_pane; mod downloads_pane; mod results_pane; mod searches_pane; +pub use download_info_pane::render_download_info_pane; pub use downloads_pane::render_downloads_pane; pub use results_pane::{ResultsPaneParams, render_results_pane}; pub use searches_pane::render_searches_pane;