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
+55 -6
View File
@@ -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<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_free: stats.slots_free,
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_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<UserInfoDto> 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 {
+8
View File
@@ -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<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>,
}
/// 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.
+1
View File
@@ -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",