feat(tui): added option for storing downloaded files in username directories

This commit is contained in:
Maik Steiger
2026-10-08 21:59:23 +02:00
parent 20bd676e0f
commit e567fa75ed
30 changed files with 130 additions and 7 deletions
+13 -1
View File
@@ -102,6 +102,7 @@ pub trait SessionApi: Send + Sync {
username: String,
size: u64,
download_directory: String,
use_username_dirs: bool,
) -> Result<(Download, Receiver<DownloadStatus>)>;
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<DownloadStatus>)>;
fn get_all_downloads(&self) -> Vec<Download>;
@@ -271,8 +273,16 @@ impl SessionApi for Client {
username: String,
size: u64,
download_directory: String,
use_username_dirs: bool,
) -> Result<(Download, Receiver<DownloadStatus>)> {
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<DownloadStatus>)> {
Self::download_with_metadata(
@@ -289,6 +300,7 @@ impl SessionApi for Client {
username,
size,
download_directory,
use_username_dirs,
metadata,
)
}
+1
View File
@@ -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}")))?;
+1
View File
@@ -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() {
+2
View File
@@ -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<u32>,
@@ -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),
+4
View File
@@ -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<soulseek_rs::DownloadStatus>,
@@ -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,
+11
View File
@@ -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<String>,
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<String>,
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(),
+19
View File
@@ -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<Vec<String>>,
pub use_username_dirs: Option<bool>,
}
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<String>,
pub use_username_dirs: bool,
pub max_concurrent_downloads: usize,
pub search_timeout: u64,
pub password_cmd: Option<String>,
@@ -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"));
+1
View File
@@ -126,6 +126,7 @@ mod tests {
token: 7,
size: 42,
download_directory: "/music".into(),
use_username_dirs: false,
status,
sender,
queue_position: None,
+3
View File
@@ -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,
}
}
+3
View File
@@ -367,6 +367,7 @@ fn rebuild(dto: DownloadDto, sender: Sender<DownloadStatus>) -> 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<DownloadStatus>)> {
self.start_download(filename, username, size, metadata)
+2
View File
@@ -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,
+1
View File
@@ -177,6 +177,7 @@ mod tests {
token: 1,
size,
download_directory: "/tmp".to_string(),
use_username_dirs: false,
status,
sender,
queue_position: None,
+2
View File
@@ -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));
+10 -1
View File
@@ -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)) => {
+14 -2
View File
@@ -23,6 +23,7 @@ pub struct MainTui {
client: Arc<dyn SessionApi>,
state: AppState,
download_dir: String,
use_username_dirs: bool,
search_timeout: Duration,
spinner_state: usize,
store: Option<StateStore>,
@@ -40,6 +41,7 @@ impl MainTui {
pub fn new(
client: Arc<dyn SessionApi>,
download_dir: String,
use_username_dirs: bool,
search_timeout: Duration,
store: Option<StateStore>,
config_path: Option<std::path::PathBuf>,
@@ -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<dyn SessionApi>,
download_dir: String,
use_username_dirs: bool,
search_timeout: Duration,
store: Option<StateStore>,
config_path: Option<std::path::PathBuf>,
listener_fallback: Option<(u16, u16)>,
) -> Result<TuiExit> {
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)
}
+10
View File
@@ -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 {
@@ -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"),
+3
View File
@@ -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 {
+4
View File
@@ -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<soulseek_rs::DownloadStatus>,
@@ -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<TalkativeSession>) -> MainTui {
MainTui::new(
session,
"/tmp".to_string(),
false,
Duration::from_secs(1),
None,
None,
@@ -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),
+1
View File
@@ -339,6 +339,7 @@ mod tests {
token: 1,
size: 4096,
download_directory: "/tmp".to_string(),
use_username_dirs: false,
status,
sender,
queue_position: None,
@@ -436,6 +436,7 @@ mod tests {
token: 1,
size: 100,
download_directory: "~/dl".to_string(),
use_username_dirs: false,
status,
sender,
queue_position: None,