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:
@@ -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,53 @@
|
|||||||
],
|
],
|
||||||
"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_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": {
|
"SayParams": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"message": {
|
"message": {
|
||||||
@@ -1776,6 +1838,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",
|
||||||
|
|||||||
@@ -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.
|
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_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 <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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -329,6 +332,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_free: stat.map(|s| s.slots_free),
|
||||||
|
country: stat.and_then(|s| s.country.clone()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -11,10 +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,
|
||||||
|
UserResult,
|
||||||
};
|
};
|
||||||
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -46,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::UserRequest | Method::UserInfoOf => {
|
Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => {
|
||||||
@@ -88,6 +90,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::MessageHistory => schema_of::<Messages>(generator),
|
Method::MessageHistory => schema_of::<Messages>(generator),
|
||||||
Method::UserInfoOf => schema_of::<UserResult>(generator),
|
Method::UserInfoOf => schema_of::<UserResult>(generator),
|
||||||
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
|
Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => {
|
||||||
@@ -172,6 +175,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::MessageSend => "Send a private message.",
|
Method::MessageSend => "Send a private message.",
|
||||||
Method::MessageHistory => {
|
Method::MessageHistory => {
|
||||||
"Private messages the daemon collected, including while nobody was attached."
|
"Private messages the daemon collected, including while nobody was attached."
|
||||||
|
|||||||
@@ -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,58 @@ 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,
|
||||||
|
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
|
/// 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 +533,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 {
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a
|
/// One line of chat history. Unlike a live [`UserMessageDto`] this carries a
|
||||||
/// direction, because a conversation the daemon collected includes what this
|
/// direction, because a conversation the daemon collected includes what this
|
||||||
/// account sent as well as what it received.
|
/// account sent as well as what it received.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads,
|
||||||
|
UserInfoDto, UserRef, UserResult,
|
||||||
};
|
};
|
||||||
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
|
||||||
@@ -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]
|
#[test]
|
||||||
fn stop_is_recorded_so_the_daemon_can_wind_down() {
|
fn stop_is_recorded_so_the_daemon_can_wind_down() {
|
||||||
let daemon = daemon_with_token("t");
|
let daemon = daemon_with_token("t");
|
||||||
@@ -605,8 +641,14 @@ 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 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;
|
struct SilentSession;
|
||||||
|
|
||||||
|
/// 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),
|
session: Arc::new(SilentSession),
|
||||||
@@ -764,6 +806,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_free: 1,
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)]
|
#[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_free: Option<u32>,
|
||||||
|
pub country: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Record for RoomMemberRecord {
|
impl Record for RoomMemberRecord {
|
||||||
fn text(&self) -> String {
|
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())
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
Uploads, UserRef, UserResult,
|
||||||
};
|
};
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user