Files
soulseek-rs-modded/soulseek-rs/src/models/browse.rs
T
Michel de Graaf f6423a2ed6 feat(lib): carry audio attributes in browse and folder listings
A browse listed each file as a name and a size, so a searcher who saw a
bitrate in a search reply lost it the moment they opened the sharer's
files. Listings (codes 5 and 37) now carry each file's attributes the
way Nicotine+ sends them, as a SharedFileEntry with name, size and
attributes in place of the (name, size) pair; the CLI's browse record
gains bitrate and duration, and the daemon protocol moves to version 2
for the new listing shape.
2026-09-08 01:44:49 +02:00

595 lines
18 KiB
Rust

//! Model for browsing another user's shared files as a collapsible tree.
//!
//! The library returns a *flat* `Vec<SharedDirectory>` where each directory
//! carries its full backslash-separated path and only directories that directly
//! contain files are present. This module synthesizes the intermediate folders
//! into a real nested tree, and provides a flattened, expansion-aware row view
//! for rendering and navigation.
use soulseek_rs::SharedDirectory;
use std::collections::{HashMap, HashSet};
use std::time::Instant;
/// A node in the browse tree: a folder (with children) or a file leaf.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrowseNode {
Folder {
/// Last path segment (display name).
name: String,
/// Full backslash path (expansion key + display).
path: String,
children: Vec<Self>,
},
File {
/// Basename (display name).
name: String,
/// Full backslash virtual path — the exact key used to download it.
path: String,
size: u64,
},
}
impl BrowseNode {
fn sort_key(&self) -> String {
match self {
Self::Folder { name, .. } | Self::File { name, .. } => {
name.to_lowercase()
}
}
}
}
/// The synthesized tree plus counts for the title bar.
#[derive(Debug, Default)]
pub struct BuiltTree {
pub nodes: Vec<BrowseNode>,
pub file_count: usize,
pub folder_count: usize,
}
/// Intermediate builder keyed by path segment while synthesizing folders.
#[derive(Default)]
struct Builder {
full_path: String,
folders: HashMap<String, Self>,
/// `(basename, size, full_virtual_path)`.
files: Vec<(String, u64, String)>,
}
impl Builder {
fn into_nodes(
self,
file_count: &mut usize,
folder_count: &mut usize,
) -> Vec<BrowseNode> {
let mut folders: Vec<BrowseNode> = self
.folders
.into_iter()
.map(|(name, child)| {
*folder_count += 1;
let path = child.full_path.clone();
let children = child.into_nodes(file_count, folder_count);
BrowseNode::Folder {
name,
path,
children,
}
})
.collect();
folders.sort_by_key(BrowseNode::sort_key);
let mut files: Vec<BrowseNode> = self
.files
.into_iter()
.map(|(name, size, path)| {
*file_count += 1;
BrowseNode::File { name, path, size }
})
.collect();
files.sort_by_key(BrowseNode::sort_key);
folders.extend(files);
folders
}
}
/// Build a nested tree from the peer's flat directory listing, synthesizing
/// intermediate folders. File download paths are taken from the *original*
/// directory name so they are byte-exact for the download request.
#[must_use]
pub fn build_browse_tree(directories: &[SharedDirectory]) -> BuiltTree {
let mut root = Builder::default();
for dir in directories {
// Walk/synthesize the folder chain, tolerating leading/trailing/doubled
// backslashes by dropping empty segments.
let mut node = &mut root;
let mut parent_path = String::new();
for segment in dir.name.split('\\').filter(|s| !s.is_empty()) {
let child_path = if parent_path.is_empty() {
segment.to_string()
} else {
format!("{parent_path}\\{segment}")
};
node =
node.folders.entry(segment.to_string()).or_insert_with(|| {
Builder {
full_path: child_path.clone(),
..Builder::default()
}
});
parent_path = child_path;
}
// File paths come from the original name so downloads match exactly.
let dir_path = dir.name.trim_end_matches('\\');
for file in &dir.files {
let file_path = if dir_path.is_empty() {
file.name.clone()
} else {
format!("{dir_path}\\{}", file.name)
};
node.files.push((file.name.clone(), file.size, file_path));
}
}
let mut file_count = 0;
let mut folder_count = 0;
let nodes = root.into_nodes(&mut file_count, &mut folder_count);
BuiltTree {
nodes,
file_count,
folder_count,
}
}
/// One rendered/navigable row of the flattened tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrowseRow {
pub depth: usize,
pub is_folder: bool,
pub expanded: bool,
pub name: String,
pub path: String,
pub size: Option<u64>,
}
/// Flatten the tree into visible rows, descending only into expanded folders.
#[must_use]
pub fn flatten_browse(
nodes: &[BrowseNode],
expanded: &HashSet<String>,
) -> Vec<BrowseRow> {
let mut rows = Vec::new();
flatten_into(nodes, 0, expanded, &mut rows);
rows
}
fn flatten_into(
nodes: &[BrowseNode],
depth: usize,
expanded: &HashSet<String>,
rows: &mut Vec<BrowseRow>,
) {
for node in nodes {
match node {
BrowseNode::Folder {
name,
path,
children,
} => {
let is_expanded = expanded.contains(path);
rows.push(BrowseRow {
depth,
is_folder: true,
expanded: is_expanded,
name: name.clone(),
path: path.clone(),
size: None,
});
if is_expanded {
flatten_into(children, depth + 1, expanded, rows);
}
}
BrowseNode::File { name, path, size } => {
rows.push(BrowseRow {
depth,
is_folder: false,
expanded: false,
name: name.clone(),
path: path.clone(),
size: Some(*size),
});
}
}
}
}
/// Find a node by its full path.
#[must_use]
pub fn find_node<'a>(
nodes: &'a [BrowseNode],
path: &str,
) -> Option<&'a BrowseNode> {
for node in nodes {
match node {
BrowseNode::Folder {
path: p, children, ..
} => {
if p == path {
return Some(node);
}
if let Some(found) = find_node(children, path) {
return Some(found);
}
}
BrowseNode::File { path: p, .. } => {
if p == path {
return Some(node);
}
}
}
}
None
}
/// All file leaves under `node` as `(full_path, size)`, descending the tree
/// structurally (never string-prefix matching).
#[must_use]
pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> {
let mut out = Vec::new();
collect_files(node, &mut out);
out
}
fn collect_files(node: &BrowseNode, out: &mut Vec<(String, u64)>) {
match node {
BrowseNode::File { path, size, .. } => out.push((path.clone(), *size)),
BrowseNode::Folder { children, .. } => {
for child in children {
collect_files(child, out);
}
}
}
}
/// Loading state of an in-flight browse.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowseStatus {
Loading,
Loaded,
Empty,
TimedOut,
}
/// UI state for browsing one user.
pub struct BrowseState {
pub username: String,
pub status: BrowseStatus,
pub tree: Vec<BrowseNode>,
pub expanded: HashSet<String>,
pub selected_row: usize,
pub file_count: usize,
pub folder_count: usize,
pub requested_at: Instant,
}
impl BrowseState {
#[must_use]
pub fn loading(username: String) -> Self {
Self {
username,
status: BrowseStatus::Loading,
tree: Vec::new(),
expanded: HashSet::new(),
selected_row: 0,
file_count: 0,
folder_count: 0,
requested_at: Instant::now(),
}
}
/// Populate from a received listing: build the tree, auto-expand the top
/// level so structure is visible, and set Loaded/Empty.
pub fn load(&mut self, directories: &[SharedDirectory]) {
let built = build_browse_tree(directories);
self.expanded = built
.nodes
.iter()
.filter_map(|n| match n {
BrowseNode::Folder { path, .. } => Some(path.clone()),
BrowseNode::File { .. } => None,
})
.collect();
self.status = if built.nodes.is_empty() {
BrowseStatus::Empty
} else {
BrowseStatus::Loaded
};
self.tree = built.nodes;
self.file_count = built.file_count;
self.folder_count = built.folder_count;
self.selected_row = 0;
}
/// The flattened visible rows for the current expansion state.
#[must_use]
pub fn rows(&self) -> Vec<BrowseRow> {
flatten_browse(&self.tree, &self.expanded)
}
}
/// A set of open browse views (tabs), one per user, like the chat-room tabs.
#[derive(Default)]
pub struct BrowseTabs {
pub tabs: Vec<BrowseState>,
pub active: usize,
}
impl BrowseTabs {
#[must_use]
pub const fn new() -> Self {
Self {
tabs: Vec::new(),
active: 0,
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.tabs.is_empty()
}
#[must_use]
pub fn active_tab(&self) -> Option<&BrowseState> {
self.tabs.get(self.active)
}
#[must_use]
pub fn active_tab_mut(&mut self) -> Option<&mut BrowseState> {
self.tabs.get_mut(self.active)
}
/// Focus the tab for `username`, or open a fresh loading one. Returns
/// `true` if the caller should (re)issue a browse request — i.e. the tab is
/// new or was retried after a timeout.
pub fn open(&mut self, username: &str) -> bool {
if let Some(idx) = self.tabs.iter().position(|t| t.username == username)
{
self.active = idx;
// Re-request only if the previous attempt gave up.
if self.tabs[idx].status == BrowseStatus::TimedOut {
self.tabs[idx] = BrowseState::loading(username.to_string());
true
} else {
false
}
} else {
self.tabs.push(BrowseState::loading(username.to_string()));
self.active = self.tabs.len() - 1;
true
}
}
/// Retry the active tab if it timed out: reset it to loading and return the
/// username so the caller can re-issue the request. Returns `None` if the
/// active tab isn't in a retryable (timed-out) state.
pub fn retry_active(&mut self) -> Option<String> {
let tab = self.tabs.get_mut(self.active)?;
if tab.status == BrowseStatus::TimedOut {
let username = tab.username.clone();
*tab = BrowseState::loading(username.clone());
Some(username)
} else {
None
}
}
/// Close the active tab, returning `false` when no tabs remain.
pub fn close_active(&mut self) -> bool {
if self.active < self.tabs.len() {
self.tabs.remove(self.active);
}
if self.active >= self.tabs.len() {
self.active = self.tabs.len().saturating_sub(1);
}
!self.tabs.is_empty()
}
pub const fn next_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = (self.active + 1) % self.tabs.len();
}
}
pub const fn prev_tab(&mut self) {
if !self.tabs.is_empty() {
self.active = (self.active + self.tabs.len() - 1) % self.tabs.len();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory {
SharedDirectory {
name: name.to_string(),
files: files
.iter()
.map(|(n, s)| soulseek_rs::SharedFileEntry {
name: (*n).to_string(),
size: *s,
attributes: Vec::new(),
})
.collect(),
}
}
#[test]
fn intermediate_folders_are_synthesized() {
let tree =
build_browse_tree(&[dir("share\\music\\album", &[("a.mp3", 10)])]);
// share -> music -> album -> a.mp3
assert_eq!(tree.file_count, 1);
assert_eq!(tree.folder_count, 3);
let BrowseNode::Folder {
name,
path,
children,
} = &tree.nodes[0]
else {
panic!("expected a folder");
};
assert_eq!(name, "share");
assert_eq!(path, "share");
assert!(
matches!(&children[0], BrowseNode::Folder { name, .. } if name == "music")
);
}
#[test]
fn same_named_dirs_merge_regardless_of_order() {
let tree = build_browse_tree(&[
dir("share\\music\\album", &[("a.mp3", 1)]),
dir("share\\music", &[("b.mp3", 2)]),
]);
// A single "share" -> single "music" holding album/ and b.mp3.
assert_eq!(tree.nodes.len(), 1);
let BrowseNode::Folder {
children: share_kids,
..
} = &tree.nodes[0]
else {
panic!();
};
assert_eq!(share_kids.len(), 1); // just "music"
let BrowseNode::Folder {
children: music_kids,
..
} = &share_kids[0]
else {
panic!();
};
// folders first (album), then files (b.mp3)
assert_eq!(music_kids.len(), 2);
assert!(
matches!(&music_kids[0], BrowseNode::Folder { name, .. } if name == "album")
);
assert!(
matches!(&music_kids[1], BrowseNode::File { name, .. } if name == "b.mp3")
);
}
#[test]
fn file_paths_are_byte_exact_from_original_name() {
// Trailing backslash and a root-level (empty name) directory.
let tree = build_browse_tree(&[
dir("share\\music\\", &[("song.flac", 5)]),
dir("", &[("root.txt", 3)]),
]);
let files = collect_all(&tree.nodes);
assert!(files.contains(&("share\\music\\song.flac".to_string(), 5)));
assert!(files.contains(&("root.txt".to_string(), 3)));
}
fn collect_all(nodes: &[BrowseNode]) -> Vec<(String, u64)> {
let mut out = Vec::new();
for n in nodes {
out.extend(files_under(n));
}
out
}
#[test]
fn flatten_only_descends_into_expanded_folders() {
let tree = build_browse_tree(&[dir("a\\b", &[("f.mp3", 1)])]);
let mut expanded = HashSet::new();
// Nothing expanded: only the top folder "a" shows.
assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 1);
expanded.insert("a".to_string());
assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 2); // a, b
expanded.insert("a\\b".to_string());
assert_eq!(flatten_browse(&tree.nodes, &expanded).len(), 3); // a, b, f
}
#[test]
fn files_under_collects_all_descendant_leaves() {
let tree = build_browse_tree(&[
dir("a\\b", &[("x.mp3", 1)]),
dir("a\\c", &[("y.mp3", 2), ("z.mp3", 3)]),
]);
let all = files_under(&tree.nodes[0]); // folder "a"
assert_eq!(all.len(), 3);
}
#[test]
fn browse_tabs_open_focus_close_and_cycle() {
let mut tabs = BrowseTabs::new();
assert!(tabs.is_empty());
// New users open new tabs and request a browse.
assert!(tabs.open("alice"));
assert!(tabs.open("bob"));
assert_eq!(tabs.tabs.len(), 2);
assert_eq!(tabs.active, 1);
assert_eq!(tabs.active_tab().unwrap().username, "bob");
// Re-opening a live tab just focuses it (no new request).
assert!(!tabs.open("alice"));
assert_eq!(tabs.active, 0);
assert_eq!(tabs.tabs.len(), 2);
// Cycling wraps.
tabs.next_tab();
assert_eq!(tabs.active, 1);
tabs.next_tab();
assert_eq!(tabs.active, 0);
tabs.prev_tab();
assert_eq!(tabs.active, 1);
// Closing the active tab returns whether any remain.
assert!(tabs.close_active());
assert_eq!(tabs.tabs.len(), 1);
assert!(!tabs.close_active());
assert!(tabs.is_empty());
}
#[test]
fn browse_tabs_reopen_after_timeout_requests_again() {
let mut tabs = BrowseTabs::new();
tabs.open("alice");
tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut;
// Re-opening a timed-out tab retries (returns true) and resets it.
assert!(tabs.open("alice"));
assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading);
}
#[test]
fn browse_tabs_retry_active_only_when_timed_out() {
let mut tabs = BrowseTabs::new();
tabs.open("alice");
assert_eq!(tabs.retry_active(), None);
tabs.active_tab_mut().unwrap().status = BrowseStatus::TimedOut;
// Timed-out tab retries and resets to loading.
assert_eq!(tabs.retry_active().as_deref(), Some("alice"));
assert_eq!(tabs.active_tab().unwrap().status, BrowseStatus::Loading);
// Now loading again, no further retry.
assert_eq!(tabs.retry_active(), None);
}
#[test]
fn browse_state_load_auto_expands_top_level() {
let mut state = BrowseState::loading("bob".to_string());
state.load(&[dir("music\\album", &[("a.mp3", 1)])]);
assert_eq!(state.status, BrowseStatus::Loaded);
// Top-level "music" auto-expanded → music and album rows visible.
assert!(state.rows().len() >= 2);
let mut empty = BrowseState::loading("nobody".to_string());
empty.load(&[]);
assert_eq!(empty.status, BrowseStatus::Empty);
}
}