diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index 987ae66..c10985a 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -2,7 +2,7 @@ use super::{ Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, DownloadStatus, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, - TcpStream, debug, error, info, mpsc, thread, trace, warn, + TcpStream, debug, error, info, mpsc, thread, trace, }; impl Client { @@ -46,6 +46,7 @@ impl Client { let shared_folder_count = shares.folder_count(); let shared_file_count = shares.file_count(); ctx.shares = shares; + ctx.shared_directories = self.shared_directories.clone(); let server_actor = ServerActor::new( self.address.clone(), diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 9e04cf2..cbfb62b 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -202,6 +202,8 @@ pub struct ClientContext { pending_connect_tokens: HashMap, /// Files we share with peers (read-only after connect). pub shares: Arc, + /// The directories the current share index was built from. + pub shared_directories: Vec, /// Peer listen addresses learned from GetPeerAddress responses. peer_addresses: HashMap, /// Peer messages waiting for a control connection to that peer. @@ -501,6 +503,7 @@ impl ClientContext { private_messages: Vec::new(), pending_connect_tokens: HashMap::new(), shares: Arc::new(Shares::empty()), + shared_directories: Vec::new(), peer_addresses: HashMap::new(), pending_peer_messages: HashMap::new(), uploads: HashMap::new(), @@ -645,10 +648,51 @@ impl Client { } } - /// The directories whose files are shared with other peers. + /// The directories whose files are currently shared with other peers. #[must_use] - pub fn shared_directories(&self) -> &[String] { - &self.shared_directories + pub fn shared_directories(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shared_directories.clone()) + .unwrap_or_default() + } + + /// Replace the shared directories at runtime: rescan into a fresh + /// index (served to peers from then on) and re-announce the new + /// folder/file counts to the server. + /// + /// # Errors + /// Returns [`SoulseekRs::NotConnected`] if the client is not connected. + pub fn set_shared_directories(&self, dirs: Vec) -> Result<()> { + let roots: Vec = dirs + .iter() + .filter(|dir| !dir.trim().is_empty()) + .map(std::path::PathBuf::from) + .collect(); + let shares = if roots.is_empty() { + Shares::empty() + } else { + Shares::scan_many(&roots) + }; + info!( + "Now sharing {} files in {} folders from {} directories", + shares.file_count(), + shares.folder_count(), + roots.len() + ); + let folder_count = shares.folder_count(); + let file_count = shares.file_count(); + { + let mut ctx = self.context.write_safe()?; + ctx.shares = Arc::new(shares); + ctx.shared_directories = dirs; + } + self.send_server_message( + crate::message::server::MessageFactory::build_shared_folders_message( + folder_count, + file_count, + ), + ) } } diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index e7fbb65..609948b 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -28,7 +28,7 @@ impl Client { /// Send a raw server message via the server actor, mapping a dead channel /// to [`SoulseekRs::NotConnected`]. - fn send_server_message( + pub(super) fn send_server_message( &self, message: crate::message::Message, ) -> Result<()> { diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index a14d539..9d100f2 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -46,7 +46,12 @@ impl Shares { let mut files = Vec::new(); let mut folder_count = 0; - scan_root(root, &root_display_name(root), &mut files, &mut folder_count); + scan_root( + root, + &root_display_name(root), + &mut files, + &mut folder_count, + ); Ok(Self::from_files(files, folder_count)) } @@ -278,8 +283,7 @@ mod tests { fn scan_many_merges_roots() { let root_a = temp_tree(); let root_b = temp_tree(); - let shares = - Shares::scan_many(&[root_a.clone(), root_b.clone()]); + let shares = Shares::scan_many(&[root_a.clone(), root_b.clone()]); assert_eq!(shares.file_count(), 6); assert_eq!(shares.folder_count(), 4); // Files from both roots are reachable under their root's name. @@ -303,8 +307,7 @@ mod tests { std::fs::write(music_a.join("a.mp3"), b"a").unwrap(); std::fs::write(music_b.join("b.mp3"), b"b").unwrap(); - let shares = - Shares::scan_many(&[music_a.clone(), music_b.clone()]); + let shares = Shares::scan_many(&[music_a.clone(), music_b.clone()]); assert!(shares.get("music\\a.mp3").is_some()); assert!( shares.get("music (2)\\b.mp3").is_some(), diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 6be0b81..10bea24 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -1249,6 +1249,62 @@ fn two_real_clients_search_and_download() { let _ = std::fs::remove_dir_all(download_dir); } +#[test] +fn a_runtime_share_update_is_visible_to_browsers() { + let server = server_or_skip!(); + + // The sharer starts sharing NOTHING, then adds a directory at runtime + // (what the TUI settings screen does). + let sharer_port = free_port().expect("sharer port"); + let mut sharer = Client::with_settings(server.listening_settings( + "e2e_reshare", + "pw", + sharer_port, + )); + sharer.connect().expect("sharer connect"); + assert!(sharer.login().expect("sharer login")); + assert!(sharer.shared_directories().is_empty()); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("new")).unwrap(); + std::fs::write(share_dir.join("new").join("late.mp3"), b"yyyy").unwrap(); + sharer + .set_shared_directories(vec![share_dir.display().to_string()]) + .expect("runtime share update"); + assert_eq!(sharer.shared_directories().len(), 1); + + let browser_port = free_port().expect("browser port"); + let mut browser = Client::with_settings(server.listening_settings( + "e2e_reshare_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_reshare").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_reshare") { + 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 == "late.mp3")), + "the listing should include the file shared at runtime" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + #[test] fn browse_a_peers_shared_files() { let server = server_or_skip!(); diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render.rs index a041e87..d89b089 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render.rs @@ -354,7 +354,7 @@ impl MainTui { let shortcuts_line = format_shortcuts_styled(&shortcuts); // Surface our own sharing status in the block title. let shared = self.client.shared_directories(); - let sharing = match shared { + let sharing = match shared.as_slice() { [] => "off".to_string(), [only] => only.clone(), more => format!("{} folders", more.len()),