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
This commit is contained in:
Michel de Graaf
2026-08-28 13:27:09 +00:00
parent fa49ebd2eb
commit 60f7100e30
12 changed files with 288 additions and 27 deletions
+64 -5
View File
@@ -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<String> {
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_free: 1,
country: Some("NL".to_string()),
}]
}
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
Vec::new()
}