diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs index aa29d45..c7a0697 100644 --- a/soulseek-rs-lib/src/client/tests.rs +++ b/soulseek-rs-lib/src/client/tests.rs @@ -632,7 +632,7 @@ fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { average_speed: 1, shared_files: 2, shared_folders: 3, - slots_free: 1, + slots_full: true, country: None, }; @@ -660,7 +660,7 @@ fn rejoining_replaces_the_previous_member_stats() { average_speed: 1, shared_files: 2, shared_folders: 3, - slots_free: 0, + slots_full: false, country: None, }; diff --git a/soulseek-rs-lib/src/message/server/join_room.rs b/soulseek-rs-lib/src/message/server/join_room.rs index 0e48716..273e10c 100644 --- a/soulseek-rs-lib/src/message/server/join_room.rs +++ b/soulseek-rs-lib/src/message/server/join_room.rs @@ -59,7 +59,8 @@ fn read_int32s(message: &mut Message) -> Vec { /// 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 free upload slots, then country codes. +/// 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 @@ -87,7 +88,7 @@ fn parse_member_stats( shares.push((average_speed, shared_files, shared_folders)); } - let slots_free = read_int32s(message); + let slots_full = read_int32s(message); let countries = read_strings(message); users @@ -104,7 +105,7 @@ fn parse_member_stats( average_speed, shared_files, shared_folders, - slots_free: slots_free.get(i).copied().unwrap_or(0), + slots_full: slots_full.get(i).copied().unwrap_or(0) != 0, country: countries.get(i).cloned().filter(|c| !c.is_empty()), } }) @@ -156,7 +157,7 @@ mod tests { .write_int64(0) .write_int32(10) .write_int32(2) - // free slots + // slots full .write_int32(2) .write_int32(1) .write_int32(0) @@ -188,7 +189,7 @@ mod tests { average_speed: 1024, shared_files: 4321, shared_folders: 77, - slots_free: 1, + slots_full: true, country: Some("NL".to_string()), }, RoomUserStats { @@ -197,7 +198,7 @@ mod tests { average_speed: 50, shared_files: 10, shared_folders: 2, - slots_free: 0, + slots_full: false, country: None, }, ] diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 7fd3e1f..be94a22 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -341,8 +341,10 @@ pub struct RoomUserStats { pub average_speed: u32, pub shared_files: u32, pub shared_folders: u32, - /// Upload slots the member has free; zero means they are busy. - pub slots_free: 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, } diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index 072bc21..53f18fe 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -2622,3 +2622,84 @@ fn a_peer_that_hangs_up_its_control_connection_still_delivers_the_file() { 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" + ); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index d8b6ead..dcdf9ec 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -772,10 +772,9 @@ "minimum": 0, "type": "integer" }, - "slots_free": { - "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`.", @@ -791,7 +790,7 @@ "average_speed", "shared_files", "shared_folders", - "slots_free" + "slots_full" ], "type": "object" }, diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index dc3f173..8a603d0 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -141,10 +141,10 @@ server did not answer — those are different facts, so do not treat null as 0. **`room list`** — `room`, `users`. **`room users `** — `room`, `user`, `status`, `average_speed`, -`shared_files`, `shared_folders`, `slots_free`, `country`. Everything but +`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_free` of 0 means the peer has -no free upload slot right now, so a download from them will queue. +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 `** — streams `type`, `room`, `user`, `message`. **`room say `** — prints nothing; exit 0 is the confirmation. diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index 6c33a4d..4bedea4 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -164,7 +164,7 @@ pub fn room_users(ctx: &Ctx, room: &str, timeout: Duration) -> CliResult { 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), + slots_full: stat.map(|s| s.slots_full), country: stat.and_then(|s| s.country.clone()), }); } diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index dea5140..c7ad6aa 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -468,7 +468,8 @@ pub struct RoomUserStatsDto { pub average_speed: u32, pub shared_files: u32, pub shared_folders: u32, - pub slots_free: u32, + /// True when the member's upload slots are all occupied. + pub slots_full: bool, pub country: Option, } @@ -480,7 +481,7 @@ impl From<&RoomUserStats> for RoomUserStatsDto { average_speed: stats.average_speed, shared_files: stats.shared_files, shared_folders: stats.shared_folders, - slots_free: stats.slots_free, + slots_full: stats.slots_full, country: stats.country.clone(), } } @@ -494,7 +495,7 @@ impl From for RoomUserStats { average_speed: dto.average_speed, shared_files: dto.shared_files, shared_folders: dto.shared_folders, - slots_free: dto.slots_free, + slots_full: dto.slots_full, country: dto.country, } } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 13726ce..b710a76 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -614,7 +614,7 @@ mod tests { .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]["slots_full"], true); assert_eq!(stats["members"][0]["country"], "NL"); } @@ -819,7 +819,7 @@ mod tests { average_speed: 1024, shared_files: 20, shared_folders: 3, - slots_free: 1, + slots_full: true, country: Some("NL".to_string()), }] } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index 85209fd..3e0f70e 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -384,7 +384,7 @@ pub struct RoomMemberRecord { pub average_speed: Option, pub shared_files: Option, pub shared_folders: Option, - pub slots_free: Option, + pub slots_full: Option, pub country: Option, }