feat: multiple shared directories

Shares::scan_many merges several roots into one index, suffixing
duplicate root names so virtual paths cannot collide, and skipping
unreadable roots instead of failing. ClientSettings now takes
shared_directories: Vec<String>; config gains a shared_dirs list
(combined with the single shared_dir and --shared-dir, deduped), with an
explicitly empty value still disabling sharing and the
share-what-you-download default unchanged.
This commit is contained in:
michel
2026-07-20 20:47:23 +02:00
parent 8389d525e7
commit a605431a10
9 changed files with 277 additions and 117 deletions
+1 -1
View File
@@ -10,5 +10,5 @@ pub struct SearchConfig {
pub download_dir: String,
pub verbose: u8,
pub max_concurrent_downloads: usize,
pub shared_directory: Option<String>,
pub shared_directories: Vec<String>,
}
+14
View File
@@ -35,6 +35,20 @@ pub fn resolve_shared_directory(
Ok(Some(path))
}
/// Validate several configured share paths; invalid ones are skipped with a
/// warning instead of aborting (the client just shares less).
pub fn resolve_shared_directories(dirs: &[String]) -> Vec<String> {
let mut resolved = Vec::new();
for dir in dirs {
match resolve_shared_directory(Some(dir)) {
Ok(Some(path)) => resolved.push(path.display().to_string()),
Ok(None) => {}
Err(e) => eprintln!("⚠️ Ignoring shared directory: {e}"),
}
}
resolved
}
#[cfg(test)]
mod tests {
use super::resolve_shared_directory;
+10 -24
View File
@@ -72,17 +72,10 @@ fn main() -> Result<()> {
let (server_host, server_port) = parse_server_address(&resolved.server)?;
// Resolve the optional shared/upload directory up front; a misconfigured
// one is a warning, not a fatal error (the client just shares nothing).
let shared_directory = match directories::resolve_shared_directory(
resolved.shared_dir.as_deref(),
) {
Ok(dir) => dir.map(|path| path.display().to_string()),
Err(e) => {
eprintln!("⚠️ Ignoring shared directory: {e}");
None
}
};
// Resolve the configured shared/upload directories up front; a
// misconfigured one is a warning, not a fatal error.
let shared_directories =
directories::resolve_shared_directories(&resolved.shared_dirs);
let settings = ClientSettings {
username: username.clone(),
@@ -90,7 +83,7 @@ fn main() -> Result<()> {
server_address: PeerAddress::new(server_host.clone(), server_port),
enable_listen: !resolved.disable_listener,
listen_port: resolved.listener_port,
shared_directory: shared_directory.clone(),
shared_directories: shared_directories.clone(),
};
match cli.command {
@@ -112,7 +105,7 @@ fn main() -> Result<()> {
download_dir,
verbose: cli.verbose,
max_concurrent_downloads,
shared_directory,
shared_directories,
};
search_and_download(config)
}
@@ -182,15 +175,8 @@ fn run_default_tui(
);
}
let shared_directory = match directories::resolve_shared_directory(
resolved.shared_dir.as_deref(),
) {
Ok(dir) => dir.map(|path| path.display().to_string()),
Err(e) => {
eprintln!("⚠️ Ignoring shared directory: {e}");
None
}
};
let shared_directories =
directories::resolve_shared_directories(&resolved.shared_dirs);
let secret_store = persist::secret::KeyringStore;
let initial_password = persist::secret::resolve_password(
@@ -217,7 +203,7 @@ fn run_default_tui(
server_address: PeerAddress::new(server_host.clone(), server_port),
enable_listen,
listen_port,
shared_directory: shared_directory.clone(),
shared_directories: shared_directories.clone(),
};
// Clear screen and enable mouse capture before initializing TUI
@@ -475,7 +461,7 @@ fn search_and_download(config: SearchConfig) -> Result<()> {
),
enable_listen: config.enable_listener,
listen_port: config.listener_port,
shared_directory: config.shared_directory.clone(),
shared_directories: config.shared_directories.clone(),
};
let _port_mapper = settings
+78 -18
View File
@@ -13,7 +13,11 @@ pub struct FileConfig {
pub listener_port: Option<u16>,
pub disable_listener: Option<bool>,
pub download_dir: Option<String>,
/// Single shared folder (also what `--shared-dir` sets). Prefer
/// `shared_dirs` for multiple; both may be combined.
pub shared_dir: Option<String>,
/// Multiple shared folders. An explicitly empty list disables sharing.
pub shared_dirs: Option<Vec<String>>,
pub max_concurrent_downloads: Option<usize>,
pub search_timeout: Option<u64>,
/// Command whose stdout is the password (headless fallback, like mutt's
@@ -61,7 +65,7 @@ pub struct Resolved {
pub listener_port: u16,
pub disable_listener: bool,
pub download_dir: String,
pub shared_dir: Option<String>,
pub shared_dirs: Vec<String>,
pub max_concurrent_downloads: usize,
pub search_timeout: u64,
pub password_cmd: Option<String>,
@@ -98,14 +102,7 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
disable_listener: cli.disable_listener
|| file.disable_listener.unwrap_or(false),
download_dir: download_dir.clone(),
// Soulseek convention: share what you download. An explicitly empty
// shared_dir ("") opts out of sharing entirely.
shared_dir: cli
.shared_dir
.clone()
.or_else(|| file.shared_dir.clone())
.or(Some(download_dir))
.filter(|dir| !dir.trim().is_empty()),
shared_dirs: resolve_shared_dirs(cli, file, &download_dir),
max_concurrent_downloads: cli
.max_concurrent_downloads
.or(file.max_concurrent_downloads)
@@ -118,6 +115,33 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
}
}
/// Sharing follows the Soulseek convention of sharing what you download:
/// with nothing configured, the download folder is shared. Configuring any
/// of `--shared-dir` / `shared_dir` / `shared_dirs` replaces that default
/// (their non-empty values are combined), and an explicitly empty value
/// (`shared_dir = ""` or `shared_dirs = []`) disables sharing entirely.
fn resolve_shared_dirs(
cli: &crate::cli::Cli,
file: &FileConfig,
download_dir: &str,
) -> Vec<String> {
if cli.shared_dir.is_none()
&& file.shared_dir.is_none()
&& file.shared_dirs.is_none()
{
return vec![download_dir.to_string()];
}
let mut dirs: Vec<String> = Vec::new();
let singles = cli.shared_dir.iter().chain(file.shared_dir.iter());
for dir in singles.chain(file.shared_dirs.iter().flatten()) {
let dir = dir.trim();
if !dir.is_empty() && !dirs.iter().any(|d| d == dir) {
dirs.push(dir.to_string());
}
}
dirs
}
#[cfg(test)]
mod tests {
use super::*;
@@ -169,22 +193,19 @@ mod tests {
}
#[test]
fn shared_dir_defaults_to_the_download_dir() {
fn sharing_defaults_to_the_download_dir() {
let resolved = resolve(&bare_cli(), &FileConfig::default());
assert_eq!(
resolved.shared_dir.as_deref(),
Some(resolved.download_dir.as_str())
);
assert_eq!(resolved.shared_dirs, vec![resolved.download_dir.clone()]);
}
#[test]
fn shared_dir_follows_a_customized_download_dir() {
fn sharing_follows_a_customized_download_dir() {
let file = FileConfig {
download_dir: Some("/music".into()),
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(resolved.shared_dir.as_deref(), Some("/music"));
assert_eq!(resolved.shared_dirs, vec!["/music".to_string()]);
}
#[test]
@@ -194,7 +215,45 @@ mod tests {
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(resolved.shared_dir, None);
assert!(resolved.shared_dirs.is_empty());
}
#[test]
fn empty_shared_dirs_list_disables_sharing() {
let file = FileConfig {
shared_dirs: Some(Vec::new()),
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert!(resolved.shared_dirs.is_empty());
}
#[test]
fn shared_dirs_list_replaces_the_default_share() {
let file = FileConfig {
shared_dirs: Some(vec!["/a".into(), "/b".into()]),
..FileConfig::default()
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(
resolved.shared_dirs,
vec!["/a".to_string(), "/b".to_string()]
);
}
#[test]
fn cli_shared_dir_combines_with_file_list_and_dedupes() {
let mut cli = bare_cli();
cli.shared_dir = Some("/a".into());
let file = FileConfig {
shared_dirs: Some(vec!["/a".into(), "/b".into()]),
..FileConfig::default()
};
let resolved = resolve(&cli, &file);
assert_eq!(
resolved.shared_dirs,
vec!["/a".to_string(), "/b".to_string()]
);
}
#[test]
@@ -206,6 +265,7 @@ mod tests {
disable_listener: Some(true),
download_dir: Some("/music".into()),
shared_dir: Some("/shared".into()),
shared_dirs: None,
max_concurrent_downloads: Some(2),
search_timeout: Some(30),
password_cmd: Some("pass show slsk".into()),
@@ -216,7 +276,7 @@ mod tests {
assert_eq!(resolved.listener_port, 4321);
assert!(resolved.disable_listener);
assert_eq!(resolved.download_dir, "/music");
assert_eq!(resolved.shared_dir.as_deref(), Some("/shared"));
assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]);
assert_eq!(resolved.max_concurrent_downloads, 2);
assert_eq!(resolved.search_timeout, 30);
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
+7 -4
View File
@@ -353,10 +353,13 @@ impl MainTui {
let shortcuts_line = format_shortcuts_styled(&shortcuts);
// Surface our own sharing status in the block title.
let title = format!(
"Shortcuts · Sharing: {}",
self.client.shared_directory().unwrap_or("off")
);
let shared = self.client.shared_directories();
let sharing = match shared {
[] => "off".to_string(),
[only] => only.clone(),
more => format!("{} folders", more.len()),
};
let title = format!("Shortcuts · Sharing: {sharing}");
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
Block::default()
.borders(Borders::ALL)