//! Soulseek network client: server connection, peer connections, search, //! downloads. //! //! Behavioral reference: Soulseek.NET `SoulseekClient.cs` and `src/Network/**`. //! //! M1: connect + login, peer listener, indirect (PierceFirewall) //! connections both directions, live search. //! M2: downloads — TransferRequest/TransferResponse dance, F-type transfer //! connections (both directions), resume via offset, download manager. mod conn; mod distributed; mod responder; mod share; mod transfer; mod upload; mod upnp; pub use share::ShareIndex; pub use transfer::{DownloadState, DownloadUpdate}; pub use upload::{UploadState, UploadUpdate}; use std::collections::{HashMap, HashSet, VecDeque}; use std::net::{Ipv4Addr, SocketAddrV4}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use tokio::net::TcpStream; use tokio::sync::{mpsc, oneshot}; use tokio_util::sync::CancellationToken; use soulseek_proto::{server, types::SearchResponse}; pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242); pub const DEFAULT_LISTEN_PORT: u16 = 50300; #[derive(Debug, Clone)] pub struct Config { pub server_host: String, pub server_port: u16, pub listen_port: u16, pub downloads_dir: PathBuf, /// Folders we share with the network. pub shared_folders: Vec, /// Concurrent upload slots offered to peers. pub upload_slots: u32, /// User-info card description. pub description: String, } impl Default for Config { fn default() -> Self { Config { server_host: DEFAULT_SERVER.0.into(), server_port: DEFAULT_SERVER.1, listen_port: DEFAULT_LISTEN_PORT, downloads_dir: PathBuf::from("downloads"), shared_folders: Vec::new(), upload_slots: 2, description: "SoulStream user".into(), } } } #[derive(Debug, Clone)] pub enum Event { LoggedIn { greeting: String, own_ip: Ipv4Addr, }, LoginFailed { reason: String, }, Disconnected { reason: String, }, SearchResult { token: u32, response: SearchResponse, }, ExcludedSearchPhrases(Vec), Download(DownloadUpdate), Upload(UploadUpdate), /// We answered a peer's search with N files (for activity display). RespondedToSearch { username: String, files: usize, }, } #[derive(Debug, thiserror::Error)] pub enum ClientError { #[error("io: {0}")] Io(#[from] std::io::Error), #[error("protocol: {0}")] Proto(#[from] soulseek_proto::ProtoError), #[error("not connected to server")] NotConnected, #[error("login failed: {0}")] LoginFailed(String), #[error("user {0} is offline or unknown")] UserOffline(String), #[error("could not reach peer {0}")] PeerUnreachable(String), #[error("transfer rejected: {0}")] TransferRejected(String), #[error("timed out")] Timeout, } pub type Result = std::result::Result; /// Write handle for an established peer message ("P") connection. #[derive(Clone)] pub(crate) struct PeerHandle { pub write_tx: mpsc::UnboundedSender>, } /// Peer's answer to our TransferRequest(download). #[derive(Debug, Clone)] pub(crate) struct TransferAck { pub allowed: bool, pub size: u64, pub message: String, } /// Peer's TransferRequest(upload) once we reach their queue front — /// or the denial that arrived instead. pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size) pub(crate) struct Inner { pub server_tx: mpsc::UnboundedSender>, pub event_tx: mpsc::UnboundedSender, pub username: Mutex, pub active_searches: Mutex>, pub pending_login: Mutex>>>, pub token_counter: AtomicU32, pub listen_port: u16, pub downloads_dir: PathBuf, /// GetPeerAddress requests awaiting a server answer, by username. pub pending_addresses: Mutex>>>, /// Last known endpoint per user. pub address_cache: Mutex>, /// Our indirect-connection solicitations: token → deliver the pierced stream. pub pending_pierce: Mutex>>, /// Inbound transfer connections we expect: (username, remote token) → stream. pub expected_transfers: Mutex>>, /// Live peer message connections. pub peers: Mutex>, /// TransferResponse waits: (username, our token). pub transfer_waits: Mutex>>, /// TransferRequest(upload) waits: (username, remote filename). pub upload_offers: Mutex>>, /// Active downloads by (username, remote filename) → (id, cancel). pub downloads_by_file: Mutex>, pub download_counter: AtomicU64, // ---- M5: sharing / uploads / distributed ---- /// Files we share with the network. pub shares: Arc, /// Excluded phrases from the server; outgoing search results filter these. pub excluded_phrases: RwLock>, /// Our user-info description. pub description: String, /// Total upload slots and how many are in use. pub upload_slots: u32, pub uploads_active: AtomicU32, /// Pending upload queue (username, remote path), FIFO. pub upload_queue: Mutex>, /// Rolling average upload speed reported to peers (bytes/sec). pub upload_speed: AtomicU32, pub upload_counter: AtomicU64, /// Whether we currently have a distributed parent. pub has_parent: AtomicBool, /// De-dupe relayed searches so we don't answer the same token twice. pub answered_searches: Mutex>, /// Count of relayed searches we've seen (instrumentation). pub searches_seen: AtomicU64, } impl Inner { pub fn next_token(&self) -> u32 { self.token_counter.fetch_add(1, Ordering::Relaxed) } pub fn own_username(&self) -> String { self.username.lock().unwrap().clone() } pub fn send_server(&self, frame: Vec) -> Result<()> { self.server_tx .send(frame) .map_err(|_| ClientError::NotConnected) } pub fn excluded(&self) -> Vec { self.excluded_phrases.read().unwrap().clone() } pub fn has_free_upload_slot(&self) -> bool { self.uploads_active.load(Ordering::Relaxed) < self.upload_slots } } /// Handle to a running client. Dropping it does not tear down the /// connection; call [`Client::shutdown`]. pub struct Client { inner: Arc, shutdown_tx: mpsc::UnboundedSender<()>, listener_shutdown_tx: mpsc::UnboundedSender<()>, } impl Client { /// Connect the server socket and start the peer listener. /// Events (search results, download progress) arrive on the receiver. pub async fn connect(config: Config) -> Result<(Client, mpsc::UnboundedReceiver)> { let (event_tx, event_rx) = mpsc::unbounded_channel(); let (server_tx, server_rx) = mpsc::unbounded_channel::>(); let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel::<()>(); let (listener_shutdown_tx, listener_shutdown_rx) = mpsc::unbounded_channel::<()>(); let stream = TcpStream::connect((config.server_host.as_str(), config.server_port)).await?; stream.set_nodelay(true).ok(); // Bind the listener before login so SetListenPort is accurate. let listener = conn::bind_listener(config.listen_port).await?; let listen_port = listener.local_addr()?.port(); // Seed tokens off the clock so restarts don't reuse recent tokens. let seed = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.subsec_nanos()) .unwrap_or(1); // Build the share index up front (blocking walk on a worker thread). let shares = Arc::new(ShareIndex::new()); if !config.shared_folders.is_empty() { let shares2 = shares.clone(); let folders = config.shared_folders.clone(); let (dirs, files) = tokio::task::spawn_blocking(move || shares2.rebuild(&folders)) .await .unwrap_or((0, 0)); log::info!("share index: {files} files in {dirs} folders"); } let inner = Arc::new(Inner { server_tx, event_tx, username: Mutex::new(String::new()), active_searches: Mutex::new(HashSet::new()), pending_login: Mutex::new(None), token_counter: AtomicU32::new(seed), listen_port, downloads_dir: config.downloads_dir.clone(), pending_addresses: Mutex::new(HashMap::new()), address_cache: Mutex::new(HashMap::new()), pending_pierce: Mutex::new(HashMap::new()), expected_transfers: Mutex::new(HashMap::new()), peers: Mutex::new(HashMap::new()), transfer_waits: Mutex::new(HashMap::new()), upload_offers: Mutex::new(HashMap::new()), downloads_by_file: Mutex::new(HashMap::new()), download_counter: AtomicU64::new(1), shares, excluded_phrases: RwLock::new(Vec::new()), description: config.description.clone(), upload_slots: config.upload_slots, uploads_active: AtomicU32::new(0), upload_queue: Mutex::new(VecDeque::new()), upload_speed: AtomicU32::new(0), upload_counter: AtomicU64::new(1), has_parent: AtomicBool::new(false), answered_searches: Mutex::new(HashSet::new()), searches_seen: AtomicU64::new(0), }); conn::spawn_server_io(stream, server_rx, shutdown_rx, inner.clone()); conn::spawn_listener(listener, inner.clone(), listener_shutdown_rx); // Best-effort UPnP mapping for direct connectivity (non-fatal). tokio::spawn(upnp::try_map_port(listen_port)); Ok(( Client { inner, shutdown_tx, listener_shutdown_tx, }, event_rx, )) } /// Log in; resolves when the server answers. pub async fn login(&self, username: &str, password: &str) -> Result { let (tx, rx) = oneshot::channel(); *self.inner.pending_login.lock().unwrap() = Some(tx); *self.inner.username.lock().unwrap() = username.to_string(); self.inner.send_server(server::login(username, password))?; let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx) .await .map_err(|_| ClientError::Timeout)? .map_err(|_| ClientError::NotConnected)?; match outcome { Ok(greeting) => { // Post-login bookkeeping the server expects from clients. self.inner .send_server(server::set_listen_port(self.inner.listen_port as u32))?; let (dirs, files) = self.inner.shares.counts(); self.inner .send_server(server::shared_folders_and_files(dirs, files))?; // Join the distributed network as a child so we receive // relayed searches to answer. self.inner.send_server(server::have_no_parents(true))?; self.inner .send_server(server::branch_root(&self.inner.own_username()))?; self.inner.send_server(server::branch_level(0))?; self.inner.send_server(server::accept_children(false))?; Ok(greeting) } Err(reason) => Err(ClientError::LoginFailed(reason)), } } /// Re-index the shared folders and report the new counts to the server. pub async fn update_shares(&self, folders: Vec) -> (u32, u32) { let shares = self.inner.shares.clone(); let (dirs, files) = tokio::task::spawn_blocking(move || shares.rebuild(&folders)) .await .unwrap_or((0, 0)); let _ = self .inner .send_server(server::shared_folders_and_files(dirs, files)); (dirs, files) } /// Current (folders, files) share counts. pub fn share_counts(&self) -> (u32, u32) { self.inner.shares.counts() } /// How many relayed searches we've received (whether or not matched). pub fn searches_seen(&self) -> u64 { self.inner.searches_seen.load(Ordering::Relaxed) } /// Seed a peer's endpoint, bypassing server address resolution. Useful /// for LAN peers and for tests where two instances share one public IP. pub fn set_peer_address(&self, username: &str, addr: SocketAddrV4) { self.inner .address_cache .lock() .unwrap() .insert(username.to_string(), addr); } /// Whether we currently hold a distributed parent connection. pub fn has_parent(&self) -> bool { self.inner.has_parent.load(Ordering::Relaxed) } /// Start a network-wide file search. Results stream out as /// [`Event::SearchResult`] carrying this token. pub fn search(&self, query: &str) -> Result { let token = self.inner.next_token(); self.inner.active_searches.lock().unwrap().insert(token); self.inner.send_server(server::file_search(token, query))?; Ok(token) } /// Stop routing results for a search token. pub fn stop_search(&self, token: u32) { self.inner.active_searches.lock().unwrap().remove(&token); } /// Start downloading `filename` (full remote path) from `username`. /// Progress streams out as [`Event::Download`]. Returns the download id. pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 { transfer::start_download(self.inner.clone(), username, filename, size, None) } /// Like [`download`](Self::download) but writing to an explicit local /// path (used for stream-to-cache). pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 { transfer::start_download(self.inner.clone(), username, filename, size, Some(target)) } /// Cancel an active download by id. pub fn cancel_download(&self, id: u64) { let map = self.inner.downloads_by_file.lock().unwrap(); for (did, cancel) in map.values() { if *did == id { cancel.cancel(); break; } } } pub fn listen_port(&self) -> u16 { self.inner.listen_port } pub fn shutdown(&self) { let _ = self.shutdown_tx.send(()); let _ = self.listener_shutdown_tx.send(()); } }