From 60f7100e309d98f4b0749fa7adfb8cd26ae637d4 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Fri, 28 Aug 2026 13:27:09 +0000 Subject: [PATCH] feat(daemon): publish room member statistics over RPC and the CLI The statistics parsed from the JoinRoom reply were reachable only from the library. They answer the question `room users` already exists to ask -- who is in here, and are they worth downloading from -- so they belong on its output rather than behind a second call. - proto: room.member_stats, with a RoomUserStatsDto carrying status, share counts, free slots and country - SessionApi::room_member_stats, so local and remote sessions stay interchangeable; the status word is decoded through one shared helper rather than a third copy of the match - `room users` now emits status, average_speed, shared_files, shared_folders, slots_free and country, each null when the server said nothing, documented in SKILL.md - docs/openrpc.json regenerated from the wire types --- soulseek-rs/docs/openrpc.json | 82 ++++++++++++++++++++++++++ soulseek-rs/src/SKILL.md | 6 +- soulseek-rs/src/api.rs | 9 ++- soulseek-rs/src/commands/social.rs | 11 ++++ soulseek-rs/src/daemon/openrpc.rs | 20 ++++--- soulseek-rs/src/daemon/proto/dto.rs | 61 +++++++++++++++++-- soulseek-rs/src/daemon/proto/method.rs | 8 +++ soulseek-rs/src/daemon/proto/mod.rs | 1 + soulseek-rs/src/daemon/server/mod.rs | 69 ++++++++++++++++++++-- soulseek-rs/src/output.rs | 21 ++++++- soulseek-rs/src/remote/mod.rs | 21 +++++-- soulseek-rs/src/ui/main_tui/mod.rs | 6 ++ 12 files changed, 288 insertions(+), 27 deletions(-) diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index 213bfc7..d8b6ead 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -525,6 +525,21 @@ ], "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": { "properties": { "users": { @@ -733,6 +748,53 @@ ], "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_free": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "status": { + "description": "`online`, `away`, or `offline`.", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "status", + "average_speed", + "shared_files", + "shared_folders", + "slots_free" + ], + "type": "object" + }, "SayParams": { "properties": { "message": { @@ -1776,6 +1838,26 @@ }, "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", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index 44594b3..dc3f173 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -140,7 +140,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. **`room list`** — `room`, `users`. -**`room users `** — `room`, `user`. +**`room users `** — `room`, `user`, `status`, `average_speed`, +`shared_files`, `shared_folders`, `slots_free`, `country`. Everything but +`room` and `user` is `null` when the server sent no statistics for that +member — `null` means unknown, not zero. `slots_free` of 0 means the peer has +no free upload slot right now, so a download from them will queue. **`room listen `** — streams `type`, `room`, `user`, `message`. **`room say `** — prints nothing; exit 0 is the confirmation. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 4006a84..a2a52d0 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -13,7 +13,7 @@ //! remote session has no borrowed client to hand a `&str` out of. use crate::daemon::proto::ChatMessageDto; -use soulseek_rs::types::{Download, DownloadMetadata}; +use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats}; use soulseek_rs::{ Client, DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, SharedDirectory, UploadInfo, UserInfo, UserMessage, @@ -126,6 +126,9 @@ pub trait SessionApi: Send + Sync { fn leave_room(&self, room: &str) -> Result<()>; fn say_in_room(&self, room: &str, message: &str) -> Result<()>; fn room_members(&self, room: &str) -> Vec; + /// 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; fn take_room_events(&self) -> Vec; fn send_private_message(&self, username: &str, message: &str) @@ -329,6 +332,10 @@ impl SessionApi for Client { Self::room_members(self, room) } + fn room_member_stats(&self, room: &str) -> Vec { + Self::room_member_stats(self, room) + } + fn take_room_events(&self) -> Vec { Self::take_room_events(self) } diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index 1bdde18..6c33a4d 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -151,10 +151,21 @@ pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult { 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 { + let stat = stats.iter().find(|s| s.username == user); ctx.out.emit(&RoomMemberRecord { room: room.to_string(), 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_free: stat.map(|s| s.slots_free), + country: stat.and_then(|s| s.country.clone()), }); } Ok(()) diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index e58b9c4..6acb736 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -11,10 +11,11 @@ use super::proto::{ Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, - Downloads, Event, IntervalSeconds, Members, MessageParams, Messages, - Method, QueryParams, RoomEventDto, RoomRef, SayParams, SearchResults, - Searches, Seconds, SessionLossEvent, SharesStatus, SlotsParams, - TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, UserResult, + Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, + Messages, Method, QueryParams, RoomEventDto, RoomRef, SayParams, + SearchResults, Searches, Seconds, SessionLossEvent, SharesStatus, + SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, + UserResult, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -46,9 +47,10 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::UploadCancel => schema_of::(generator), Method::DownloadSetDir => schema_of::(generator), Method::UploadSlots => schema_of::(generator), - Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => { - schema_of::(generator) - } + Method::RoomJoin + | Method::RoomLeave + | Method::RoomMembers + | Method::RoomMemberStats => schema_of::(generator), Method::RoomSay => schema_of::(generator), Method::MessageSend => schema_of::(generator), Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => { @@ -88,6 +90,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { Method::UploadList => schema_of::(generator), Method::PrivilegesOwn => schema_of::(generator), Method::RoomMembers => schema_of::(generator), + Method::RoomMemberStats => schema_of::(generator), Method::MessageHistory => schema_of::(generator), Method::UserInfoOf => schema_of::(generator), Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { @@ -172,6 +175,9 @@ fn summary(method: Method) -> &'static str { Method::RoomLeave => "Leave a chat room.", Method::RoomSay => "Post a message to a room.", Method::RoomMembers => "Who is currently in a room.", + Method::RoomMemberStats => { + "What the server said about each member of a joined room." + } Method::MessageSend => "Send a private message.", Method::MessageHistory => { "Private messages the daemon collected, including while nobody was attached." diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index c5f513d..dea5140 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -7,7 +7,8 @@ use serde::{Deserialize, Serialize}; use soulseek_rs::types::{ - Download, DownloadMetadata, UserPresence, UserStats, UserStatus, + Download, DownloadMetadata, RoomUserStats, UserPresence, UserStats, + UserStatus, }; use soulseek_rs::{ DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, @@ -447,6 +448,58 @@ pub struct UserStatsDto { 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, + pub slots_free: u32, + pub country: Option, +} + +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_free: stats.slots_free, + country: stats.country.clone(), + } + } +} + +impl From 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_free: dto.slots_free, + country: dto.country, + } + } +} + /// Each half is `None` until its reply lands, so a consumer can tell "the /// server said offline" from "the server has not answered yet". #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -480,11 +533,7 @@ impl From for UserInfo { fn from(dto: UserInfoDto) -> Self { let mut info = Self::pending(dto.username); info.presence = dto.presence.map(|presence| UserPresence { - status: match presence.status.as_str() { - "online" => UserStatus::Online, - "away" => UserStatus::Away, - _ => UserStatus::Offline, - }, + status: status_from_str(&presence.status), privileged: presence.privileged, }); info.stats = dto.stats.map(|stats| UserStats { diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index 96a69c2..7b22f03 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -1,5 +1,6 @@ //! What each method takes and returns, and what each pushed event carries. +use super::dto::RoomUserStatsDto; use super::dto::{ DownloadDto, DownloadMetadataDto, DownloadStatusDto, SearchResultDto, SessionLossDto, SharedDirectoryDto, UploadInfoDto, UserInfoDto, @@ -139,6 +140,13 @@ pub struct Members { pub users: Vec, } +/// 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, +} + /// One line of chat history. Unlike a live [`UserMessageDto`] this carries a /// direction, because a conversation the daemon collected includes what this /// account sent as well as what it received. diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index f91b83c..9079da7 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -101,6 +101,7 @@ wire_vocabulary! { RoomLeave => "room.leave", RoomSay => "room.say", RoomMembers => "room.members", + RoomMemberStats => "room.member_stats", MessageSend => "message.send", MessageHistory => "message.history", BrowseUser => "browse.user", diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 718a23a..13726ce 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -9,11 +9,12 @@ use super::hub::{Hub, PendingBrowses, PendingDownload}; use super::proto::{ Ack, AuthResult, CODE_APPLICATION, CODE_INVALID_PARAMS, ChatMessageDto, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, - DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, Members, - MessageParams, Messages, Method, OPENRPC, PROTOCOL_VERSION, QueryParams, - RoomRef, RpcError, SayParams, SearchResultDto, SearchResults, - SearchSummary, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, - UploadInfoDto, Uploads, UserInfoDto, UserRef, UserResult, + DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, + MemberStats, Members, MessageParams, Messages, Method, OPENRPC, + PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError, + SayParams, SearchResultDto, SearchResults, SearchSummary, Searches, + Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, + UserInfoDto, UserRef, UserResult, }; use crate::api::SessionApi; 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 => { let message: MessageParams = parse(params)?; self.session @@ -593,6 +606,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_free"], 1); + 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] fn stop_is_recorded_so_the_daemon_can_wind_down() { let daemon = daemon_with_token("t"); @@ -605,8 +641,14 @@ mod tests { // A session that answers nothing: enough to exercise dispatch, parameter // validation, and the auth gate without a Soulseek server. + /// A session that answers nothing, except for one room whose members it + /// describes: the stats method is only meaningful if it can return + /// something for a joined room and nothing for any other. struct SilentSession; + /// The one room `SilentSession` claims to have joined. + const STUB_ROOM: &str = "jazz"; + fn daemon_with_token(token: &str) -> Daemon { Daemon { session: Arc::new(SilentSession), @@ -764,6 +806,23 @@ mod tests { fn room_members(&self, _room: &str) -> Vec { Vec::new() } + fn room_member_stats( + &self, + room: &str, + ) -> Vec { + 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_free: 1, + country: Some("NL".to_string()), + }] + } fn take_room_events(&self) -> Vec { Vec::new() } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index d50a853..85209fd 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -370,16 +370,33 @@ 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)] pub struct RoomMemberRecord { pub room: String, pub user: String, + /// `online`, `away`, or `offline`. + pub status: Option, + pub average_speed: Option, + pub shared_files: Option, + pub shared_folders: Option, + pub slots_free: Option, + pub country: Option, } impl Record for RoomMemberRecord { fn text(&self) -> String { - sanitize(&self.user) + let unknown = || "-".to_string(); + format!( + "{}\t{}\t{}", + sanitize(&self.user), + self.status.clone().unwrap_or_else(unknown), + self.shared_files.map_or_else(unknown, |v| v.to_string()) + ) } } diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index e236d05..01ebde5 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -12,14 +12,14 @@ use crate::daemon::proto::{ Ack, AuthParams, AuthResult, CODE_APPLICATION, ChatMessageDto, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, - Members, MessageParams, Messages, Method, PROTOCOL_VERSION, QueryParams, - Request, Response, RoomRef, RpcError, SayParams, SearchResults, Searches, - Seconds, SharesStatus, SlotsParams, TransferRef, Uploads, UserRef, - UserResult, + MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, + QueryParams, Request, Response, RoomRef, RpcError, SayParams, + SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, + Uploads, UserRef, UserResult, }; use mirror::Mirror; use serde::de::DeserializeOwned; -use soulseek_rs::types::{Download, DownloadMetadata}; +use soulseek_rs::types::{Download, DownloadMetadata, RoomUserStats}; use soulseek_rs::{ DownloadStatus, Result, RoomEvent, SearchResult, SessionLoss, SharedDirectory, SoulseekRs, UploadInfo, UserInfo, UserMessage, @@ -710,6 +710,17 @@ impl SessionApi for RemoteSession { .unwrap_or_default() } + fn room_member_stats(&self, room: &str) -> Vec { + 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 { self.mirror.take_rooms() } diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 0b80188..4d47014 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -461,6 +461,12 @@ mod tests { fn room_members(&self, _room: &str) -> Vec { Vec::new() } + fn room_member_stats( + &self, + _room: &str, + ) -> Vec { + Vec::new() + } fn take_room_events(&self) -> Vec { Vec::new() }