Files
soulseek-rs-modded/soulseek-rs-lib/src/shares/mod.rs
T
Michel de Graaf 26b551d997 feat: browse your own shares as a peer receives them
The window could show any peer's share tree but never this account's, so
"am I actually sharing that?" had no answer short of asking a friend to
browse you. The tree is already there; what was missing was feeding it
from our own index.

B opens it — the same collapsible tree, filter, and navigation the peer
browse offers, built from `Shares::directories()`, which is the listing
GetShareFileList answers with. So what it shows is what the network sees,
not what the config meant to share. r re-scans the disk rather than
re-asking a peer, o steps across to the folders that feed it, and nothing
here queues a download from ourselves.

Attached to a daemon the index lives on the daemon's host, so it comes
back over a new `shares.files` method rather than being scanned locally;
`shares files [--filter]` prints the same listing headless, through the
`emit_listing` that `browse` now shares with it — the two are only
comparable while they are built the same way.

Two allocation fixes came out of the review, both on paths this reaches:
the daemon hands its listing to the DTO instead of deep-copying one it
already owns, and `Shares::directories()` allocates its map key once per
folder rather than once per file, which the peer-facing serve path has
been paying for all along.
2026-09-10 11:44:57 +02:00

419 lines
14 KiB
Rust

//! An in-memory index of the files we share with other peers.
//!
//! Scanning a directory produces a read-only [`Shares`] snapshot keyed by the
//! peer-facing *virtual path* (the shared directory's own name followed by the
//! backslash-separated relative path, matching the Soulseek wire convention).
mod audio;
use crate::message::peer::{SharedDirectory, SharedFileEntry};
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
/// One shared file: its peer-facing virtual path and where it lives on disk.
///
/// Built by the scan, never by a host, so fields can be added without a
/// breaking release.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SharedFile {
/// Backslash-separated path exposed to peers, e.g. `music\album\song.mp3`.
pub virtual_path: String,
/// `virtual_path` lowercased once at scan time, so answering a search does
/// not lowercase every shared path again.
search_key: String,
/// The real filesystem path used to serve the bytes.
pub real_path: PathBuf,
pub size: u64,
/// `(code, value)` audio attributes read from the file's headers: bitrate,
/// duration, VBR, sample rate, bit depth, as far as the format gives them.
pub attributes: Vec<(u32, u32)>,
}
/// A read-only snapshot of the shared files, cheap to clone behind an `Arc`.
#[derive(Debug, Default)]
pub struct Shares {
files: Vec<SharedFile>,
by_virtual: HashMap<String, usize>,
folder_count: usize,
}
impl Shares {
/// An empty share set (nothing shared).
#[must_use]
pub fn empty() -> Self {
Self::default()
}
/// Scan `root` recursively into a share index. Symlinks and unreadable
/// entries are skipped so a single bad entry never aborts the scan.
///
/// # Errors
/// Returns an error only if `root` itself cannot be read.
pub fn scan(root: &Path) -> io::Result<Self> {
// Fail fast if the root is unreadable; deeper failures are skipped.
let _ = std::fs::read_dir(root)?;
Ok(Self::scan_many(&[root.to_path_buf()]))
}
/// 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;
}
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();
Self {
files,
by_virtual,
folder_count,
}
}
/// Files whose virtual path contains *every* whitespace-separated term of
/// `query` (case-insensitive). An empty query matches nothing.
#[must_use]
pub fn search(&self, query: &str) -> Vec<&SharedFile> {
let terms: Vec<String> = query
.to_lowercase()
.split_whitespace()
.map(String::from)
.collect();
if terms.is_empty() {
return Vec::new();
}
self.files
.iter()
.filter(|f| terms.iter().all(|t| f.search_key.contains(t.as_str())))
.collect()
}
/// Look up a shared file by its exact virtual path.
#[must_use]
pub fn get(&self, virtual_path: &str) -> Option<&SharedFile> {
self.by_virtual.get(virtual_path).map(|&i| &self.files[i])
}
/// All shared files, in scan order.
#[must_use]
pub fn files(&self) -> &[SharedFile] {
&self.files
}
/// Files grouped by their virtual directory (everything before the final
/// backslash), in the form a browse or folder listing carries.
#[must_use]
pub fn directories(&self) -> Vec<SharedDirectory> {
let mut by_dir: std::collections::BTreeMap<
String,
Vec<SharedFileEntry>,
> = std::collections::BTreeMap::new();
for file in &self.files {
let (dir, base) = file
.virtual_path
.rsplit_once('\\')
.unwrap_or(("", file.virtual_path.as_str()));
let entry = SharedFileEntry {
name: base.to_string(),
size: file.size,
attributes: file.attributes.clone(),
};
// Looked up before inserting: `entry()` would allocate the key
// for every file, and a folder holds many.
if let Some(files) = by_dir.get_mut(dir) {
files.push(entry);
} else {
by_dir.insert(dir.to_string(), vec![entry]);
}
}
by_dir
.into_iter()
.map(|(name, files)| SharedDirectory { name, files })
.collect()
}
#[must_use]
pub const fn file_count(&self) -> u32 {
self.files.len() as u32
}
#[must_use]
pub const fn folder_count(&self) -> u32 {
self.folder_count as u32
}
}
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() {
// The download directory is usually shared too; an in-progress
// `.part` file is not something to offer other peers.
if path
.to_string_lossy()
.ends_with(crate::utils::path::PART_SUFFIX)
{
continue;
}
let virtual_path = virtual_path_for(root_name, root, &path);
files.push(SharedFile {
search_key: virtual_path.to_lowercase(),
virtual_path,
attributes: audio::probe(&path),
real_path: path,
size: meta.len(),
});
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 {
let rel = path.strip_prefix(root).unwrap_or(path);
let mut parts = vec![root_name.to_string()];
for component in rel.components() {
parts.push(component.as_os_str().to_string_lossy().into_owned());
}
parts.join("\\")
}
#[cfg(test)]
mod tests {
use super::Shares;
fn temp_tree() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir()
.join(format!("soulseek-shares-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("album")).unwrap();
std::fs::write(root.join("top.mp3"), b"aaaa").unwrap();
std::fs::write(root.join("album").join("song one.flac"), b"bbbbbb")
.unwrap();
std::fs::write(root.join("album").join("song two.flac"), b"cc")
.unwrap();
root
}
#[test]
fn a_scanned_mp3_carries_its_bitrate() {
let root = std::env::temp_dir()
.join(format!("soulseek-shares-audio-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
// 100 frames at 128 kbps is 2.6 seconds.
std::fs::write(
root.join("track.mp3"),
super::audio::tests::cbr_mp3(100),
)
.unwrap();
std::fs::write(root.join("cover.jpg"), b"jpeg").unwrap();
let shares = Shares::scan(&root).unwrap();
let track = shares
.get(
"soulseek-shares-audio-{pid}\\track.mp3"
.replace("{pid}", &std::process::id().to_string())
.as_str(),
)
.unwrap();
assert!(track.attributes.contains(&(0, 128)));
assert!(track.attributes.contains(&(1, 2)));
let cover = shares
.files()
.iter()
.find(|f| f.virtual_path.ends_with("cover.jpg"))
.unwrap();
assert!(cover.attributes.is_empty());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn scan_counts_files_and_folders() {
let root = temp_tree();
let shares = Shares::scan(&root).unwrap();
assert_eq!(shares.file_count(), 3);
// Two folders contain files: the root and `album`.
assert_eq!(shares.folder_count(), 2);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn in_progress_downloads_are_not_shared() {
let root = temp_tree();
std::fs::write(root.join("half.mp3.part"), b"dd").unwrap();
let shares = Shares::scan(&root).unwrap();
assert_eq!(shares.file_count(), 3, ".part files stay out of the index");
let base = root.file_name().unwrap().to_string_lossy().into_owned();
assert!(shares.get(&format!("{base}\\half.mp3.part")).is_none());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn nested_files_use_backslash_virtual_paths() {
let root = temp_tree();
let base = root.file_name().unwrap().to_string_lossy().into_owned();
let shares = Shares::scan(&root).unwrap();
let vpath = format!("{base}\\album\\song one.flac");
let file = shares.get(&vpath).expect("nested file indexed");
assert_eq!(file.size, 6);
assert!(shares.get("does\\not\\exist").is_none());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn a_mixed_case_path_matches_a_lowercase_query() {
let root = std::env::temp_dir()
.join(format!("soulseek-shares-case-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("Live Set")).unwrap();
std::fs::write(root.join("Live Set").join("Opening TRACK.flac"), b"x")
.unwrap();
let shares = Shares::scan(&root).unwrap();
assert_eq!(shares.search("live opening track").len(), 1);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn search_is_case_insensitive_and_multi_term_and() {
let root = temp_tree();
let shares = Shares::scan(&root).unwrap();
// Both album files match "SONG"; "song one" narrows to one.
assert_eq!(shares.search("SONG").len(), 2);
assert_eq!(shares.search("song ONE").len(), 1);
assert!(shares.search("nonexistent").is_empty());
assert!(shares.search("").is_empty());
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, music_b]);
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();
assert_eq!(shares.file_count(), 0);
assert_eq!(shares.folder_count(), 0);
assert!(shares.search("anything").is_empty());
}
}