Merge pull request #50 from michel/feat/room-member-stats

feat: per-member room statistics from the JoinRoom reply
This commit is contained in:
Michel de Graaf
2026-08-28 17:38:44 +02:00
committed by GitHub
20 changed files with 761 additions and 46 deletions
+13 -1
View File
@@ -28,7 +28,8 @@ use crate::message::{Message, MessageReader};
use crate::peer::ConnectionType; use crate::peer::ConnectionType;
use crate::peer::Peer; use crate::peer::Peer;
use crate::types::{ use crate::types::{
ClientVersion, RoomEvent, RoomInfo, SessionLoss, SessionWatch, ClientVersion, RoomEvent, RoomInfo, RoomUserStats, SessionLoss,
SessionWatch,
}; };
use crate::utils::lock::RwLockExt; use crate::utils::lock::RwLockExt;
@@ -225,6 +226,11 @@ pub enum ServerMessage {
room: String, room: String,
users: Vec<String>, users: Vec<String>,
}, },
/// Per-member statistics carried by the same `JoinRoom` (code 14) reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
RoomLeft { RoomLeft {
room: String, room: String,
}, },
@@ -510,6 +516,12 @@ impl ServerActor {
ServerMessage::RoomJoined { room, users } => { ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users }); self.forward_room_event(RoomEvent::Joined { room, users });
} }
ServerMessage::RoomMemberStats { room, stats } => {
self.forward_to_client(ClientOperation::RoomMemberStats {
room,
stats,
});
}
ServerMessage::RoomLeft { room } => { ServerMessage::RoomLeft { room } => {
self.forward_room_event(RoomEvent::Left { room }); self.forward_room_event(RoomEvent::Left { room });
} }
+34 -1
View File
@@ -5,7 +5,8 @@ use crate::actor::server_actor::{
use crate::download_store::{DownloadStore, collect_failed_tokens}; use crate::download_store::{DownloadStore, collect_failed_tokens};
use crate::types::{ use crate::types::{
ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo,
SessionLoss, SessionWatch, UserInfo, UserPresence, UserStats, UserStatus, RoomUserStats, SessionLoss, SessionWatch, UserInfo, UserPresence,
UserStats, UserStatus,
}; };
use crate::utils::logger; use crate::utils::logger;
use crate::{ use crate::{
@@ -287,6 +288,11 @@ pub enum ClientOperation {
/// Something happened in the chat-room subsystem (list refreshed, a room /// Something happened in the chat-room subsystem (list refreshed, a room
/// joined/left, a message said, a member joined/left). /// joined/left, a message said, a member joined/left).
RoomEvent(RoomEvent), RoomEvent(RoomEvent),
/// Per-member statistics from a `JoinRoom` reply.
RoomMemberStats {
room: String,
stats: Vec<RoomUserStats>,
},
/// The server announced how many seconds must pass between wishlist /// The server announced how many seconds must pass between wishlist
/// searches. /// searches.
WishlistInterval(u32), WishlistInterval(u32),
@@ -334,6 +340,9 @@ pub struct ClientContext {
/// Who is in each room we have joined, kept current from the membership /// Who is in each room we have joined, kept current from the membership
/// the server sends on join plus the later join/leave events. /// the server sends on join plus the later join/leave events.
room_members: HashMap<String, Vec<String>>, room_members: HashMap<String, Vec<String>>,
/// Per-member statistics for each joined room, from the stat vectors the
/// server sends alongside the membership list.
room_member_stats: HashMap<String, Vec<RoomUserStats>>,
/// What the server has told us about other users, merged across the /// What the server has told us about other users, merged across the
/// separate status and statistics replies. /// separate status and statistics replies.
user_info: HashMap<String, UserInfo>, user_info: HashMap<String, UserInfo>,
@@ -428,6 +437,7 @@ impl ClientContext {
room_list: Vec::new(), room_list: Vec::new(),
room_events: Vec::new(), room_events: Vec::new(),
room_members: HashMap::new(), room_members: HashMap::new(),
room_member_stats: HashMap::new(),
user_info: HashMap::new(), user_info: HashMap::new(),
watched_users: HashSet::new(), watched_users: HashSet::new(),
wishlist_interval: None, wishlist_interval: None,
@@ -592,6 +602,29 @@ impl ClientContext {
self.room_members.get(room).cloned().unwrap_or_default() self.room_members.get(room).cloned().unwrap_or_default()
} }
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list. /// The latest snapshot of the public chat-room list.
#[must_use] #[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> { pub fn room_list(&self) -> Vec<RoomInfo> {
+10
View File
@@ -499,6 +499,16 @@ impl Client {
Err(e) => error!("[client] RoomEvent write: {}", e), Err(e) => error!("[client] RoomEvent write: {}", e),
} }
} }
ClientOperation::RoomMemberStats { room, stats } => {
match client_context.write_safe() {
Ok(mut ctx) => {
ctx.apply_room_member_stats(room, stats);
}
Err(e) => {
error!("[client] RoomMemberStats write: {}", e);
}
}
}
ClientOperation::WishlistInterval(seconds) => { ClientOperation::WishlistInterval(seconds) => {
if let Ok(mut ctx) = client_context.write_safe() { if let Ok(mut ctx) = client_context.write_safe() {
ctx.wishlist_interval = Some(seconds); ctx.wishlist_interval = Some(seconds);
+19 -1
View File
@@ -2,7 +2,7 @@ use super::{
Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage, Client, PeerMessage, Result, RoomEvent, RoomInfo, RwLockExt, ServerMessage,
SharedDirectory, SoulseekRs, UserMessage, error, SharedDirectory, SoulseekRs, UserMessage, error,
}; };
use crate::types::UserInfo; use crate::types::{RoomUserStats, UserInfo};
impl Client { impl Client {
/// Send a private message to another user via the server. /// Send a private message to another user via the server.
@@ -196,6 +196,24 @@ impl Client {
} }
} }
/// What the server reported about the members of `room` when we joined:
/// their status, share statistics, free upload slots and country.
///
/// Populated from the same reply that fills [`Client::room_members`], so
/// it lands shortly after joining. Unlike the roster it is not updated by
/// later join and leave events -- it is the snapshot taken at join time.
/// Returns an empty list for a room this client has not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
match self.context.read_safe() {
Ok(ctx) => ctx.room_member_stats(room),
Err(e) => {
error!("[client] room_member_stats: {}", e);
Vec::new()
}
}
}
/// Remove and return all chat-room events received since the last call. /// Remove and return all chat-room events received since the last call.
#[must_use] #[must_use]
pub fn take_room_events(&self) -> Vec<RoomEvent> { pub fn take_room_events(&self) -> Vec<RoomEvent> {
+50
View File
@@ -623,6 +623,56 @@ fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
); );
} }
#[test]
fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: true,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats(
"jazz".to_string(),
vec![stat("carol"), stat("alice")],
);
let names: Vec<String> = context
.room_member_stats("jazz")
.into_iter()
.map(|s| s.username)
.collect();
assert_eq!(names, vec!["alice", "carol"]);
assert!(context.room_member_stats("unjoined").is_empty());
}
#[test]
fn rejoining_replaces_the_previous_member_stats() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: false,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]);
context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]);
let stats = context.room_member_stats("jazz");
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}
#[test] #[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() { fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new(); let mut context = ClientContext::new();
+244 -10
View File
@@ -1,6 +1,7 @@
use crate::{ use crate::{
actor::server_actor::ServerMessage, actor::server_actor::ServerMessage,
message::{Message, MessageHandler}, message::{Message, MessageHandler},
types::{RoomUserStats, UserStatus},
}; };
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
@@ -12,22 +13,103 @@ impl MessageHandler<ServerMessage> for JoinRoomHandler {
} }
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) { fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
// JoinRoom (code 14): room name, then a vector of member usernames. // JoinRoom (code 14): room name, the member usernames, then the
// Per-user stat vectors follow but are not needed here, so we stop // parallel per-member stat vectors.
// after reading the names.
let room = message.read_string(); let room = message.read_string();
let user_count = message.read_int32(); let users = read_strings(message);
let mut users = Vec::new(); let stats = parse_member_stats(message, &users);
for _ in 0..user_count { let _ = sender.send(ServerMessage::RoomJoined {
// Guard against a hostile user_count outrunning the payload; each room: room.clone(),
// username is a length-prefixed string (>= 4 bytes). users,
});
if !stats.is_empty() {
let _ = sender.send(ServerMessage::RoomMemberStats { room, stats });
}
}
}
/// Read a length-prefixed vector of strings, stopping early if the count
/// outruns the payload: a bogus (possibly hostile) count must not spin us
/// into an OOM allocation loop. A 4-byte floor is the smallest a
/// length-prefixed string can be.
fn read_strings(message: &mut Message) -> Vec<String> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() { if message.get_pointer() + 4 > message.get_size() {
break; break;
} }
users.push(message.read_string()); values.push(message.read_string());
} }
let _ = sender.send(ServerMessage::RoomJoined { room, users }); values
}
/// Read a length-prefixed vector of 32-bit values, bounded the same way.
fn read_int32s(message: &mut Message) -> Vec<u32> {
let count = message.read_int32();
let mut values = Vec::new();
for _ in 0..count {
if message.get_pointer() + 4 > message.get_size() {
break;
} }
values.push(message.read_int32());
}
values
}
/// Parse the per-member statistics that follow the membership list of a
/// `JoinRoom` (code 14) reply: statuses, then a record of share statistics per
/// member, then whether each member's upload slots are full, then country
/// codes.
///
/// Each vector is independently length-prefixed and a server may send fewer
/// entries than there are members (or, on an older server, omit the country
/// vector entirely), so every field is looked up positionally and defaults
/// when absent rather than shifting the whole roster.
#[must_use]
fn parse_member_stats(
message: &mut Message,
users: &[String],
) -> Vec<RoomUserStats> {
let statuses = read_int32s(message);
let stat_count = message.read_int32();
let mut shares = Vec::new();
for _ in 0..stat_count {
// A share record is 20 bytes: speed, an obsolete 64-bit upload
// number, files and folders.
if message.get_pointer() + 20 > message.get_size() {
break;
}
let average_speed = message.read_int32();
let _upload_number = message.read_int64(); // obsolete
let shared_files = message.read_int32();
let shared_folders = message.read_int32();
shares.push((average_speed, shared_files, shared_folders));
}
let slots_full = read_int32s(message);
let countries = read_strings(message);
users
.iter()
.enumerate()
.map(|(i, username)| {
let (average_speed, shared_files, shared_folders) =
shares.get(i).copied().unwrap_or((0, 0, 0));
RoomUserStats {
username: username.clone(),
status: UserStatus::from_code(
statuses.get(i).copied().unwrap_or(0),
),
average_speed,
shared_files,
shared_folders,
slots_full: slots_full.get(i).copied().unwrap_or(0) != 0,
country: countries.get(i).cloned().filter(|c| !c.is_empty()),
}
})
.collect()
} }
#[cfg(test)] #[cfg(test)]
@@ -53,6 +135,158 @@ mod tests {
} }
} }
/// A full JoinRoom payload: room, members, then the four parallel stat
/// vectors the server sends for them.
fn join_payload() -> Message {
framed(|m| {
m.write_string("nicotine")
.write_int32(2)
.write_string("alice")
.write_string("bob")
// statuses
.write_int32(2)
.write_int32(2)
.write_int32(1)
// share records
.write_int32(2)
.write_int32(1024)
.write_int64(0)
.write_int32(4321)
.write_int32(77)
.write_int32(50)
.write_int64(0)
.write_int32(10)
.write_int32(2)
// slots full
.write_int32(2)
.write_int32(1)
.write_int32(0)
// countries
.write_int32(2)
.write_string("NL")
.write_string("");
})
}
#[test]
fn member_stats_are_parsed_alongside_the_roster() {
let (tx, rx) = std::sync::mpsc::channel();
JoinRoomHandler.handle(&mut join_payload(), tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { room, stats }) => {
assert_eq!(room, "nicotine");
assert_eq!(
stats,
vec![
RoomUserStats {
username: "alice".to_string(),
status: UserStatus::Online,
average_speed: 1024,
shared_files: 4321,
shared_folders: 77,
slots_full: true,
country: Some("NL".to_string()),
},
RoomUserStats {
username: "bob".to_string(),
status: UserStatus::Away,
average_speed: 50,
shared_files: 10,
shared_folders: 2,
slots_full: false,
country: None,
},
]
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn a_server_that_omits_the_stat_vectors_still_yields_a_roster() {
// Nothing after the membership list: every stat reads as a default
// rather than shifting the roster or dropping members.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz").write_int32(1).write_string("alice");
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].username, "alice");
assert_eq!(stats[0].average_speed, 0);
assert_eq!(stats[0].country, None);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn short_stat_vectors_do_not_shift_the_roster() {
// Stats for the first member only: the second must still be reported,
// with defaults, rather than inheriting the first member's figures.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(2)
.write_string("alice")
.write_string("bob")
.write_int32(1)
.write_int32(2)
.write_int32(1)
.write_int32(1024)
.write_int64(0)
.write_int32(9)
.write_int32(1)
.write_int32(0)
.write_int32(0);
});
JoinRoomHandler.handle(&mut message, tx);
let _ = rx.try_recv();
match rx.try_recv() {
Ok(ServerMessage::RoomMemberStats { stats, .. }) => {
assert_eq!(stats.len(), 2);
assert_eq!(stats[0].shared_files, 9);
assert_eq!(stats[1].username, "bob");
assert_eq!(stats[1].shared_files, 0);
assert_eq!(stats[1].status, UserStatus::Offline);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn hostile_stat_counts_do_not_hang_or_overallocate() {
// Vectors claiming ~4 billion entries in a tiny frame must return
// promptly, bounded by the payload.
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_string("jazz")
.write_int32(1)
.write_string("alice")
.write_int32(u32::MAX);
});
JoinRoomHandler.handle(&mut message, tx);
assert!(matches!(
rx.try_recv(),
Ok(ServerMessage::RoomJoined { .. })
));
}
#[test] #[test]
fn forwards_room_and_member_list() { fn forwards_room_and_member_list() {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
+21
View File
@@ -328,6 +328,27 @@ impl UserInfo {
} }
} }
/// What the server reports about one member of a room we joined, from the
/// per-member stat vectors of a `JoinRoom` (code 14) reply.
///
/// A server may send shorter stat vectors than the membership list (older
/// servers omit country codes entirely), so absent fields read as zero and an
/// unreported country is `None` rather than an empty string.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomUserStats {
pub username: String,
pub status: UserStatus,
pub average_speed: u32,
pub shared_files: u32,
pub shared_folders: u32,
/// True when the member's upload slots are all occupied, so a download
/// from them will queue. The wire field is `slotsfull`, a boolean — not a
/// count, and not a count of *free* slots.
pub slots_full: bool,
/// Two-letter country code, when the server reports one.
pub country: Option<String>,
}
/// A public chat room advertised by the server (`RoomList`, code 64). /// A public chat room advertised by the server (`RoomList`, code 64).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoomInfo { pub struct RoomInfo {
+81
View File
@@ -2623,6 +2623,87 @@ fn a_peer_that_hangs_up_its_control_connection_still_delivers_the_file() {
let _ = std::fs::remove_dir_all(&download_dir); let _ = std::fs::remove_dir_all(&download_dir);
} }
#[test]
fn a_joined_room_reports_its_members_statistics() {
let server = server_or_skip!();
let room = "e2e_room_stats";
// A member with a known share, so the statistics are checkable rather
// than merely present.
let share_dir = unique_download_dir().join("stats_share");
std::fs::create_dir_all(share_dir.join("album")).unwrap();
for name in ["one.flac", "two.flac", "three.flac"] {
std::fs::write(share_dir.join("album").join(name), b"xxxx").unwrap();
}
let mut sharer = Client::with_settings(ClientSettings {
shared_directories: vec![share_dir.display().to_string()],
..server.settings("e2e_stats_sharer", "pw")
});
sharer.connect().expect("sharer connect");
assert!(sharer.login().expect("sharer login"));
sharer.join_room(room).expect("sharer joins room");
// Let the server register the sharer's membership and share counts, so
// the observer's own join reply describes a room that already has them.
std::thread::sleep(Duration::from_millis(750));
let mut observer =
Client::with_settings(server.settings("e2e_stats_observer", "pw"));
observer.connect().expect("observer connect");
assert!(observer.login().expect("observer login"));
observer.join_room(room).expect("observer joins room");
// The stats ride along with the membership list, so poll for the member
// rather than for a fixed delay.
let deadline = Instant::now() + Duration::from_secs(15);
let mut sharer_stats = None;
while Instant::now() < deadline {
if let Some(found) = observer
.room_member_stats(room)
.into_iter()
.find(|s| s.username == "e2e_stats_sharer")
{
sharer_stats = Some(found);
break;
}
std::thread::sleep(Duration::from_millis(100));
}
let stats =
sharer_stats.expect("the join reply should describe the members");
assert!(
stats.status.is_reachable(),
"a member who is in the room cannot read as offline, got {}",
stats.status
);
assert_eq!(
stats.shared_files, 3,
"the member's file count must survive the parallel stat vectors"
);
assert_eq!(stats.shared_folders, 1);
assert!(
!stats.slots_full,
"a client that never filled its slots must not read as full"
);
// Every member is described, not just the one we looked up: a short or
// misread vector would drop or shift the rest.
let members = observer.room_member_stats(room);
let names: Vec<&str> =
members.iter().map(|s| s.username.as_str()).collect();
assert!(
names.contains(&"e2e_stats_sharer")
&& names.contains(&"e2e_stats_observer"),
"both members should be described, got {names:?}"
);
assert_eq!(
members.len(),
observer.room_members(room).len(),
"the stats must line up one-to-one with the roster"
);
}
#[test] #[test]
fn watching_a_user_returns_their_status_and_share_counts() { fn watching_a_user_returns_their_status_and_share_counts() {
let server = server_or_skip!(); let server = server_or_skip!();
+81
View File
@@ -525,6 +525,21 @@
], ],
"type": "object" "type": "object"
}, },
"MemberStats": {
"description": "What the server said about each member of a room when it was joined.",
"properties": {
"members": {
"items": {
"$ref": "#/components/schemas/RoomUserStatsDto"
},
"type": "array"
}
},
"required": [
"members"
],
"type": "object"
},
"Members": { "Members": {
"properties": { "properties": {
"users": { "users": {
@@ -733,6 +748,52 @@
], ],
"type": "object" "type": "object"
}, },
"RoomUserStatsDto": {
"description": "One member of a room, as the JoinRoom reply described them.",
"properties": {
"average_speed": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"country": {
"type": [
"string",
"null"
]
},
"shared_files": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"shared_folders": {
"format": "uint32",
"minimum": 0,
"type": "integer"
},
"slots_full": {
"description": "True when the member's upload slots are all occupied.",
"type": "boolean"
},
"status": {
"description": "`online`, `away`, or `offline`.",
"type": "string"
},
"username": {
"type": "string"
}
},
"required": [
"username",
"status",
"average_speed",
"shared_files",
"shared_folders",
"slots_full"
],
"type": "object"
},
"SayParams": { "SayParams": {
"properties": { "properties": {
"message": { "message": {
@@ -1791,6 +1852,26 @@
}, },
"summary": "Who is currently in a room." "summary": "Who is currently in a room."
}, },
{
"name": "room.member_stats",
"paramStructure": "by-name",
"params": [
{
"name": "room",
"required": true,
"schema": {
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/MemberStats"
}
},
"summary": "What the server said about each member of a joined room."
},
{ {
"name": "message.send", "name": "message.send",
"paramStructure": "by-name", "paramStructure": "by-name",
+5 -1
View File
@@ -147,7 +147,11 @@ cheapest way to prove the credentials and connection work.
server did not answer — those are different facts, so do not treat null as 0. server did not answer — those are different facts, so do not treat null as 0.
**`room list`** — `room`, `users`. **`room list`** — `room`, `users`.
**`room users <room>`** — `room`, `user`. **`room users <room>`** — `room`, `user`, `status`, `average_speed`,
`shared_files`, `shared_folders`, `slots_full`, `country`. Everything but
`room` and `user` is `null` when the server sent no statistics for that
member — `null` means unknown, not zero. `slots_full` is true when the peer's
upload slots are all occupied, so a download from them will queue.
**`room listen <room>`** — streams `type`, `room`, `user`, `message`. **`room listen <room>`** — streams `type`, `room`, `user`, `message`.
**`room say <room> <message>`** — prints nothing; exit 0 is the confirmation. **`room say <room> <message>`** — prints nothing; exit 0 is the confirmation.
+8 -1
View File
@@ -13,7 +13,7 @@
//! remote session has no borrowed client to hand a `&str` out of. //! remote session has no borrowed client to hand a `&str` out of.
use crate::daemon::proto::ChatMessageDto; use crate::daemon::proto::ChatMessageDto;
use soulseek_rs::types::{Download, DownloadMetadata}; use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats};
use soulseek_rs::{ use soulseek_rs::{
Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss,
SharedDirectory, UploadInfo, UserInfo, UserMessage, SharedDirectory, UploadInfo, UserInfo, UserMessage,
@@ -126,6 +126,9 @@ pub trait SessionApi: Send + Sync {
fn leave_room(&self, room: &str) -> Result<()>; fn leave_room(&self, room: &str) -> Result<()>;
fn say_in_room(&self, room: &str, message: &str) -> Result<()>; fn say_in_room(&self, room: &str, message: &str) -> Result<()>;
fn room_members(&self, room: &str) -> Vec<String>; fn room_members(&self, room: &str) -> Vec<String>;
/// What the server said about each member when the room was joined:
/// status, share statistics, free upload slots and country.
fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats>;
fn take_room_events(&self) -> Vec<RoomEvent>; fn take_room_events(&self) -> Vec<RoomEvent>;
fn send_private_message(&self, username: &str, message: &str) fn send_private_message(&self, username: &str, message: &str)
@@ -335,6 +338,10 @@ impl SessionApi for Client {
Self::room_members(self, room) Self::room_members(self, room)
} }
fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
Self::room_member_stats(self, room)
}
fn take_room_events(&self) -> Vec<RoomEvent> { fn take_room_events(&self) -> Vec<RoomEvent> {
Self::take_room_events(self) Self::take_room_events(self)
} }
+11
View File
@@ -151,10 +151,21 @@ pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult {
timeout.as_secs() timeout.as_secs()
))); )));
}; };
// The stats ride along with the membership the server sent, so they are
// already here; a server that sent none leaves every field null rather
// than reporting a zero that reads like an answer.
let stats = session.client.room_member_stats(room);
for user in members { for user in members {
let stat = stats.iter().find(|s| s.username == user);
ctx.out.emit(&RoomMemberRecord { ctx.out.emit(&RoomMemberRecord {
room: room.to_string(), room: room.to_string(),
user, user,
status: stat.map(|s| s.status.to_string()),
average_speed: stat.map(|s| s.average_speed),
shared_files: stat.map(|s| s.shared_files),
shared_folders: stat.map(|s| s.shared_folders),
slots_full: stat.map(|s| s.slots_full),
country: stat.and_then(|s| s.country.clone()),
}); });
} }
Ok(()) Ok(())
+13 -8
View File
@@ -11,11 +11,11 @@
use super::proto::{ use super::proto::{
Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams,
DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent,
Downloads, Event, IntervalSeconds, Members, MessageParams, Messages, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams,
Method, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, Messages, Method, QueryParams, RoomEventDto, RoomRef, SayParams,
Searches, Seconds, SessionLossEvent, SharesStatus, SlotsParams, SearchResults, Searches, Seconds, SessionLossEvent, SharesStatus,
TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult, SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef,
Watched, UserResult, Watched,
}; };
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -47,9 +47,10 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
| Method::UploadCancel => schema_of::<TransferRef>(generator), | Method::UploadCancel => schema_of::<TransferRef>(generator),
Method::DownloadSetDir => schema_of::<DirectoryParams>(generator), Method::DownloadSetDir => schema_of::<DirectoryParams>(generator),
Method::UploadSlots => schema_of::<SlotsParams>(generator), Method::UploadSlots => schema_of::<SlotsParams>(generator),
Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => { Method::RoomJoin
schema_of::<RoomRef>(generator) | Method::RoomLeave
} | Method::RoomMembers
| Method::RoomMemberStats => schema_of::<RoomRef>(generator),
Method::RoomSay => schema_of::<SayParams>(generator), Method::RoomSay => schema_of::<SayParams>(generator),
Method::MessageSend => schema_of::<MessageParams>(generator), Method::MessageSend => schema_of::<MessageParams>(generator),
Method::BrowseUser Method::BrowseUser
@@ -92,6 +93,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value {
Method::UploadList => schema_of::<Uploads>(generator), Method::UploadList => schema_of::<Uploads>(generator),
Method::PrivilegesOwn => schema_of::<Seconds>(generator), Method::PrivilegesOwn => schema_of::<Seconds>(generator),
Method::RoomMembers => schema_of::<Members>(generator), Method::RoomMembers => schema_of::<Members>(generator),
Method::RoomMemberStats => schema_of::<MemberStats>(generator),
Method::UserWatched => schema_of::<Watched>(generator), Method::UserWatched => schema_of::<Watched>(generator),
Method::MessageHistory => schema_of::<Messages>(generator), Method::MessageHistory => schema_of::<Messages>(generator),
Method::UserInfoOf => schema_of::<UserResult>(generator), Method::UserInfoOf => schema_of::<UserResult>(generator),
@@ -179,6 +181,9 @@ fn summary(method: Method) -> &'static str {
Method::RoomLeave => "Leave a chat room.", Method::RoomLeave => "Leave a chat room.",
Method::RoomSay => "Post a message to a room.", Method::RoomSay => "Post a message to a room.",
Method::RoomMembers => "Who is currently in a room.", Method::RoomMembers => "Who is currently in a room.",
Method::RoomMemberStats => {
"What the server said about each member of a joined room."
}
Method::UserWatch => { Method::UserWatch => {
"Watch a user, so the server keeps pushing their status." "Watch a user, so the server keeps pushing their status."
} }
+56 -6
View File
@@ -7,7 +7,8 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use soulseek_rs::types::{ use soulseek_rs::types::{
Download, DownloadMetadata, UserPresence, UserStats, UserStatus, Download, DownloadMetadata, RoomUserStats, UserPresence, UserStats,
UserStatus,
}; };
use soulseek_rs::{ use soulseek_rs::{
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss,
@@ -447,6 +448,59 @@ pub struct UserStatsDto {
pub shared_folders: u32, pub shared_folders: u32,
} }
/// The inverse of [`UserStatus`]'s `Display`. An unrecognised word reads as
/// offline, matching how the wire code is decoded.
fn status_from_str(status: &str) -> UserStatus {
match status {
"online" => UserStatus::Online,
"away" => UserStatus::Away,
_ => UserStatus::Offline,
}
}
/// One member of a room, as the JoinRoom reply described them.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct RoomUserStatsDto {
pub username: String,
/// `online`, `away`, or `offline`.
pub status: String,
pub average_speed: u32,
pub shared_files: u32,
pub shared_folders: u32,
/// True when the member's upload slots are all occupied.
pub slots_full: bool,
pub country: Option<String>,
}
impl From<&RoomUserStats> for RoomUserStatsDto {
fn from(stats: &RoomUserStats) -> Self {
Self {
username: stats.username.clone(),
status: stats.status.to_string(),
average_speed: stats.average_speed,
shared_files: stats.shared_files,
shared_folders: stats.shared_folders,
slots_full: stats.slots_full,
country: stats.country.clone(),
}
}
}
impl From<RoomUserStatsDto> for RoomUserStats {
fn from(dto: RoomUserStatsDto) -> Self {
Self {
username: dto.username,
status: status_from_str(&dto.status),
average_speed: dto.average_speed,
shared_files: dto.shared_files,
shared_folders: dto.shared_folders,
slots_full: dto.slots_full,
country: dto.country,
}
}
}
/// Each half is `None` until its reply lands, so a consumer can tell "the /// Each half is `None` until its reply lands, so a consumer can tell "the
/// server said offline" from "the server has not answered yet". /// server said offline" from "the server has not answered yet".
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
@@ -480,11 +534,7 @@ impl From<UserInfoDto> for UserInfo {
fn from(dto: UserInfoDto) -> Self { fn from(dto: UserInfoDto) -> Self {
let mut info = Self::pending(dto.username); let mut info = Self::pending(dto.username);
info.presence = dto.presence.map(|presence| UserPresence { info.presence = dto.presence.map(|presence| UserPresence {
status: match presence.status.as_str() { status: status_from_str(&presence.status),
"online" => UserStatus::Online,
"away" => UserStatus::Away,
_ => UserStatus::Offline,
},
privileged: presence.privileged, privileged: presence.privileged,
}); });
info.stats = dto.stats.map(|stats| UserStats { info.stats = dto.stats.map(|stats| UserStats {
+8
View File
@@ -1,5 +1,6 @@
//! What each method takes and returns, and what each pushed event carries. //! What each method takes and returns, and what each pushed event carries.
use super::dto::RoomUserStatsDto;
use super::dto::{ use super::dto::{
DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto, DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto,
SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto, SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto,
@@ -139,6 +140,13 @@ pub struct Members {
pub users: Vec<String>, pub users: Vec<String>,
} }
/// What the server said about each member of a room when it was joined.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
pub struct MemberStats {
pub members: Vec<RoomUserStatsDto>,
}
/// Everyone this session is watching, sorted by name. /// Everyone this session is watching, sorted by name.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))] #[cfg_attr(test, derive(schemars::JsonSchema))]
+1
View File
@@ -101,6 +101,7 @@ wire_vocabulary! {
RoomLeave => "room.leave", RoomLeave => "room.leave",
RoomSay => "room.say", RoomSay => "room.say",
RoomMembers => "room.members", RoomMembers => "room.members",
RoomMemberStats => "room.member_stats",
MessageSend => "message.send", MessageSend => "message.send",
MessageHistory => "message.history", MessageHistory => "message.history",
BrowseUser => "browse.user", BrowseUser => "browse.user",
+66 -8
View File
@@ -9,11 +9,12 @@ use super::hub::{Hub, PendingBrowses, PendingDownload};
use super::proto::{ use super::proto::{
Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto, Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto,
DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto,
DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, Members, DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds,
MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, MemberStats, Members, MessageParams, Messages, Method, OPENRPC,
RoomRef, RpcError, SayParams, SearchResultDto, SearchResults, PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError,
SearchSummary, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, SayParams, SearchResultDto, SearchResults, SearchSummary, Searches,
UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult, Watched, Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads,
UserInfoDto, UserRef, UserResult, Watched,
}; };
use crate::api::SessionApi; use crate::api::SessionApi;
use crate::output::Exit; use crate::output::Exit;
@@ -252,6 +253,18 @@ impl Daemon {
}) })
} }
Method::RoomMemberStats => {
let room: RoomRef = parse(params)?;
ok(MemberStats {
members: self
.session
.room_member_stats(&room.room)
.iter()
.map(RoomUserStatsDto::from)
.collect(),
})
}
Method::MessageSend => { Method::MessageSend => {
let message: MessageParams = parse(params)?; let message: MessageParams = parse(params)?;
self.session self.session
@@ -611,6 +624,29 @@ mod tests {
); );
} }
#[test]
fn member_stats_are_published_for_a_joined_room() {
let daemon = daemon_with_token("t");
let stats = daemon
.call(Method::RoomMemberStats, json!({ "room": STUB_ROOM }))
.expect("member stats");
assert_eq!(stats["members"][0]["username"], "alice");
assert_eq!(stats["members"][0]["status"], "online");
assert_eq!(stats["members"][0]["slots_full"], true);
assert_eq!(stats["members"][0]["country"], "NL");
}
#[test]
fn a_room_that_was_never_joined_has_no_member_stats() {
// Empty, not an error: asking about a room this session is not in is
// an ordinary thing for a client to do.
let daemon = daemon_with_token("t");
let stats = daemon
.call(Method::RoomMemberStats, json!({ "room": "elsewhere" }))
.expect("member stats");
assert_eq!(stats["members"], json!([]));
}
#[test] #[test]
fn a_watch_is_readable_back_and_survives_until_unwatched() { fn a_watch_is_readable_back_and_survives_until_unwatched() {
// The three methods are only useful together: what `user.watch` // The three methods are only useful together: what `user.watch`
@@ -666,14 +702,19 @@ mod tests {
// A session that answers nothing: enough to exercise dispatch, parameter // A session that answers nothing: enough to exercise dispatch, parameter
// validation, and the auth gate without a Soulseek server. // validation, and the auth gate without a Soulseek server.
/// A session that answers nothing, except that it remembers watches: /// A session that answers nothing, with two exceptions the methods under
/// the watch methods are only meaningful if what one call records the /// test need: it remembers watches, so what one call records the next can
/// next call can read back. /// read back, and it describes the members of one room, so the stats
/// method can return something for a joined room and nothing for any
/// other.
#[derive(Default)] #[derive(Default)]
struct SilentSession { struct SilentSession {
watched: Mutex<Vec<String>>, watched: Mutex<Vec<String>>,
} }
/// The one room `SilentSession` claims to have joined.
const STUB_ROOM: &str = "jazz";
fn daemon_with_token(token: &str) -> Daemon { fn daemon_with_token(token: &str) -> Daemon {
Daemon { Daemon {
session: Arc::new(SilentSession::default()), session: Arc::new(SilentSession::default()),
@@ -831,6 +872,23 @@ mod tests {
fn room_members(&self, _room: &str) -> Vec<String> { fn room_members(&self, _room: &str) -> Vec<String> {
Vec::new() Vec::new()
} }
fn room_member_stats(
&self,
room: &str,
) -> Vec<soulseek_rs::types::RoomUserStats> {
if room != STUB_ROOM {
return Vec::new();
}
vec![soulseek_rs::types::RoomUserStats {
username: "alice".to_string(),
status: soulseek_rs::types::UserStatus::Online,
average_speed: 1024,
shared_files: 20,
shared_folders: 3,
slots_full: true,
country: Some("NL".to_string()),
}]
}
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> { fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
Vec::new() Vec::new()
} }
+15 -1
View File
@@ -370,14 +370,28 @@ impl Record for UserRecord {
} }
} }
/// One member of a chat room. /// One member of a chat room, with the server's description of them.
///
/// The statistics come from the reply that named the members, and are `None`
/// for a server that sent none — so a consumer never reads "shares nothing"
/// from a missing answer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RoomMemberRecord { pub struct RoomMemberRecord {
pub room: String, pub room: String,
pub user: String, pub user: String,
/// `online`, `away`, or `offline`.
pub status: Option<String>,
pub average_speed: Option<u32>,
pub shared_files: Option<u32>,
pub shared_folders: Option<u32>,
pub slots_full: Option<bool>,
pub country: Option<String>,
} }
impl Record for RoomMemberRecord { impl Record for RoomMemberRecord {
/// The username alone, unchanged by the statistics added alongside it:
/// the text line is a roster a caller pipes into other commands, and the
/// statistics are there for `--json` to carry.
fn text(&self) -> String { fn text(&self) -> String {
sanitize(&self.user) sanitize(&self.user)
} }
+16 -5
View File
@@ -12,14 +12,14 @@ use crate::daemon::proto::{
Ack, AuthParams, AuthResult, CODE_APPLICATION, ChatMessageDto, Ack, AuthParams, AuthResult, CODE_APPLICATION, ChatMessageDto,
DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto,
DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds,
Members, MessageParams, Messages, Method, PROTOCOL_VERSION, QueryParams, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION,
Request, Response, RoomRef, RpcError, SayParams, SearchResults, Searches, QueryParams, Request, Response, RoomRef, RpcError, SayParams,
Seconds, SharesStatus, SlotsParams, TransferRef, Uploads, UserRef, SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef,
UserResult, Watched, Uploads, UserRef, UserResult, Watched,
}; };
use mirror::Mirror; use mirror::Mirror;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use soulseek_rs::types::{Download, DownloadMetadata}; use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats};
use soulseek_rs::{ use soulseek_rs::{
DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss,
SharedDirectory, SoulseekRs, UploadInfo, UserInfo, UserMessage, SharedDirectory, SoulseekRs, UploadInfo, UserInfo, UserMessage,
@@ -710,6 +710,17 @@ impl SessionApi for RemoteSession {
.unwrap_or_default() .unwrap_or_default()
} }
fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
self.request::<_, MemberStats>(
Method::RoomMemberStats,
RoomRef {
room: room.to_string(),
},
)
.map(|stats| stats.members.into_iter().map(Into::into).collect())
.unwrap_or_default()
}
fn take_room_events(&self) -> Vec<RoomEvent> { fn take_room_events(&self) -> Vec<RoomEvent> {
self.mirror.take_rooms() self.mirror.take_rooms()
} }
+6
View File
@@ -461,6 +461,12 @@ mod tests {
fn room_members(&self, _room: &str) -> Vec<String> { fn room_members(&self, _room: &str) -> Vec<String> {
Vec::new() Vec::new()
} }
fn room_member_stats(
&self,
_room: &str,
) -> Vec<soulseek_rs::types::RoomUserStats> {
Vec::new()
}
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> { fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
Vec::new() Vec::new()
} }