diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index 95717a7..2245e92 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -38,6 +38,8 @@ pub enum PeerMessage { RequestTransfer(Download), /// A peer queued one of our shared files for download (they sent us code 43). IncomingQueueUpload(String), + /// A peer asked to browse our shared files (they sent us code 4). + ShareListRequested, /// Offer the queued file to that peer: send an upload TransferRequest. ServeUpload { token: u32, @@ -347,6 +349,15 @@ impl PeerActor { ); self.send_message(message); } + PeerMessage::ShareListRequested => { + let requester_key = self.peer_username(); + if let Err(e) = self + .client_channel + .send(ClientOperation::ShareListRequested { requester_key }) + { + error!("[peer_actor] forward ShareListRequested: {}", 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 16d009f..851306b 100644 --- a/soulseek-rs-lib/src/client.rs +++ b/soulseek-rs-lib/src/client.rs @@ -173,6 +173,10 @@ pub enum ClientOperation { StartUpload { token: u32, }, + /// A peer asked to browse our shared files; send our SharedFileListResponse. + ShareListRequested { + requester_key: String, + }, /// 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. @@ -1784,6 +1788,39 @@ impl Client { } } } + ClientOperation::ShareListRequested { + requester_key, + } => { + // Reply with our full shared-file listing. + let (registry, message) = match client_context + .read_safe() + { + Ok(ctx) => { + let dirs = ctx + .shares + .directories() + .into_iter() + .map(|(name, files)| { + crate::message::peer::SharedDirectory { + name, + files, + } + }) + .collect::>(); + ( + ctx.peer_registry.clone(), + crate::message::peer::build_shared_file_list(&dirs), + ) + } + Err(_) => continue, + }; + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &requester_key, + PeerMessage::SendMessage(message), + ); + } + } ClientOperation::PeerConnectFailed( id, username, diff --git a/soulseek-rs-lib/src/message/peer/get_share_file_list.rs b/soulseek-rs-lib/src/message/peer/get_share_file_list.rs index 9f04870..d650be3 100644 --- a/soulseek-rs-lib/src/message/peer/get_share_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/get_share_file_list.rs @@ -1,17 +1,17 @@ use crate::{ - message::{Message, MessageHandler, server::MessageFactory}, + message::{Message, MessageHandler}, peer::PeerMessage, }; use std::sync::mpsc::Sender; +/// A peer asking to browse our shared files (peer code 4). The client (which +/// owns the shares) builds the real SharedFileListResponse in reply. pub struct GetShareFileList; impl MessageHandler for GetShareFileList { fn get_code(&self) -> u8 { 4 } fn handle(&self, _message: &mut Message, sender: Sender) { - let message = MessageFactory::build_shared_folders_message(100, 800); - - sender.send(PeerMessage::SendMessage(message)).unwrap(); + let _ = sender.send(PeerMessage::ShareListRequested); } } diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index 1f7eef8..d813ab7 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -3,6 +3,7 @@ mod get_share_file_list; mod peer_init; mod place_in_queue_response; mod queue_upload; +mod shared_file_list; mod transfer_request; mod transfer_response; mod upload_failed; @@ -15,6 +16,9 @@ pub use get_share_file_list::GetShareFileList; 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, +}; pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; pub use upload_failed::UploadFailedHandler; diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs new file mode 100644 index 0000000..d4984ad --- /dev/null +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -0,0 +1,101 @@ +//! `SharedFileListResponse` (peer code 5): a peer's full shared-file listing, +//! 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::utils::zlib::{compress_stored, deflate}; + +/// One shared directory and the files directly in it (basename + size). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SharedDirectory { + pub name: String, + pub files: Vec<(String, u64)>, +} + +/// Build a `SharedFileListResponse` (peer code 5) from the directory listing. +#[must_use] +pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { + let mut payload = Message::new(); + payload.write_int32(dirs.len() as u32); + for dir in dirs { + payload + .write_string(&dir.name) + .write_int32(dir.files.len() as u32); + for (name, size) in &dir.files { + payload + .write_int8(1) + .write_string(name) + .write_int64(*size) + .write_string("") // extension + .write_int32(0); // attribute count + } + } + payload.write_int32(0); // unknown + payload.write_int32(0); // number of private directories + + let compressed = compress_stored(&payload.get_data()); + Message::new() + .write_int32(5) + .write_raw_bytes(compressed) + .clone() +} + +/// Parse the (zlib-compressed) `SharedFileListResponse` payload. `message` must +/// be positioned at the compressed blob (the dispatcher sets pointer 8). +/// +/// Returns an empty listing if the payload is malformed. +#[must_use] +pub fn parse_shared_file_list(message: &mut Message) -> Vec { + let pointer = message.get_pointer(); + let size = message.get_size(); + let compressed = message.get_slice(pointer, size); + let Ok(data) = deflate(&compressed) else { + return Vec::new(); + }; + + let mut body = Message::new_with_data(data); + let dir_count = body.read_int32(); + let mut dirs = Vec::new(); + for _ in 0..dir_count { + let name = body.read_string(); + let file_count = body.read_int32(); + let mut files = Vec::new(); + for _ in 0..file_count { + body.read_int8(); // code + let filename = body.read_string(); + let file_size = body.read_int64(); + body.read_string(); // extension + let attr_count = body.read_int32(); + for _ in 0..attr_count { + body.read_int32(); + body.read_int32(); + } + files.push((filename, file_size)); + } + dirs.push(SharedDirectory { name, files }); + } + dirs +} + +#[test] +fn shared_file_list_roundtrips() { + let dirs = vec![ + SharedDirectory { + name: "music\\album".to_string(), + files: vec![ + ("song one.flac".to_string(), 123), + ("song two.flac".to_string(), 456), + ], + }, + SharedDirectory { + name: "music".to_string(), + files: vec![("top.mp3".to_string(), 789)], + }, + ]; + let message = build_shared_file_list(&dirs); + + // Decode via the same offset the dispatcher would use. + let mut decoded = Message::new_with_data(message.get_buffer()); + decoded.set_pointer(8); + assert_eq!(parse_shared_file_list(&mut decoded), dirs); +} diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index 2e63878..437a48a 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -128,6 +128,26 @@ impl Shares { &self.files } + /// Files grouped by their virtual directory (everything before the final + /// backslash), as `(directory, [(basename, size)])`. Used to build a + /// SharedFileListResponse. + #[must_use] + pub fn directories(&self) -> Vec<(String, Vec<(String, u64)>)> { + let mut by_dir: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for file in &self.files { + let (dir, base) = file + .virtual_path + .rsplit_once('\\') + .unwrap_or(("", file.virtual_path.as_str())); + by_dir + .entry(dir.to_string()) + .or_default() + .push((base.to_string(), file.size)); + } + by_dir.into_iter().collect() + } + #[must_use] pub const fn file_count(&self) -> u32 { self.files.len() as u32