Merge remote-tracking branch 'origin/develop' into feat/room-member-stats

# Conflicts:
#	soulseek-rs-lib/src/client/tests.rs
#	soulseek-rs-lib/tests/e2e.rs
#	soulseek-rs/src/daemon/openrpc.rs
#	soulseek-rs/src/daemon/proto/method.rs
#	soulseek-rs/src/daemon/server/mod.rs
#	soulseek-rs/src/remote/mod.rs
This commit is contained in:
Michel de Graaf
2026-08-28 15:18:37 +00:00
22 changed files with 845 additions and 15 deletions
+77
View File
@@ -246,6 +246,15 @@ pub enum ClientOperation {
shared_files: u32,
shared_folders: u32,
},
/// The server answered `WatchUser` for a user we started watching.
WatchedUserReceived {
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
},
PeerConnected(String),
/// A search distributed to us by the server; reply if our shares match.
IncomingSearch {
@@ -337,6 +346,10 @@ pub struct ClientContext {
/// What the server has told us about other users, merged across the
/// separate status and statistics replies.
user_info: HashMap<String, UserInfo>,
/// Users we asked the server to watch (code 5), so it keeps pushing their
/// status changes. Kept so a UI can render the watch list and so an
/// unwatch can be rejected for someone we never watched.
watched_users: HashSet<String>,
/// Seconds the server wants between wishlist searches (code 104), once it
/// has told us.
wishlist_interval: Option<u32>,
@@ -426,6 +439,7 @@ impl ClientContext {
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
@@ -513,6 +527,69 @@ impl ClientContext {
});
}
/// Record that we are now watching `username`.
pub fn add_watched_user(&mut self, username: &str) {
self.watched_users.insert(username.to_string());
}
/// Forget a watch, dropping any snapshot we held for that user so a later
/// re-watch reports a fresh answer rather than a stale one.
pub fn remove_watched_user(&mut self, username: &str) {
self.watched_users.remove(username);
self.user_info.remove(username);
}
/// Everyone we are currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
let mut users: Vec<String> =
self.watched_users.iter().cloned().collect();
users.sort();
users
}
/// Record the initial snapshot from a `WatchUser` reply. A username the
/// server does not know carries no stats and is dropped from the watch
/// list, since the server will never push anything for it.
pub fn apply_watched_user(
&mut self,
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
if !exists {
self.watched_users.remove(&username);
return;
}
if let Some(status) = status {
let entry = self
.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username.clone()));
// The watch reply carries no privileged flag, so keep whatever a
// previous GetUserStatus told us rather than asserting `false`.
let privileged =
entry.presence.as_ref().is_some_and(|p| p.privileged);
entry.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
if let (Some(average_speed), Some(shared_files), Some(shared_folders)) =
(average_speed, shared_files, shared_folders)
{
self.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
}
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
+20
View File
@@ -473,6 +473,26 @@ impl Client {
error!("[client] UserStatsReceived write: {}", e);
}
},
ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_watched_user(
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
),
Err(e) => {
error!("[client] WatchedUserReceived write: {}", e);
}
},
ClientOperation::RoomEvent(event) => {
match client_context.write_safe() {
Ok(mut ctx) => ctx.apply_room_event(event),
+40
View File
@@ -138,6 +138,46 @@ impl Client {
}
}
/// Ask the server to watch `username` (code 5).
///
/// The server replies with that user's current status and share
/// statistics — readable with [`Client::user_info`] — and then keeps
/// pushing their status changes until [`Client::unwatch_user`]. Watching
/// a user the server does not know is answered with a "does not exist"
/// flag, and that user is dropped from [`Client::watched_users`].
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn watch_user(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.send_server_message(MessageFactory::build_watch_user(username))?;
self.context.write_safe()?.add_watched_user(username);
Ok(())
}
/// Stop watching `username` (code 6) and drop what we knew about them.
///
/// # Errors
/// Returns [`SoulseekRs::NotConnected`] if the client is not connected.
pub fn unwatch_user(&self, username: &str) -> Result<()> {
use crate::message::server::MessageFactory;
self.send_server_message(MessageFactory::build_unwatch_user(username))?;
self.context.write_safe()?.remove_watched_user(username);
Ok(())
}
/// Everyone this client is currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
match self.context.read_safe() {
Ok(ctx) => ctx.watched_users(),
Err(e) => {
error!("[client] watched_users: {}", e);
Vec::new()
}
}
}
/// Who is currently in `room`, sorted by name.
///
/// The roster is built from the membership the server sends when
+80
View File
@@ -672,3 +672,83 @@ fn rejoining_replaces_the_previous_member_stats() {
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}
#[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_watched_user(
"alice".to_string(),
true,
Some(2),
Some(1024),
Some(20),
Some(3),
);
let info = context.user_info("alice").expect("a snapshot");
assert!(info.is_complete(), "a watch reply carries status and stats");
assert_eq!(info.stats.map(|s| s.average_speed), Some(1024));
assert_eq!(context.watched_users(), vec!["alice".to_string()]);
}
#[test]
fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() {
// WatchUser has no privileged field, so it must not overwrite what
// GetUserStatus already told us with a fabricated `false`.
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
context.apply_watched_user(
"bob".to_string(),
true,
Some(2),
Some(1),
Some(2),
Some(3),
);
let info = context.user_info("bob").expect("a snapshot");
assert_eq!(info.presence.map(|p| p.privileged), Some(true));
}
#[test]
fn watching_an_unknown_user_drops_them_from_the_list() {
// The server will never push status for a name it does not know, so
// keeping it in the watch list would show a permanently blank row.
let mut context = ClientContext::new();
context.add_watched_user("ghost");
context.apply_watched_user(
"ghost".to_string(),
false,
None,
None,
None,
None,
);
assert!(context.watched_users().is_empty());
assert!(context.user_info("ghost").is_none());
}
#[test]
fn unwatching_forgets_the_users_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_user_status("alice".to_string(), 2, false);
context.remove_watched_user("alice");
assert!(context.watched_users().is_empty());
assert!(
context.user_info("alice").is_none(),
"a later re-watch must report a fresh answer"
);
}
#[test]
fn watched_users_are_listed_in_a_stable_order() {
let mut context = ClientContext::new();
context.add_watched_user("carol");
context.add_watched_user("alice");
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}