diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index c0d30c1..faf21e2 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -6,6 +6,7 @@ use crate::message::peer::{ PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, TransferRequest, TransferResponse, UploadDeniedHandler, UploadFailedHandler, + UserInfoRequest, }; use crate::message::server::MessageFactory; use crate::message::{Handlers, Message, MessageReader, MessageType}; @@ -46,6 +47,8 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer asked what we say about ourselves (they sent us code 15). + UserInfoRequested, /// A peer asked for one folder of our shares (they sent us code 36). FolderContentsRequested { token: u32, @@ -184,6 +187,7 @@ impl PeerActor { handlers.register_handler(TransferResponse); handlers.register_handler(GetShareFileList); handlers.register_handler(FolderContentsRequest); + handlers.register_handler(UserInfoRequest); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); handlers.register_handler(PlaceInQueueRequest); @@ -274,13 +278,27 @@ impl PeerActor { self.handle_serve_upload(token, filename, size); } PeerMessage::ShareListRequested => { - self.handle_share_list_requested(); + self.forward(ClientOperation::ShareListRequested { + requester_key: self.peer_username(), + }); } PeerMessage::ShareListReceived(directories) => { - self.handle_share_list_received(directories); + self.forward(ClientOperation::BrowseResult { + username: self.peer_username(), + directories, + }); + } + PeerMessage::UserInfoRequested => { + self.forward(ClientOperation::UserInfoRequested { + requester_key: self.peer_username(), + }); } PeerMessage::FolderContentsRequested { token, folder } => { - self.handle_folder_contents_requested(token, folder); + self.forward(ClientOperation::FolderContentsRequested { + requester_key: self.peer_username(), + token, + folder, + }); } PeerMessage::RequestTransfer(download) => { let message = MessageFactory::build_transfer_request_message( @@ -440,39 +458,9 @@ impl PeerActor { self.send_message(message); } - fn handle_share_list_requested(&self) { - let requester_key = self.peer_username(); - if let Err(e) = self - .client_channel - .send(ClientOperation::ShareListRequested { requester_key }) - { - error!("[peer_actor] forward ShareListRequested: {}", e); - } - } - - fn handle_folder_contents_requested(&self, token: u32, folder: String) { - let requester_key = self.peer_username(); - if let Err(e) = - self.client_channel - .send(ClientOperation::FolderContentsRequested { - requester_key, - token, - folder, - }) - { - error!("[peer_actor] forward FolderContentsRequested: {}", e); - } - } - - fn handle_share_list_received(&self, directories: Vec) { - let username = self.peer_username(); - if let Err(e) = - self.client_channel.send(ClientOperation::BrowseResult { - username, - directories, - }) - { - error!("[peer_actor] forward BrowseResult: {}", e); + fn forward(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[peer_actor] forward to client: {}", e); } } diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 11e2d11..d4098d9 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -292,6 +292,10 @@ pub enum ClientOperation { ShareListRequested { requester_key: String, }, + /// A peer asked what we say about ourselves; send a UserInfoResponse. + UserInfoRequested { + requester_key: String, + }, /// A peer asked for one folder of our shares; send a FolderContentsResponse. FolderContentsRequested { requester_key: String, diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations.rs index d54d731..1bc662c 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations.rs @@ -766,6 +766,19 @@ impl Client { }, ); } + ClientOperation::UserInfoRequested { requester_key } => { + Self::reply_to_peer( + &client_context, + &requester_key, + |ctx| { + crate::message::peer::build_user_info( + ctx.upload_slots as u32, + ctx.upload_queue.len() as u32, + ctx.has_free_upload_slot(), + ) + }, + ); + } ClientOperation::FolderContentsRequested { requester_key, token, diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index 7da6abe..4c37b1a 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -10,6 +10,7 @@ mod transfer_request; mod transfer_response; mod upload_denied; mod upload_failed; +mod user_info; // Re-export handlers pub use file_search_response::{ @@ -31,3 +32,4 @@ pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; pub use upload_denied::UploadDeniedHandler; pub use upload_failed::UploadFailedHandler; +pub use user_info::{UserInfoRequest, build_user_info}; diff --git a/soulseek-rs-lib/src/message/peer/user_info.rs b/soulseek-rs-lib/src/message/peer/user_info.rs new file mode 100644 index 0000000..0887ad8 --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/user_info.rs @@ -0,0 +1,33 @@ +//! `UserInfoRequest` (peer code 15) and its reply (code 16): what a peer says +//! about itself when another user opens its profile. + +use crate::message::{Message, MessageHandler}; +use crate::peer::PeerMessage; +use std::sync::mpsc::Sender; + +pub struct UserInfoRequest; +impl MessageHandler for UserInfoRequest { + fn get_code(&self) -> u32 { + 15 + } + fn handle(&self, _message: &mut Message, sender: Sender) { + let _ = sender.send(PeerMessage::UserInfoRequested); + } +} + +/// Build a `UserInfoResponse` (peer code 16) with no description or picture. +#[must_use] +pub fn build_user_info( + upload_slots: u32, + queue_size: u32, + slots_free: bool, +) -> Message { + Message::new() + .write_int32(16) + .write_string("") + .write_bool(false) + .write_int32(upload_slots) + .write_int32(queue_size) + .write_bool(slots_free) + .clone() +} diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index e2ebd22..7780c1e 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -3247,3 +3247,52 @@ fn a_third_party_client_fetches_one_folder_of_our_shares() { let _ = std::fs::remove_dir_all(share_dir); } + +// "User info" in SoulseekQt and Nicotine+ asks the peer directly (peer code +// 15); a peer that never answers shows an endless spinner. +#[test] +fn a_third_party_client_reads_our_user_info() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("probe.flac"), b"xxxx").unwrap(); + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(ClientSettings { + shared_directories: vec![share_dir.display().to_string()], + ..server.listening_settings("e2e_info_sharer", "pw", sharer_port) + }); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + sharer.set_upload_slots(3); + let server_addr = format!("{}:{}", server.host, server.port); + let _qt = login_raw(&server_addr, "e2e_info_asker", "pw") + .expect("third-party client logs in"); + + let mut p = connect_retry( + &format!("127.0.0.1:{sharer_port}"), + Duration::from_secs(5), + ) + .expect("dial the sharer"); + p.set_read_timeout(Some(Duration::from_secs(15))).unwrap(); + p.write_all(&peer_init_bytes("e2e_info_asker", "P", 0)) + .unwrap(); + p.write_all(&Message::new().write_int32(15).get_buffer()) + .unwrap(); + p.flush().unwrap(); + + let mut reply = expect_code(&mut p, 16, Duration::from_secs(15)) + .expect("the sharer answers with a UserInfoResponse"); + reply.set_pointer(8); + let description = reply.read_string(); + let has_picture = reply.read_bool(); + let upload_slots = reply.read_int32(); + let queue_size = reply.read_int32(); + let slots_free = reply.read_bool(); + assert!(description.is_empty(), "no description is configured"); + assert!(!has_picture); + assert_eq!(upload_slots, 3, "the configured slot count"); + assert_eq!(queue_size, 0, "nobody is waiting"); + assert!(slots_free, "nothing is uploading"); + + let _ = std::fs::remove_dir_all(share_dir); +}