diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index 2245e92..79f5a8b 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -3,7 +3,8 @@ use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::peer::{ FileSearchResponse, GetShareFileList, PeerInit, PlaceInQueueResponse, - QueueUploadHandler, TransferRequest, TransferResponse, UploadFailedHandler, + QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, + TransferRequest, TransferResponse, UploadFailedHandler, }; use crate::message::server::MessageFactory; use crate::message::{Handlers, Message, MessageReader, MessageType}; @@ -40,6 +41,8 @@ pub enum PeerMessage { IncomingQueueUpload(String), /// A peer asked to browse our shared files (they sent us code 4). ShareListRequested, + /// A peer we are browsing sent us their shared-file listing (code 5). + ShareListReceived(Vec), /// Offer the queued file to that peer: send an upload TransferRequest. ServeUpload { token: u32, @@ -153,6 +156,7 @@ impl PeerActor { handlers.register_handler(UploadFailedHandler); handlers.register_handler(PlaceInQueueResponse); handlers.register_handler(QueueUploadHandler); + handlers.register_handler(SharedFileListResponseHandler); handlers.register_handler(PeerInit); self.dispatcher = Some(MessageDispatcher::new( @@ -358,6 +362,17 @@ impl PeerActor { error!("[peer_actor] forward ShareListRequested: {}", e); } } + PeerMessage::ShareListReceived(directories) => { + let username = self.peer_username(); + if let Err(e) = + self.client_channel.send(ClientOperation::BrowseResult { + username, + directories, + }) + { + error!("[peer_actor] forward BrowseResult: {}", e); + } + } PeerMessage::RequestTransfer(download) => { let message = MessageFactory::build_transfer_request_message( &download.filename, diff --git a/soulseek-rs-lib/src/client.rs b/soulseek-rs-lib/src/client.rs index 851306b..6d45864 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -9,7 +9,7 @@ use crate::{ Transfer, actor::{ActorSystem, peer_registry::PeerRegistry}, error::{Result, SoulseekRs}, - message::peer::{FileEntry, build_file_search_response}, + message::peer::{FileEntry, SharedDirectory, build_file_search_response}, peer::{ ConnectionType, DownloadPeer, NewPeer, Peer, PeerMessage, listen::Listen, @@ -177,6 +177,11 @@ pub enum ClientOperation { ShareListRequested { requester_key: String, }, + /// A peer we are browsing returned their shared-file listing. + BrowseResult { + username: String, + directories: Vec, + }, /// A direct outbound connection to this peer failed before it was /// established — the peer is likely firewalled, so fall back to asking the /// server to broker the connection. Carries the reporting actor's id. @@ -202,6 +207,8 @@ pub struct ClientContext { uploads: HashMap, /// Upload tokens waiting for the downloader's address to be resolved. pending_serves: HashMap>, + /// Shared-file listings received from peers we browsed. + browse_results: HashMap>, actor_system: Arc, } impl Default for ClientContext { @@ -491,6 +498,7 @@ impl ClientContext { pending_peer_messages: HashMap::new(), uploads: HashMap::new(), pending_serves: HashMap::new(), + browse_results: HashMap::new(), downloads: DownloadStore::new(), actor_system, } @@ -535,6 +543,23 @@ impl ClientContext { .unwrap_or_default() } + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.browse_results.insert(username, directories); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + /// Remember that a server-brokered connection to `username` is pending under /// `token`; the peer will quote it back in a PierceFirewall. pub fn add_pending_connect(&mut self, token: u32, username: String) { @@ -721,6 +746,53 @@ impl Client { Ok(()) } + /// Request a peer's shared-file listing. When it arrives it can be + /// retrieved with [`Client::take_browse_result`]. + /// + /// # Errors + /// Returns an error if the client's context lock is poisoned. + pub fn browse_user(&self, username: &str) -> Result<()> { + let request = + crate::message::server::MessageFactory::build_get_share_file_list(); + let (connected, registry) = { + let ctx = self.context.read_safe()?; + ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(username)), + ctx.peer_registry.clone(), + ) + }; + if connected { + if let Some(registry) = registry { + let _ = registry + .send_to_peer(username, PeerMessage::SendMessage(request)); + } + } else { + self.context + .write_safe()? + .queue_peer_message(username, request); + if let Some(handle) = &self.server_handle { + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); + } + } + Ok(()) + } + + /// Remove and return a peer's shared-file listing requested via + /// [`Client::browse_user`], if it has arrived. + #[must_use] + pub fn take_browse_result( + &self, + username: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_browse_result(username)) + } + /// Ask the server for a peer's address and open a direct control /// connection to it. Downloads queued for that peer are sent automatically /// once the connection is established. @@ -1821,6 +1893,18 @@ impl Client { ); } } + ClientOperation::BrowseResult { + username, + directories, + } => { + if let Ok(mut ctx) = client_context.write_safe() + { + ctx.store_browse_result( + username, + directories, + ); + } + } ClientOperation::PeerConnectFailed( id, username, diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index 3fc89e6..ea9bf27 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -24,4 +24,5 @@ pub mod prelude { pub use actor::server_actor::{PeerAddress, UserMessage}; pub use client::{Client, ClientSettings}; pub use error::{Result, SoulseekRs}; +pub use message::peer::SharedDirectory; pub use types::{DownloadStatus, File, Search, SearchResult, Transfer}; diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index d813ab7..1dda0ab 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -17,7 +17,8 @@ pub use peer_init::PeerInit; pub use place_in_queue_response::PlaceInQueueResponse; pub use queue_upload::QueueUploadHandler; pub use shared_file_list::{ - SharedDirectory, build_shared_file_list, parse_shared_file_list, + SharedDirectory, SharedFileListResponseHandler, build_shared_file_list, + parse_shared_file_list, }; pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs index d4984ad..2537563 100644 --- a/soulseek-rs-lib/src/message/peer/shared_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -2,8 +2,10 @@ //! sent in reply to `GetShareFileList` (code 4). The payload is zlib-compressed //! and groups files by their virtual directory. -use crate::message::Message; +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; use crate::utils::zlib::{compress_stored, deflate}; +use std::sync::mpsc::Sender; /// One shared directory and the files directly in it (basename + size). #[derive(Debug, Clone, PartialEq, Eq)] @@ -12,6 +14,18 @@ pub struct SharedDirectory { pub files: Vec<(String, u64)>, } +/// Receives a peer's `SharedFileListResponse` (peer code 5) when browsing them. +pub struct SharedFileListResponseHandler; +impl MessageHandler for SharedFileListResponseHandler { + fn get_code(&self) -> u8 { + 5 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let directories = parse_shared_file_list(message); + let _ = sender.send(PeerMessage::ShareListReceived(directories)); + } +} + /// Build a `SharedFileListResponse` (peer code 5) from the directory listing. #[must_use] pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index e11cc0e..6183541 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -122,6 +122,12 @@ impl MessageFactory { .clone() } + /// Ask a peer for their shared-file listing (peer code 4, no body). + #[must_use] + pub fn build_get_share_file_list() -> Message { + Message::new().write_int32(4).clone() + } + #[must_use] pub fn build_queue_upload_message(filename: &str) -> Message { Message::new() diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 1adf385..d4a1a10 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -1145,3 +1145,53 @@ fn two_real_clients_search_and_download() { let _ = std::fs::remove_dir_all(share_dir); let _ = std::fs::remove_dir_all(download_dir); } + +#[test] +fn browse_a_peers_shared_files() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("album")).unwrap(); + std::fs::write(share_dir.join("album").join("track.flac"), b"xxxx") + .unwrap(); + + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directory: Some(share_dir.display().to_string()), + ..server.listening_settings("e2e_browsee", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + + let browser_port = free_port().expect("browser port"); + let mut browser = Client::with_settings(server.listening_settings( + "e2e_browser", + "pw", + browser_port, + )); + browser.connect().expect("browser connect"); + assert!(browser.login().expect("browser login")); + + std::thread::sleep(Duration::from_secs(1)); + + browser.browse_user("e2e_browsee").expect("browse request"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline { + if let Some(result) = browser.take_browse_result("e2e_browsee") { + listing = Some(result); + break; + } + std::thread::sleep(Duration::from_millis(200)); + } + let directories = listing.expect("browser should receive the listing"); + assert!( + directories + .iter() + .any(|d| { d.files.iter().any(|(name, _)| name == "track.flac") }), + "the listing should include the shared file" + ); + + let _ = std::fs::remove_dir_all(share_dir); +}