From e567fa75ed3de070f3b9f61eb3986e8d1aba2357 Mon Sep 17 00:00:00 2001 From: Maik Steiger Date: Thu, 8 Oct 2026 21:59:23 +0200 Subject: [PATCH 1/4] feat(tui): added option for storing downloaded files in username directories --- soulseek-rs-lib/examples/stress.rs | 10 +++++++--- soulseek-rs-lib/src/client/downloads.rs | 4 ++++ soulseek-rs-lib/src/client/tests/mod.rs | 1 + soulseek-rs-lib/src/client/tests/transfers.rs | 1 + soulseek-rs-lib/src/download_store.rs | 1 + soulseek-rs-lib/src/peer/download_peer.rs | 5 +++++ soulseek-rs-lib/src/peer/listen.rs | 1 + soulseek-rs-lib/src/types.rs | 1 + soulseek-rs/src/api.rs | 14 +++++++++++++- soulseek-rs/src/commands/transfer.rs | 1 + soulseek-rs/src/daemon/mod.rs | 1 + soulseek-rs/src/daemon/proto/dto.rs | 2 ++ soulseek-rs/src/daemon/server/mod.rs | 4 ++++ soulseek-rs/src/models/settings.rs | 11 +++++++++++ soulseek-rs/src/persist/config.rs | 19 +++++++++++++++++++ soulseek-rs/src/persist/snapshot.rs | 1 + soulseek-rs/src/persist/state.rs | 3 +++ soulseek-rs/src/remote/mod.rs | 3 +++ soulseek-rs/src/run.rs | 2 ++ soulseek-rs/src/ui/downloads.rs | 1 + soulseek-rs/src/ui/main_tui/browse.rs | 2 ++ soulseek-rs/src/ui/main_tui/downloads.rs | 11 ++++++++++- soulseek-rs/src/ui/main_tui/mod.rs | 16 ++++++++++++++-- soulseek-rs/src/ui/main_tui/render/mod.rs | 10 ++++++++++ .../src/ui/main_tui/render/shortcuts.rs | 1 + soulseek-rs/src/ui/main_tui/settings.rs | 3 +++ soulseek-rs/src/ui/main_tui/tests/mod.rs | 4 ++++ soulseek-rs/src/ui/main_tui/tests/settings.rs | 2 ++ soulseek-rs/src/ui/panes/browse_pane.rs | 1 + .../src/ui/panes/download_info_pane.rs | 1 + 30 files changed, 130 insertions(+), 7 deletions(-) diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 31d086e..c216ce1 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -1148,9 +1148,13 @@ fn main() { let completed = completed.clone(); workers.push(std::thread::spawn(move || { Metrics::bump(&metrics.downloads_issued); - let Ok((_download, status_rx)) = - client.download(filename.clone(), peer.clone(), size, dir) - else { + let Ok((_download, status_rx)) = client.download( + filename.clone(), + peer.clone(), + size, + dir, + false, + ) else { return; }; let deadline = run_deadline; diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs index e47db5d..b9074eb 100644 --- a/soulseek-rs-lib/src/client/downloads.rs +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -99,12 +99,14 @@ impl Client { username: String, size: u64, download_directory: String, + use_username_dirs: bool, ) -> Result<(Download, Receiver)> { self.download_with_metadata( filename, username, size, download_directory, + use_username_dirs, DownloadMetadata::default(), ) } @@ -115,6 +117,7 @@ impl Client { username: String, size: u64, download_directory: String, + use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { info!("[client] Downloading {} from {}", filename, username); @@ -132,6 +135,7 @@ impl Client { token, size, download_directory, + use_username_dirs, status: DownloadStatus::Queued, sender: download_sender, queue_position: None, diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs index 0862c05..6d236ad 100644 --- a/soulseek-rs-lib/src/client/tests/mod.rs +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -23,6 +23,7 @@ fn download( token, size: 100, download_directory: "test".to_string(), + use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs index dbabdd3..fa49269 100644 --- a/soulseek-rs-lib/src/client/tests/transfers.rs +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -97,6 +97,7 @@ fn download_without_a_connection_resolves_failed() { "peer".to_string(), 100, "test".to_string(), + false, ) .expect("download() should return a handle"); assert!(matches!( diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs index 02f274a..6b8b041 100644 --- a/soulseek-rs-lib/src/download_store.rs +++ b/soulseek-rs-lib/src/download_store.rs @@ -242,6 +242,7 @@ mod tests { token, size: 100, download_directory: "test".to_string(), + use_username_dirs: false, status, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index fd2e231..5d19499 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -110,6 +110,10 @@ fn resolve_download_path(download: &Download) -> Result { .to_path_buf(); } + if download.use_username_dirs { + expanded_path = expanded_path.join(download.username.as_str()); + } + let final_path = expanded_path.join(extract_filename_from_path(&download.filename)); @@ -635,6 +639,7 @@ mod tests { token: 1, size, download_directory: dir.display().to_string(), + use_username_dirs: false, status: DownloadStatus::Queued, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index beb1f64..3890649 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -479,6 +479,7 @@ mod tests { token, size: 100, download_directory: std::env::temp_dir().display().to_string(), + use_username_dirs: false, status: DownloadStatus::Queued, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 8fa98e1..a34ac4b 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -185,6 +185,7 @@ pub struct Download { pub token: u32, pub size: u64, pub download_directory: String, + pub use_username_dirs: bool, pub status: DownloadStatus, pub sender: Sender, pub queue_position: Option, diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index e3a10ba..328000b 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -102,6 +102,7 @@ pub trait SessionApi: Send + Sync { username: String, size: u64, download_directory: String, + use_username_dirs: bool, ) -> Result<(Download, Receiver)>; fn download_with_metadata( &self, @@ -109,6 +110,7 @@ pub trait SessionApi: Send + Sync { username: String, size: u64, download_directory: String, + use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)>; fn get_all_downloads(&self) -> Vec; @@ -271,8 +273,16 @@ impl SessionApi for Client { username: String, size: u64, download_directory: String, + use_username_dirs: bool, ) -> Result<(Download, Receiver)> { - Self::download(self, filename, username, size, download_directory) + Self::download( + self, + filename, + username, + size, + download_directory, + use_username_dirs, + ) } fn download_with_metadata( @@ -281,6 +291,7 @@ impl SessionApi for Client { username: String, size: u64, download_directory: String, + use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { Self::download_with_metadata( @@ -289,6 +300,7 @@ impl SessionApi for Client { username, size, download_directory, + use_username_dirs, metadata, ) } diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index b97b054..c96da0c 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -518,6 +518,7 @@ fn download_one( request.user.clone(), request.size, download_dir.to_string(), + false, ) .map_err(|e| CliError::transfer(format!("cannot start: {e}")))?; diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 43453d9..6fb086b 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -441,6 +441,7 @@ fn restore( download.username.clone(), download.size, download.download_directory.clone(), + download.use_username_dirs, ) { Ok((_, updates)) => { if let Ok(mut pending) = downloads.lock() { diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 2598859..514429e 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -220,6 +220,7 @@ pub struct DownloadDto { pub token: u32, pub size: u64, pub download_directory: String, + pub use_username_dirs: bool, pub status: DownloadStatusDto, #[serde(default)] pub queue_position: Option, @@ -235,6 +236,7 @@ impl From<&Download> for DownloadDto { token: download.token, size: download.size, download_directory: download.download_directory.clone(), + use_username_dirs: download.use_username_dirs, status: DownloadStatusDto::from(&download.status), queue_position: download.queue_position, metadata: DownloadMetadataDto::from(&download.metadata), diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 201a6d0..5f2a7a7 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -436,6 +436,7 @@ impl Daemon { params.username.clone(), params.size, directory, + false, params.metadata.into(), ) .map_err(|e| { @@ -876,6 +877,7 @@ mod tests { username: String, size: u64, directory: String, + use_username_dirs: bool, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, Receiver, @@ -885,6 +887,7 @@ mod tests { username, size, directory, + use_username_dirs, soulseek_rs::types::DownloadMetadata::default(), ) } @@ -894,6 +897,7 @@ mod tests { _username: String, _size: u64, _directory: String, + _use_username_dirs: bool, _metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs index d892d99..d442ed3 100644 --- a/soulseek-rs/src/models/settings.rs +++ b/soulseek-rs/src/models/settings.rs @@ -38,6 +38,7 @@ impl SettingsMode { pub enum SettingsRow { ChangePassword, Logout, + UseUsernameDirs, DownloadDir, /// Share path at this index. Share(usize), @@ -79,6 +80,7 @@ pub struct SettingsState { pub account: AccountInfo, pub download_dir: String, pub share_dirs: Vec, + pub use_username_dirs: bool, /// Index into [`Self::rows`]. pub selected: usize, pub mode: SettingsMode, @@ -94,11 +96,13 @@ impl SettingsState { account: AccountInfo, download_dir: String, share_dirs: Vec, + use_username_dirs: bool, ) -> Self { Self { account, download_dir, share_dirs, + use_username_dirs, selected: 0, mode: SettingsMode::Navigate, input: String::new(), @@ -119,6 +123,7 @@ impl SettingsState { if self.can_log_out() { rows.push(SettingsRow::Logout); } + rows.push(SettingsRow::UseUsernameDirs); rows.push(SettingsRow::DownloadDir); rows.extend((0..self.share_dirs.len()).map(SettingsRow::Share)); rows @@ -191,6 +196,10 @@ impl SettingsState { self.input.clear(); } SettingsRow::Logout => self.mode = SettingsMode::ConfirmingLogout, + SettingsRow::UseUsernameDirs => { + self.use_username_dirs = !self.use_username_dirs; + return SettingsAction::Apply; + } SettingsRow::DownloadDir => { self.mode = SettingsMode::EditingDownloadDir; self.input.clone_from(&self.download_dir); @@ -300,6 +309,7 @@ mod tests { account(), "/dl".into(), vec!["/dl".into(), "/music".into()], + false, ) } @@ -493,6 +503,7 @@ mod tests { }, "/dl".into(), vec!["/music".into()], + false, ); assert_eq!( s.rows(), diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 6dd6f8c..bde67f9 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -40,6 +40,7 @@ pub struct FileConfig { /// A TOML array rather than a `config set` key, because a query may itself /// contain a comma. pub wishlist: Option>, + pub use_username_dirs: Option, } impl FileConfig { @@ -59,6 +60,7 @@ impl FileConfig { "password_cmd", "daemon", "daemon_token", + "use_username_dirs", ]; /// The value of `key`, or `None` when it is unset or unknown. Lists are @@ -83,6 +85,9 @@ impl FileConfig { "password_cmd" => self.password_cmd.clone(), "daemon" => self.daemon.clone(), "daemon_token" => self.daemon_token.clone(), + "use_username_dirs" => { + self.use_username_dirs.clone().map(|v| v.to_string()) + } _ => None, } } @@ -162,6 +167,17 @@ impl FileConfig { // The single-folder spelling would otherwise shadow the list. self.shared_dir = None; } + "use_username_dirs" => { + self.use_username_dirs = Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "use_username_dirs wants true or false, got '{other}'" + )); + } + }) + } other => { return Err(format!( "unknown setting '{other}' — try one of: {}", @@ -277,6 +293,7 @@ pub struct Resolved { pub accept_children: bool, pub download_dir: String, pub shared_dirs: Vec, + pub use_username_dirs: bool, pub max_concurrent_downloads: usize, pub search_timeout: u64, pub password_cmd: Option, @@ -330,6 +347,7 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { && !disable_listener, download_dir: download_dir.clone(), shared_dirs: resolve_shared_dirs(cli, file, &download_dir), + use_username_dirs: file.use_username_dirs.unwrap_or(false), max_concurrent_downloads: cli .max_concurrent_downloads .or(file.max_concurrent_downloads) @@ -573,6 +591,7 @@ mod tests { daemon: Some("nas.local:5030".into()), daemon_token: Some("deadbeef".into()), wishlist: None, + use_username_dirs: Some(false), }; let resolved = resolve(&bare_cli(), &file); assert_eq!(resolved.username.as_deref(), Some("alice")); diff --git a/soulseek-rs/src/persist/snapshot.rs b/soulseek-rs/src/persist/snapshot.rs index 554f3c8..1c8429a 100644 --- a/soulseek-rs/src/persist/snapshot.rs +++ b/soulseek-rs/src/persist/snapshot.rs @@ -126,6 +126,7 @@ mod tests { token: 7, size: 42, download_directory: "/music".into(), + use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/persist/state.rs b/soulseek-rs/src/persist/state.rs index ffb6c54..dcb7658 100644 --- a/soulseek-rs/src/persist/state.rs +++ b/soulseek-rs/src/persist/state.rs @@ -22,6 +22,7 @@ pub struct PersistedDownload { pub filename: String, pub size: u64, pub download_directory: String, + pub use_username_dirs: bool, pub completed: bool, } @@ -36,6 +37,7 @@ impl PersistedDownload { filename: download.filename.clone(), size: download.size, download_directory: download.download_directory.clone(), + use_username_dirs: download.use_username_dirs, completed: matches!(download.status, DownloadStatus::Completed), }) } @@ -182,6 +184,7 @@ mod tests { filename: "@@abc\\music\\song.mp3".into(), size: 123, download_directory: "/music".into(), + use_username_dirs: false, completed: false, } } diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 6339eb1..923ba14 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -367,6 +367,7 @@ fn rebuild(dto: DownloadDto, sender: Sender) -> Download { token: dto.token, size: dto.size, download_directory: dto.download_directory, + use_username_dirs: dto.use_username_dirs, status: dto.status.into(), sender, queue_position: dto.queue_position, @@ -578,6 +579,7 @@ impl SessionApi for RemoteSession { username: String, size: u64, _download_directory: String, + _use_username_dirs: bool, // the daemon's own download directory wins — a remote caller // cannot see the filesystem the bytes land on. Plumb a per-transfer // directory through if a client ever needs to sort into subfolders. @@ -596,6 +598,7 @@ impl SessionApi for RemoteSession { username: String, size: u64, _download_directory: String, + _use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { self.start_download(filename, username, size, metadata) diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 0d653b3..89f15b4 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -522,6 +522,7 @@ fn run_default_tui( terminal, Arc::new(outcome.client), resolved.download_dir.clone(), + resolved.use_username_dirs, Duration::from_secs(resolved.search_timeout), store, config_path.clone(), @@ -598,6 +599,7 @@ fn run_attached_tui( terminal, Arc::new(session), download_dir, + resolved.use_username_dirs, Duration::from_secs(resolved.search_timeout), None, config_path, diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 24eec71..3f1e1bf 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -177,6 +177,7 @@ mod tests { token: 1, size, download_directory: "/tmp".to_string(), + use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 2727014..bb21aa0 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -221,6 +221,7 @@ impl MainTui { let sender = self.downloads_sender(); let client = self.client.clone(); let download_dir = self.download_dir.clone(); + let use_username_dirs = self.use_username_dirs; thread::spawn(move || { for (path, size) in files { match client.download( @@ -228,6 +229,7 @@ impl MainTui { username.clone(), size, download_dir.clone(), + use_username_dirs, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index 8e650a0..c4d1e1b 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -48,9 +48,16 @@ impl MainTui { self.state.downloads.remove(index); self.select_download_after_removal(index); let _ = self.client.remove_download(&username, &filename); + let use_username_dirs = self.use_username_dirs; thread::spawn(move || { - match client.download(filename.clone(), username, size, directory) { + match client.download( + filename.clone(), + username, + size, + directory, + use_username_dirs, + ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); } @@ -197,6 +204,7 @@ impl MainTui { let sender = self.downloads_sender(); let client = self.client.clone(); let download_dir = self.download_dir.clone(); + let use_username_dirs = self.use_username_dirs; thread::spawn(move || { for file in selected_files { @@ -211,6 +219,7 @@ impl MainTui { file.username.clone(), file.size, download_dir.clone(), + use_username_dirs, metadata, ) { Ok((download, rx)) => { diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index cf5d31e..c38f140 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -23,6 +23,7 @@ pub struct MainTui { client: Arc, state: AppState, download_dir: String, + use_username_dirs: bool, search_timeout: Duration, spinner_state: usize, store: Option, @@ -40,6 +41,7 @@ impl MainTui { pub fn new( client: Arc, download_dir: String, + use_username_dirs: bool, search_timeout: Duration, store: Option, config_path: Option, @@ -49,6 +51,7 @@ impl MainTui { state: AppState::new(), download_dir, search_timeout, + use_username_dirs, spinner_state: 0, store, config_path, @@ -118,6 +121,7 @@ impl MainTui { token: 0, size: entry.size, download_directory: entry.download_directory, + use_username_dirs: entry.use_username_dirs, status: soulseek_rs::DownloadStatus::Completed, sender: std::sync::mpsc::channel().0, queue_position: None, @@ -135,6 +139,7 @@ impl MainTui { entry.username, entry.size, entry.download_directory, + entry.use_username_dirs, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); @@ -256,13 +261,20 @@ pub fn launch_main_tui( terminal: DefaultTerminal, client: Arc, download_dir: String, + use_username_dirs: bool, search_timeout: Duration, store: Option, config_path: Option, listener_fallback: Option<(u16, u16)>, ) -> Result { - let mut tui = - MainTui::new(client, download_dir, search_timeout, store, config_path); + let mut tui = MainTui::new( + client, + download_dir, + use_username_dirs, + search_timeout, + store, + config_path, + ); tui.listener_fallback = listener_fallback; tui.run(terminal) } diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs index 98cd750..66bfb2c 100644 --- a/soulseek-rs/src/ui/main_tui/render/mod.rs +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -369,6 +369,16 @@ impl MainTui { lines.push(Line::from("")); lines.push(section("Folders")); + lines.push(entry( + selected == SettingsRow::UseUsernameDirs, + "Use username directory ", + if settings.use_username_dirs { + "[x]" + } else { + "[ ]" + } + .to_string(), + )); lines.push(if settings.mode == SettingsMode::EditingDownloadDir { typing("Download folder: ", settings.input.clone()) } else { diff --git a/soulseek-rs/src/ui/main_tui/render/shortcuts.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs index 3c96557..f089f62 100644 --- a/soulseek-rs/src/ui/main_tui/render/shortcuts.rs +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -166,6 +166,7 @@ fn settings_shortcuts( SettingsRow::ChangePassword => ("Enter", "change password"), SettingsRow::Logout => ("Enter", "log out"), SettingsRow::DownloadDir => ("Enter", "edit folder"), + SettingsRow::UseUsernameDirs => ("Enter", "toggle option"), SettingsRow::Share(_) => ("d", "remove share"), }, ("a", "add share"), diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 40717bc..650d086 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -20,6 +20,7 @@ impl MainTui { account, self.download_dir.clone(), self.client.shared_directories(), + self.use_username_dirs.clone(), )); } @@ -66,6 +67,7 @@ impl MainTui { }; let download_dir = settings.download_dir.clone(); let share_dirs = settings.share_dirs.clone(); + let use_username_dirs = settings.use_username_dirs; let shares = if self.client.daemon_endpoint().is_some() { share_dirs.clone() @@ -121,6 +123,7 @@ impl MainTui { // load, so leaving it would resurrect a removed folder. config.shared_dir = None; config.shared_dirs = Some(share_dirs); + config.use_username_dirs = Some(use_username_dirs); config.save(&path) }); if let Err(e) = result { diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs index b203767..96420a3 100644 --- a/soulseek-rs/src/ui/main_tui/tests/mod.rs +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -84,6 +84,7 @@ fn at_status( token: 1, size: 4096, download_directory: "/tmp".to_string(), + use_username_dirs: false, status, sender, queue_position: None, @@ -160,6 +161,7 @@ impl SessionApi for TalkativeSession { _username: String, _size: u64, _directory: String, + _use_username_dirs: bool, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, @@ -172,6 +174,7 @@ impl SessionApi for TalkativeSession { _username: String, _size: u64, _directory: String, + _use_username_dirs: bool, _metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, @@ -349,6 +352,7 @@ fn attach(session: Arc) -> MainTui { MainTui::new( session, "/tmp".to_string(), + false, Duration::from_secs(1), None, None, diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs index 795267b..10b2a69 100644 --- a/soulseek-rs/src/ui/main_tui/tests/settings.rs +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -17,6 +17,7 @@ fn applying_settings_attached_pushes_both_folders_to_the_daemon() { let mut tui = MainTui::new( session.clone(), "/daemon/old".to_string(), + false, Duration::from_secs(1), None, Some(config_path.clone()), @@ -73,6 +74,7 @@ fn applying_settings_locally_stays_on_this_machine() { let mut tui = MainTui::new( session.clone(), "/tmp".to_string(), + false, Duration::from_secs(1), None, Some(config_path), diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index cc95259..ad3ca08 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -339,6 +339,7 @@ mod tests { token: 1, size: 4096, download_directory: "/tmp".to_string(), + use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs index d90c480..a006f51 100644 --- a/soulseek-rs/src/ui/panes/download_info_pane.rs +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -436,6 +436,7 @@ mod tests { token: 1, size: 100, download_directory: "~/dl".to_string(), + use_username_dirs: false, status, sender, queue_position: None, From 5e979a742544152427fa8e9a5e13eac6467be163 Mon Sep 17 00:00:00 2001 From: Maik Steiger Date: Thu, 8 Oct 2026 22:07:13 +0200 Subject: [PATCH 2/4] test(tui): cover username directories setting and regenerate openrpc doc --- soulseek-rs/docs/openrpc.json | 4 ++++ soulseek-rs/src/models/settings.rs | 3 +++ soulseek-rs/src/ui/main_tui/tests/settings.rs | 1 + soulseek-rs/tests/persist_e2e.rs | 1 + soulseek-rs/tests/tui_e2e.rs | 1 + 5 files changed, 10 insertions(+) diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index cbf59a6..aaab7dd 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -240,6 +240,9 @@ "minimum": 0, "type": "integer" }, + "use_username_dirs": { + "type": "boolean" + }, "username": { "type": "string" } @@ -250,6 +253,7 @@ "token", "size", "download_directory", + "use_username_dirs", "status" ], "type": "object" diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs index d442ed3..90121cd 100644 --- a/soulseek-rs/src/models/settings.rs +++ b/soulseek-rs/src/models/settings.rs @@ -509,6 +509,7 @@ mod tests { s.rows(), vec![ SettingsRow::ChangePassword, + SettingsRow::UseUsernameDirs, SettingsRow::DownloadDir, SettingsRow::Share(0), ] @@ -516,6 +517,8 @@ mod tests { // The row below "change password" is the download folder, not a // logout the window cannot perform. s.handle_key(key(KeyCode::Down)); + assert_eq!(s.selected_row(), SettingsRow::UseUsernameDirs); + s.handle_key(key(KeyCode::Down)); assert_eq!(s.selected_row(), SettingsRow::DownloadDir); } } diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs index 10b2a69..0a419d3 100644 --- a/soulseek-rs/src/ui/main_tui/tests/settings.rs +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -192,6 +192,7 @@ fn an_attached_window_offers_no_logout() { let mut tui = attach(session); press(&mut tui, KeyCode::Char('o')); press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Down); press(&mut tui, KeyCode::Enter); let settings = tui.state.settings.as_ref().expect("still open"); diff --git a/soulseek-rs/tests/persist_e2e.rs b/soulseek-rs/tests/persist_e2e.rs index c678b23..559518b 100644 --- a/soulseek-rs/tests/persist_e2e.rs +++ b/soulseek-rs/tests/persist_e2e.rs @@ -17,6 +17,7 @@ fn download_entry(filename: &str, status: DownloadStatus) -> DownloadEntry { token: 1, size: 1000, download_directory: "/music".into(), + use_username_dirs: false, status, sender: std::sync::mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs/tests/tui_e2e.rs b/soulseek-rs/tests/tui_e2e.rs index 10db6b1..3b265b4 100644 --- a/soulseek-rs/tests/tui_e2e.rs +++ b/soulseek-rs/tests/tui_e2e.rs @@ -56,6 +56,7 @@ impl Window { let tui = MainTui::new( client, downloads.display().to_string(), + false, SEARCH_WINDOW, Some(StateStore::new(state.to_path_buf())), None, From 9805b807e577bd53064060634a033e686608e980 Mon Sep 17 00:00:00 2001 From: Maik Steiger Date: Fri, 9 Oct 2026 13:33:22 +0200 Subject: [PATCH 3/4] fix(tui): removed a ton of use_username_dirs references feat(tui): username is now appended to download_dir before passed as parameter fix(tui): username is sanitized before appended to path --- soulseek-rs-lib/examples/stress.rs | 10 ++---- soulseek-rs-lib/src/client/downloads.rs | 4 --- soulseek-rs-lib/src/client/tests/mod.rs | 1 - soulseek-rs-lib/src/client/tests/transfers.rs | 1 - soulseek-rs-lib/src/download_store.rs | 1 - soulseek-rs-lib/src/peer/download_peer.rs | 5 --- soulseek-rs-lib/src/peer/listen.rs | 1 - soulseek-rs-lib/src/types.rs | 1 - soulseek-rs/docs/openrpc.json | 4 --- soulseek-rs/src/api.rs | 14 +------- soulseek-rs/src/commands/transfer.rs | 1 - soulseek-rs/src/daemon/mod.rs | 1 - soulseek-rs/src/daemon/proto/dto.rs | 2 -- soulseek-rs/src/daemon/server/mod.rs | 4 --- soulseek-rs/src/persist/config.rs | 2 +- soulseek-rs/src/persist/snapshot.rs | 1 - soulseek-rs/src/persist/state.rs | 3 -- soulseek-rs/src/remote/mod.rs | 3 -- soulseek-rs/src/ui/downloads.rs | 1 - soulseek-rs/src/ui/main_tui/browse.rs | 2 -- soulseek-rs/src/ui/main_tui/downloads.rs | 24 +++++++------ soulseek-rs/src/ui/main_tui/mod.rs | 17 +++++++-- soulseek-rs/src/ui/main_tui/settings.rs | 2 +- soulseek-rs/src/ui/main_tui/tests/mod.rs | 3 -- soulseek-rs/src/ui/panes/browse_pane.rs | 1 - .../src/ui/panes/download_info_pane.rs | 1 - soulseek-rs/src/ui/utils.rs | 35 +++++++++++++++++++ soulseek-rs/tests/persist_e2e.rs | 1 - 28 files changed, 69 insertions(+), 77 deletions(-) diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index c216ce1..31d086e 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -1148,13 +1148,9 @@ fn main() { let completed = completed.clone(); workers.push(std::thread::spawn(move || { Metrics::bump(&metrics.downloads_issued); - let Ok((_download, status_rx)) = client.download( - filename.clone(), - peer.clone(), - size, - dir, - false, - ) else { + let Ok((_download, status_rx)) = + client.download(filename.clone(), peer.clone(), size, dir) + else { return; }; let deadline = run_deadline; diff --git a/soulseek-rs-lib/src/client/downloads.rs b/soulseek-rs-lib/src/client/downloads.rs index b9074eb..e47db5d 100644 --- a/soulseek-rs-lib/src/client/downloads.rs +++ b/soulseek-rs-lib/src/client/downloads.rs @@ -99,14 +99,12 @@ impl Client { username: String, size: u64, download_directory: String, - use_username_dirs: bool, ) -> Result<(Download, Receiver)> { self.download_with_metadata( filename, username, size, download_directory, - use_username_dirs, DownloadMetadata::default(), ) } @@ -117,7 +115,6 @@ impl Client { username: String, size: u64, download_directory: String, - use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { info!("[client] Downloading {} from {}", filename, username); @@ -135,7 +132,6 @@ impl Client { token, size, download_directory, - use_username_dirs, status: DownloadStatus::Queued, sender: download_sender, queue_position: None, diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs index 6d236ad..0862c05 100644 --- a/soulseek-rs-lib/src/client/tests/mod.rs +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -23,7 +23,6 @@ fn download( token, size: 100, download_directory: "test".to_string(), - use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs index fa49269..dbabdd3 100644 --- a/soulseek-rs-lib/src/client/tests/transfers.rs +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -97,7 +97,6 @@ fn download_without_a_connection_resolves_failed() { "peer".to_string(), 100, "test".to_string(), - false, ) .expect("download() should return a handle"); assert!(matches!( diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs index 6b8b041..02f274a 100644 --- a/soulseek-rs-lib/src/download_store.rs +++ b/soulseek-rs-lib/src/download_store.rs @@ -242,7 +242,6 @@ mod tests { token, size: 100, download_directory: "test".to_string(), - use_username_dirs: false, status, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index 5d19499..fd2e231 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -110,10 +110,6 @@ fn resolve_download_path(download: &Download) -> Result { .to_path_buf(); } - if download.use_username_dirs { - expanded_path = expanded_path.join(download.username.as_str()); - } - let final_path = expanded_path.join(extract_filename_from_path(&download.filename)); @@ -639,7 +635,6 @@ mod tests { token: 1, size, download_directory: dir.display().to_string(), - use_username_dirs: false, status: DownloadStatus::Queued, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 3890649..beb1f64 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -479,7 +479,6 @@ mod tests { token, size: 100, download_directory: std::env::temp_dir().display().to_string(), - use_username_dirs: false, status: DownloadStatus::Queued, sender: mpsc::channel().0, queue_position: None, diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index a34ac4b..8fa98e1 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -185,7 +185,6 @@ pub struct Download { pub token: u32, pub size: u64, pub download_directory: String, - pub use_username_dirs: bool, pub status: DownloadStatus, pub sender: Sender, pub queue_position: Option, diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index aaab7dd..cbf59a6 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -240,9 +240,6 @@ "minimum": 0, "type": "integer" }, - "use_username_dirs": { - "type": "boolean" - }, "username": { "type": "string" } @@ -253,7 +250,6 @@ "token", "size", "download_directory", - "use_username_dirs", "status" ], "type": "object" diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 328000b..e3a10ba 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -102,7 +102,6 @@ pub trait SessionApi: Send + Sync { username: String, size: u64, download_directory: String, - use_username_dirs: bool, ) -> Result<(Download, Receiver)>; fn download_with_metadata( &self, @@ -110,7 +109,6 @@ pub trait SessionApi: Send + Sync { username: String, size: u64, download_directory: String, - use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)>; fn get_all_downloads(&self) -> Vec; @@ -273,16 +271,8 @@ impl SessionApi for Client { username: String, size: u64, download_directory: String, - use_username_dirs: bool, ) -> Result<(Download, Receiver)> { - Self::download( - self, - filename, - username, - size, - download_directory, - use_username_dirs, - ) + Self::download(self, filename, username, size, download_directory) } fn download_with_metadata( @@ -291,7 +281,6 @@ impl SessionApi for Client { username: String, size: u64, download_directory: String, - use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { Self::download_with_metadata( @@ -300,7 +289,6 @@ impl SessionApi for Client { username, size, download_directory, - use_username_dirs, metadata, ) } diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index c96da0c..b97b054 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -518,7 +518,6 @@ fn download_one( request.user.clone(), request.size, download_dir.to_string(), - false, ) .map_err(|e| CliError::transfer(format!("cannot start: {e}")))?; diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 6fb086b..43453d9 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -441,7 +441,6 @@ fn restore( download.username.clone(), download.size, download.download_directory.clone(), - download.use_username_dirs, ) { Ok((_, updates)) => { if let Ok(mut pending) = downloads.lock() { diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 514429e..2598859 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -220,7 +220,6 @@ pub struct DownloadDto { pub token: u32, pub size: u64, pub download_directory: String, - pub use_username_dirs: bool, pub status: DownloadStatusDto, #[serde(default)] pub queue_position: Option, @@ -236,7 +235,6 @@ impl From<&Download> for DownloadDto { token: download.token, size: download.size, download_directory: download.download_directory.clone(), - use_username_dirs: download.use_username_dirs, status: DownloadStatusDto::from(&download.status), queue_position: download.queue_position, metadata: DownloadMetadataDto::from(&download.metadata), diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 5f2a7a7..201a6d0 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -436,7 +436,6 @@ impl Daemon { params.username.clone(), params.size, directory, - false, params.metadata.into(), ) .map_err(|e| { @@ -877,7 +876,6 @@ mod tests { username: String, size: u64, directory: String, - use_username_dirs: bool, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, Receiver, @@ -887,7 +885,6 @@ mod tests { username, size, directory, - use_username_dirs, soulseek_rs::types::DownloadMetadata::default(), ) } @@ -897,7 +894,6 @@ mod tests { _username: String, _size: u64, _directory: String, - _use_username_dirs: bool, _metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index bde67f9..6a4578e 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -86,7 +86,7 @@ impl FileConfig { "daemon" => self.daemon.clone(), "daemon_token" => self.daemon_token.clone(), "use_username_dirs" => { - self.use_username_dirs.clone().map(|v| v.to_string()) + self.use_username_dirs.map(|v| v.to_string()) } _ => None, } diff --git a/soulseek-rs/src/persist/snapshot.rs b/soulseek-rs/src/persist/snapshot.rs index 1c8429a..554f3c8 100644 --- a/soulseek-rs/src/persist/snapshot.rs +++ b/soulseek-rs/src/persist/snapshot.rs @@ -126,7 +126,6 @@ mod tests { token: 7, size: 42, download_directory: "/music".into(), - use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/persist/state.rs b/soulseek-rs/src/persist/state.rs index dcb7658..ffb6c54 100644 --- a/soulseek-rs/src/persist/state.rs +++ b/soulseek-rs/src/persist/state.rs @@ -22,7 +22,6 @@ pub struct PersistedDownload { pub filename: String, pub size: u64, pub download_directory: String, - pub use_username_dirs: bool, pub completed: bool, } @@ -37,7 +36,6 @@ impl PersistedDownload { filename: download.filename.clone(), size: download.size, download_directory: download.download_directory.clone(), - use_username_dirs: download.use_username_dirs, completed: matches!(download.status, DownloadStatus::Completed), }) } @@ -184,7 +182,6 @@ mod tests { filename: "@@abc\\music\\song.mp3".into(), size: 123, download_directory: "/music".into(), - use_username_dirs: false, completed: false, } } diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 923ba14..6339eb1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -367,7 +367,6 @@ fn rebuild(dto: DownloadDto, sender: Sender) -> Download { token: dto.token, size: dto.size, download_directory: dto.download_directory, - use_username_dirs: dto.use_username_dirs, status: dto.status.into(), sender, queue_position: dto.queue_position, @@ -579,7 +578,6 @@ impl SessionApi for RemoteSession { username: String, size: u64, _download_directory: String, - _use_username_dirs: bool, // the daemon's own download directory wins — a remote caller // cannot see the filesystem the bytes land on. Plumb a per-transfer // directory through if a client ever needs to sort into subfolders. @@ -598,7 +596,6 @@ impl SessionApi for RemoteSession { username: String, size: u64, _download_directory: String, - _use_username_dirs: bool, metadata: DownloadMetadata, ) -> Result<(Download, Receiver)> { self.start_download(filename, username, size, metadata) diff --git a/soulseek-rs/src/ui/downloads.rs b/soulseek-rs/src/ui/downloads.rs index 3f1e1bf..24eec71 100644 --- a/soulseek-rs/src/ui/downloads.rs +++ b/soulseek-rs/src/ui/downloads.rs @@ -177,7 +177,6 @@ mod tests { token: 1, size, download_directory: "/tmp".to_string(), - use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index bb21aa0..2727014 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -221,7 +221,6 @@ impl MainTui { let sender = self.downloads_sender(); let client = self.client.clone(); let download_dir = self.download_dir.clone(); - let use_username_dirs = self.use_username_dirs; thread::spawn(move || { for (path, size) in files { match client.download( @@ -229,7 +228,6 @@ impl MainTui { username.clone(), size, download_dir.clone(), - use_username_dirs, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index c4d1e1b..37cf177 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -1,8 +1,10 @@ use super::MainTui; use crate::models::{DownloadEntry, FileDisplayData}; use crate::ui::panes::{InfoSubject, selected_transfer}; +use crate::ui::sanitize_path_component; use soulseek_rs::types::UploadStatus; use soulseek_rs::{DownloadStatus, types::Download}; +use std::path::Path; use std::{sync::mpsc, thread}; impl MainTui { @@ -48,16 +50,9 @@ impl MainTui { self.state.downloads.remove(index); self.select_download_after_removal(index); let _ = self.client.remove_download(&username, &filename); - let use_username_dirs = self.use_username_dirs; thread::spawn(move || { - match client.download( - filename.clone(), - username, - size, - directory, - use_username_dirs, - ) { + match client.download(filename.clone(), username, size, directory) { Ok((download, rx)) => { let _ = sender.send((download, rx)); } @@ -214,12 +209,21 @@ impl MainTui { peer_upload_speed: Some(file.speed), peer_free_slots: Some(file.slots), }; + let download_dir = if use_username_dirs { + let sanitized_username = + sanitize_path_component(file.username.as_str()); + Path::new(download_dir.as_str()) + .join(sanitized_username.as_str()) + .display() + .to_string() + } else { + download_dir.clone() + }; match client.download_with_metadata( file.filename.clone(), file.username.clone(), file.size, - download_dir.clone(), - use_username_dirs, + download_dir, metadata, ) { Ok((download, rx)) => { diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index c38f140..2b0a396 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -12,11 +12,13 @@ use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, state::{PersistedMessage, StateStore}, }; +use crate::ui::sanitize_path_component; use color_eyre::Result; use ratatui::{ DefaultTerminal, crossterm::event::{self, Event, KeyEventKind, poll}, }; +use std::path::Path; use std::{sync::Arc, time::Duration}; pub struct MainTui { @@ -121,7 +123,6 @@ impl MainTui { token: 0, size: entry.size, download_directory: entry.download_directory, - use_username_dirs: entry.use_username_dirs, status: soulseek_rs::DownloadStatus::Completed, sender: std::sync::mpsc::channel().0, queue_position: None, @@ -133,13 +134,23 @@ impl MainTui { } else { let client = self.client.clone(); let sender = sender.clone(); + let use_username_dirs = self.use_username_dirs; std::thread::spawn(move || { + let sanitized_username = + sanitize_path_component(entry.username.as_str()); + let download_dir = if use_username_dirs { + Path::new(entry.download_directory.as_str()) + .join(sanitized_username.as_str()) + .display() + .to_string() + } else { + entry.download_directory + }; match client.download( entry.filename.clone(), entry.username, entry.size, - entry.download_directory, - entry.use_username_dirs, + download_dir, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 650d086..8416dc2 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -20,7 +20,7 @@ impl MainTui { account, self.download_dir.clone(), self.client.shared_directories(), - self.use_username_dirs.clone(), + self.use_username_dirs, )); } diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs index 96420a3..5e74025 100644 --- a/soulseek-rs/src/ui/main_tui/tests/mod.rs +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -84,7 +84,6 @@ fn at_status( token: 1, size: 4096, download_directory: "/tmp".to_string(), - use_username_dirs: false, status, sender, queue_position: None, @@ -161,7 +160,6 @@ impl SessionApi for TalkativeSession { _username: String, _size: u64, _directory: String, - _use_username_dirs: bool, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, @@ -174,7 +172,6 @@ impl SessionApi for TalkativeSession { _username: String, _size: u64, _directory: String, - _use_username_dirs: bool, _metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index ad3ca08..cc95259 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -339,7 +339,6 @@ mod tests { token: 1, size: 4096, download_directory: "/tmp".to_string(), - use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/ui/panes/download_info_pane.rs b/soulseek-rs/src/ui/panes/download_info_pane.rs index a006f51..d90c480 100644 --- a/soulseek-rs/src/ui/panes/download_info_pane.rs +++ b/soulseek-rs/src/ui/panes/download_info_pane.rs @@ -436,7 +436,6 @@ mod tests { token: 1, size: 100, download_directory: "~/dl".to_string(), - use_username_dirs: false, status, sender, queue_position: None, diff --git a/soulseek-rs/src/ui/utils.rs b/soulseek-rs/src/ui/utils.rs index fd09f44..83c97c1 100644 --- a/soulseek-rs/src/ui/utils.rs +++ b/soulseek-rs/src/ui/utils.rs @@ -72,6 +72,41 @@ pub const fn get_spinner_char(state: usize) -> &'static str { SPINNER_CHARS[state % SPINNER_CHARS.len()] } +#[must_use] +pub fn sanitize_path_component(name: &str) -> String { + let mut out = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect::(); + if out.is_empty() || out.ends_with(['.', ' ']) { + out.push('_'); + } + if is_reserved(out.as_str()) { + out.insert(0, '_'); + } + out +} + +#[cfg(target_os = "windows")] +fn is_reserved(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); + if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") { + true + } else { + matches!(stem.get(..3), Some("COM" | "LPT")) + && matches!(stem.as_bytes().get(3..), Some([b'1'..=b'9'])) + } +} + +#[cfg(not(target_os = "windows"))] +fn is_reserved(name: &str) -> bool { + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/soulseek-rs/tests/persist_e2e.rs b/soulseek-rs/tests/persist_e2e.rs index 559518b..c678b23 100644 --- a/soulseek-rs/tests/persist_e2e.rs +++ b/soulseek-rs/tests/persist_e2e.rs @@ -17,7 +17,6 @@ fn download_entry(filename: &str, status: DownloadStatus) -> DownloadEntry { token: 1, size: 1000, download_directory: "/music".into(), - use_username_dirs: false, status, sender: std::sync::mpsc::channel().0, queue_position: None, From ea884535a8e97dff153ca18bf4ca973dfdc07662 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Thu, 22 Oct 2026 14:19:18 +0200 Subject: [PATCH 4/4] fix(tui): apply username folders everywhere and resume in place Move the sanitizer next to the directory helpers so the CLI download command and the daemon honour use_username_dirs too, not only the TUI. Resumed downloads keep the folder they were saved with instead of nesting the username a second time, and toggling the setting applies to the next download without a restart. Document the config key. --- README.md | 7 +- soulseek-rs/src/commands/mod.rs | 12 ++++ soulseek-rs/src/commands/transfer.rs | 2 +- soulseek-rs/src/daemon/mod.rs | 1 + soulseek-rs/src/daemon/server/mod.rs | 8 ++- soulseek-rs/src/directories.rs | 66 ++++++++++++++++++- soulseek-rs/src/persist/config.rs | 2 +- soulseek-rs/src/run.rs | 1 + soulseek-rs/src/ui/main_tui/downloads.rs | 19 ++---- soulseek-rs/src/ui/main_tui/mod.rs | 16 +---- soulseek-rs/src/ui/main_tui/settings.rs | 1 + soulseek-rs/src/ui/main_tui/tests/settings.rs | 24 +++++++ soulseek-rs/src/ui/utils.rs | 35 ---------- website/src/pages/install.tsx | 5 ++ 14 files changed, 130 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index bf7808b..051779e 100644 --- a/README.md +++ b/README.md @@ -606,6 +606,7 @@ file, in that order of precedence. Flags work before or after the subcommand. | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | | — | — | `accept_children` | off | +| — | — | `use_username_dirs` | off | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | | `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up | @@ -627,10 +628,10 @@ to them. It is off by default — each child costs a socket and a copy of the network's whole search stream — and it needs the listener, since a child has to be able to dial in. -`config get` and `config set` cover the twelve settings the file holds +`config get` and `config set` cover the thirteen settings the file holds (`username`, `server`, `listener_port`, `disable_listener`, `accept_children`, -`download_dir`, `shared_dirs`, `max_concurrent_downloads`, `search_timeout`, -`password_cmd`, `daemon`, `daemon_token`), and list them back at you when you +`download_dir`, `use_username_dirs`, `shared_dirs`, `max_concurrent_downloads`, +`search_timeout`, `password_cmd`, `daemon`, `daemon_token`), and list them back at you when you name something else. An empty string clears a key, and `shared_dirs` takes a comma-separated list. Waits expressed in seconds (`--search-timeout`, `--timeout`, diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 65192e9..dbafa5c 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -34,6 +34,8 @@ pub struct Ctx { pub out: Out, pub settings: ClientSettings, pub download_dir: String, + /// Save each peer's files under `download_dir/`. + pub use_username_dirs: bool, pub max_concurrent_downloads: usize, pub search_timeout: Duration, /// Where to find a daemon, when this run should use one rather than open @@ -47,6 +49,16 @@ pub struct Ctx { } impl Ctx { + /// Where a download from `username` lands. + #[must_use] + pub fn dir_for(&self, username: &str) -> String { + crate::directories::download_dir_for( + &self.download_dir, + username, + self.use_username_dirs, + ) + } + /// Re-read a "found nothing", "timed out" or "transfer failed" verdict /// against the session that produced it. /// diff --git a/soulseek-rs/src/commands/transfer.rs b/soulseek-rs/src/commands/transfer.rs index b97b054..80bef8a 100644 --- a/soulseek-rs/src/commands/transfer.rs +++ b/soulseek-rs/src/commands/transfer.rs @@ -458,7 +458,7 @@ fn download_all( client.as_ref(), &ctx.out, &request, - &ctx.download_dir, + &ctx.dir_for(&request.user), timeout, ); if sender.send((request, outcome)).is_err() { diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 43453d9..adeb316 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -202,6 +202,7 @@ pub fn run( browses: Arc::clone(&browses), downloads: Arc::clone(&downloads), download_dir: Mutex::new(ctx.download_dir.clone()), + use_username_dirs: ctx.use_username_dirs, server: ctx.settings.server_address.to_string(), token: token.clone(), started: Instant::now(), diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 201a6d0..f11ac23 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -38,6 +38,7 @@ pub struct Daemon { /// Where transfers land, on *this* machine. A caller may point it at a /// different folder with `download.set_dir`, but never per download. pub download_dir: Mutex, + pub use_username_dirs: bool, pub server: String, pub token: String, pub started: Instant, @@ -428,7 +429,11 @@ impl Daemon { // per download would scatter transfers in ways `daemon.status` cannot // report; changing the folder is a deliberate session-wide act, // `download.set_dir`. - let directory = self.download_dir(); + let directory = crate::directories::download_dir_for( + &self.download_dir(), + ¶ms.username, + self.use_username_dirs, + ); let (download, updates) = self .session .download_with_metadata( @@ -800,6 +805,7 @@ mod tests { browses: Arc::new(PendingBrowses::default()), downloads: Arc::new(Mutex::new(Vec::new())), download_dir: Mutex::new("/tmp".to_string()), + use_username_dirs: false, server: "server.invalid:2416".to_string(), token: token.to_string(), started: Instant::now(), diff --git a/soulseek-rs/src/directories.rs b/soulseek-rs/src/directories.rs index 28375bc..3130846 100644 --- a/soulseek-rs/src/directories.rs +++ b/soulseek-rs/src/directories.rs @@ -50,9 +50,54 @@ pub fn resolve_shared_directories(dirs: &[String]) -> Vec { resolved } +/// The folder a download from `username` lands in: `download_dir` itself, or +/// a per-user subfolder of it when `per_user` is set, as SoulseekQt does. +#[must_use] +pub fn download_dir_for( + download_dir: &str, + username: &str, + per_user: bool, +) -> String { + if !per_user { + return download_dir.to_string(); + } + std::path::Path::new(download_dir) + .join(sanitize_path_component(username)) + .display() + .to_string() +} + +/// A peer-chosen name made safe as one path component: separators and +/// characters Windows refuses become `_`, so `../..` cannot climb out. +fn sanitize_path_component(name: &str) -> String { + let mut out: String = name + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + c if c.is_control() => '_', + c => c, + }) + .collect(); + if out.is_empty() || out.ends_with(['.', ' ']) { + out.push('_'); + } + if is_reserved(&out) { + out.insert(0, '_'); + } + out +} + +/// Windows device names (`CON`, `COM1`, …) that cannot be a folder name. +fn is_reserved(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || (matches!(stem.get(..3), Some("COM" | "LPT")) + && matches!(stem.as_bytes().get(3..), Some([b'1'..=b'9']))) +} + #[cfg(test)] mod tests { - use super::resolve_shared_directory; + use super::{download_dir_for, resolve_shared_directory}; use std::sync::atomic::{AtomicU32, Ordering}; fn unique_temp_dir() -> std::path::PathBuf { @@ -97,4 +142,23 @@ mod tests { assert!(result.is_err()); let _ = std::fs::remove_dir_all(dir); } + + fn dl(name: &str) -> String { + std::path::Path::new("/dl").join(name).display().to_string() + } + + #[test] + fn per_user_folder_is_a_child_of_the_download_dir() { + assert_eq!(download_dir_for("/dl", "alice", false), "/dl"); + assert_eq!(download_dir_for("/dl", "alice", true), dl("alice")); + } + + #[test] + fn a_hostile_username_cannot_leave_the_download_dir() { + assert_eq!(download_dir_for("/dl", "../..", true), dl(".._.._")); + assert_eq!(download_dir_for("/dl", "/tmp/x", true), dl("_tmp_x")); + assert_eq!(download_dir_for("/dl", "..", true), dl(".._")); + assert_eq!(download_dir_for("/dl", "", true), dl("_")); + assert_eq!(download_dir_for("/dl", "com1", true), dl("_com1")); + } } diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 6a4578e..376f48b 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -176,7 +176,7 @@ impl FileConfig { "use_username_dirs wants true or false, got '{other}'" )); } - }) + }); } other => { return Err(format!( diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index 89f15b4..f3419cc 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -276,6 +276,7 @@ fn context( version: ClientVersion::REFERENCE_CLIENT, }, download_dir, + use_username_dirs: resolved.use_username_dirs, max_concurrent_downloads: resolved.max_concurrent_downloads, search_timeout: Duration::from_secs(resolved.search_timeout), daemon, diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index 37cf177..b71dfc8 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -1,10 +1,9 @@ use super::MainTui; +use crate::directories::download_dir_for; use crate::models::{DownloadEntry, FileDisplayData}; use crate::ui::panes::{InfoSubject, selected_transfer}; -use crate::ui::sanitize_path_component; use soulseek_rs::types::UploadStatus; use soulseek_rs::{DownloadStatus, types::Download}; -use std::path::Path; use std::{sync::mpsc, thread}; impl MainTui { @@ -209,21 +208,15 @@ impl MainTui { peer_upload_speed: Some(file.speed), peer_free_slots: Some(file.slots), }; - let download_dir = if use_username_dirs { - let sanitized_username = - sanitize_path_component(file.username.as_str()); - Path::new(download_dir.as_str()) - .join(sanitized_username.as_str()) - .display() - .to_string() - } else { - download_dir.clone() - }; match client.download_with_metadata( file.filename.clone(), file.username.clone(), file.size, - download_dir, + download_dir_for( + &download_dir, + &file.username, + use_username_dirs, + ), metadata, ) { Ok((download, rx)) => { diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 2b0a396..58a1664 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -12,13 +12,11 @@ use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, state::{PersistedMessage, StateStore}, }; -use crate::ui::sanitize_path_component; use color_eyre::Result; use ratatui::{ DefaultTerminal, crossterm::event::{self, Event, KeyEventKind, poll}, }; -use std::path::Path; use std::{sync::Arc, time::Duration}; pub struct MainTui { @@ -134,23 +132,12 @@ impl MainTui { } else { let client = self.client.clone(); let sender = sender.clone(); - let use_username_dirs = self.use_username_dirs; std::thread::spawn(move || { - let sanitized_username = - sanitize_path_component(entry.username.as_str()); - let download_dir = if use_username_dirs { - Path::new(entry.download_directory.as_str()) - .join(sanitized_username.as_str()) - .display() - .to_string() - } else { - entry.download_directory - }; match client.download( entry.filename.clone(), entry.username, entry.size, - download_dir, + entry.download_directory, ) { Ok((download, rx)) => { let _ = sender.send((download, rx)); @@ -268,6 +255,7 @@ impl MainTui { } } +#[expect(clippy::too_many_arguments, reason = "one call site per mode")] pub fn launch_main_tui( terminal: DefaultTerminal, client: Arc, diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 8416dc2..7668fda 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -109,6 +109,7 @@ impl MainTui { if dir_applied { self.download_dir.clone_from(&download_dir); } + self.use_username_dirs = use_username_dirs; // Persist to config.toml so the change survives a restart. What was // refused live is not written either: the file must not promise a diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs index 0a419d3..8f72269 100644 --- a/soulseek-rs/src/ui/main_tui/tests/settings.rs +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -108,6 +108,30 @@ fn applying_settings_locally_stays_on_this_machine() { assert_eq!(tui.download_dir, downloads_dir); } +#[test] +fn toggling_username_folders_applies_live_and_persists() { + let session = Arc::new(TalkativeSession::default()); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session, + scratch.path().to_str().expect("utf-8").to_string(), + false, + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + tui.open_settings(); + tui.state.settings.as_mut().expect("open").use_username_dirs = true; + tui.apply_settings(); + + assert!(tui.use_username_dirs, "the next download uses it"); + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config saved"); + assert_eq!(saved.use_username_dirs, Some(true)); +} + /// The account block answers "who is this session, and what is it /// offering" without a trip to another screen. #[test] diff --git a/soulseek-rs/src/ui/utils.rs b/soulseek-rs/src/ui/utils.rs index 83c97c1..fd09f44 100644 --- a/soulseek-rs/src/ui/utils.rs +++ b/soulseek-rs/src/ui/utils.rs @@ -72,41 +72,6 @@ pub const fn get_spinner_char(state: usize) -> &'static str { SPINNER_CHARS[state % SPINNER_CHARS.len()] } -#[must_use] -pub fn sanitize_path_component(name: &str) -> String { - let mut out = name - .chars() - .map(|c| match c { - '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', - c if c.is_control() => '_', - c => c, - }) - .collect::(); - if out.is_empty() || out.ends_with(['.', ' ']) { - out.push('_'); - } - if is_reserved(out.as_str()) { - out.insert(0, '_'); - } - out -} - -#[cfg(target_os = "windows")] -fn is_reserved(name: &str) -> bool { - let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase(); - if matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") { - true - } else { - matches!(stem.get(..3), Some("COM" | "LPT")) - && matches!(stem.as_bytes().get(3..), Some([b'1'..=b'9'])) - } -} - -#[cfg(not(target_os = "windows"))] -fn is_reserved(name: &str) -> bool { - false -} - #[cfg(test)] mod tests { use super::*; diff --git a/website/src/pages/install.tsx b/website/src/pages/install.tsx index 9e03fb0..eac4943 100644 --- a/website/src/pages/install.tsx +++ b/website/src/pages/install.tsx @@ -129,6 +129,11 @@ const SETTINGS: readonly Setting[] = [ ), }, + { + key: 'use_username_dirs', + fallback: 'false', + body: <>Save each peer's files in a subfolder named after them., + }, { key: 'max_concurrent_downloads', fallback: '20',