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
+135 -36
View File
@@ -44,55 +44,55 @@ impl Shares {
// Fail fast if the root is unreadable; deeper failures are skipped.
let _ = std::fs::read_dir(root)?;
let root_name = root.file_name().map_or_else(
|| "shared".to_string(),
|n| n.to_string_lossy().into_owned(),
);
let mut files = Vec::new();
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
let mut stack = vec![root.to_path_buf()];
let mut folder_count = 0;
scan_root(root, &root_display_name(root), &mut files, &mut folder_count);
Ok(Self::from_files(files, folder_count))
}
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
/// Scan several roots into one merged index. Unreadable roots are
/// skipped (with a log line) instead of failing the whole scan, and
/// roots that share a directory name get a ` (2)`, ` (3)`… suffix so
/// their virtual paths cannot collide.
#[must_use]
pub fn scan_many(roots: &[PathBuf]) -> Self {
let mut files = Vec::new();
let mut folder_count = 0;
let mut name_uses: HashMap<String, u32> = HashMap::new();
for root in roots {
if std::fs::read_dir(root).is_err() {
crate::warn!(
"Skipping unreadable shared directory {}",
root.display()
);
continue;
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
// `entry.metadata()` does not traverse the symlink itself.
if meta.file_type().is_symlink() {
continue;
}
let path = entry.path();
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() {
let virtual_path =
virtual_path_for(&root_name, root, &path);
files.push(SharedFile {
virtual_path,
real_path: path,
size: meta.len(),
attributes: Vec::new(),
});
folders_with_files.insert(dir.clone());
}
}
let base = root_display_name(root);
let uses = name_uses.entry(base.clone()).or_insert(0);
*uses += 1;
let name = if *uses == 1 {
base
} else {
format!("{base} ({uses})")
};
scan_root(root, &name, &mut files, &mut folder_count);
}
Self::from_files(files, folder_count)
}
fn from_files(files: Vec<SharedFile>, folder_count: usize) -> Self {
let by_virtual = files
.iter()
.enumerate()
.map(|(i, f)| (f.virtual_path.clone(), i))
.collect();
Ok(Self {
Self {
files,
by_virtual,
folder_count: folders_with_files.len(),
})
folder_count,
}
}
/// Files whose virtual path contains *every* whitespace-separated term of
@@ -159,6 +159,54 @@ impl Shares {
}
}
fn root_display_name(root: &Path) -> String {
root.file_name().map_or_else(
|| "shared".to_string(),
|n| n.to_string_lossy().into_owned(),
)
}
/// Recursively walk `root`, appending its files (under the virtual root name
/// `root_name`) to `files` and counting folders that contain files.
fn scan_root(
root: &Path,
root_name: &str,
files: &mut Vec<SharedFile>,
folder_count: &mut usize,
) {
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(meta) = entry.metadata() else {
continue;
};
// `entry.metadata()` does not traverse the symlink itself.
if meta.file_type().is_symlink() {
continue;
}
let path = entry.path();
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() {
let virtual_path = virtual_path_for(root_name, root, &path);
files.push(SharedFile {
virtual_path,
real_path: path,
size: meta.len(),
attributes: Vec::new(),
});
folders_with_files.insert(dir.clone());
}
}
}
*folder_count += folders_with_files.len();
}
/// Build the peer-facing virtual path for `path` under `root`: the root's own
/// name followed by the backslash-separated components relative to it.
fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String {
@@ -226,6 +274,57 @@ mod tests {
let _ = std::fs::remove_dir_all(root);
}
#[test]
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()]);
assert_eq!(shares.file_count(), 6);
assert_eq!(shares.folder_count(), 4);
// Files from both roots are reachable under their root's name.
let name_a = root_a.file_name().unwrap().to_string_lossy();
let name_b = root_b.file_name().unwrap().to_string_lossy();
assert!(shares.get(&format!("{name_a}\\top.mp3")).is_some());
assert!(shares.get(&format!("{name_b}\\top.mp3")).is_some());
let _ = std::fs::remove_dir_all(root_a);
let _ = std::fs::remove_dir_all(root_b);
}
#[test]
fn scan_many_disambiguates_equal_root_names() {
// Two different directories both named "music".
let parent_a = temp_tree();
let parent_b = temp_tree();
let music_a = parent_a.join("music");
let music_b = parent_b.join("music");
std::fs::create_dir_all(&music_a).unwrap();
std::fs::create_dir_all(&music_b).unwrap();
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()]);
assert!(shares.get("music\\a.mp3").is_some());
assert!(
shares.get("music (2)\\b.mp3").is_some(),
"second root with the same name gets a numbered suffix"
);
let _ = std::fs::remove_dir_all(parent_a);
let _ = std::fs::remove_dir_all(parent_b);
}
#[test]
fn scan_many_skips_unreadable_roots() {
let root = temp_tree();
let shares = Shares::scan_many(&[
std::path::PathBuf::from("/does/not/exist"),
root.clone(),
]);
assert_eq!(shares.file_count(), 3);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn empty_shares_have_no_files_or_folders() {
let shares = Shares::empty();