Correctness: - A child that stopped reading could freeze the whole client: the relay writes under the context lock and the socket was blocking, so one stalled peer took every download, upload and API call with it. Child writes now have a deadline, and a child that hits it loses its slot. - Relaying searches to children was gated by the leaf's answer budget, so a parent forwarded a fraction of the stream while advertising itself as healthy. The budget bounds our own share scan, which is what it was for; the tree's stream goes down whole. - A dropped client kept its listener port for the life of the process, so a retried login advertised an ephemeral port nobody was told about. The listener is now stopped and the operations thread let go. - Excluded phrases were compared against a lowercased path without being lowercased themselves, so a capitalised phrase never matched. They are normalised where they arrive. - Losing our own operatorship wiped the room's whole operator roster, though we are still a member who can see it. - A brokered ConnectToPeer of type D was logged and dropped; the peer is now told, since we never dial out to adopt a child. - A peer request made with no server connection returned Ok and parked forever; it says NotConnected, as its docs always claimed. - An empty interest was refused locally but still sent, leaving the server's list and ours permanently apart. Simplification: - Deleted the excluded-phrase helper nothing called, and the field doc that promised behaviour it never had. - The four roster handlers and four standing handlers were eight constructors carrying constants their own message code already implies: one newtype each, 100 lines less. - One place registers a search; the phrase list is borrowed, not cloned per relayed search.
543 lines
19 KiB
Rust
543 lines
19 KiB
Rust
//! The social side of the protocol: room tickers (codes 113-116), the global
|
|
//! room feed (150-152), interests and the recommendation queries built on them
|
|
//! (51/52/54/56/57/110/111/112/117/118), and the odds and ends a client owes
|
|
//! the server — a keepalive ping (32), gifting privileges (123) and changing
|
|
//! our password (142).
|
|
|
|
use super::{
|
|
Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser,
|
|
UserInterests, error,
|
|
};
|
|
use crate::message::server::MessageFactory;
|
|
|
|
impl Client {
|
|
/// Send a keepalive ping (server code 32). The server does not reply; the
|
|
/// point is that a connection with nothing to say still has traffic on it,
|
|
/// so a NAT or the server's idle reaper does not drop it.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn ping_server(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_server_ping())
|
|
}
|
|
|
|
/// Join a private room, creating it (and owning it) if the name is free.
|
|
///
|
|
/// A name already taken by someone else's private room comes back as a
|
|
/// [`crate::RoomEvent::CantCreate`] rather than a join.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn join_private_room(&self, room: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_join_room(room, true))
|
|
}
|
|
|
|
/// Invite `username` into the private room we own (code 134).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_add_room_member(
|
|
room, username,
|
|
))
|
|
}
|
|
|
|
/// Remove `username` from the private room we own (code 135).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_remove_room_member(
|
|
room, username,
|
|
))
|
|
}
|
|
|
|
/// Give up our own membership of a private room (code 136).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn leave_private_room(&self, room: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_cancel_room_membership(
|
|
room,
|
|
))
|
|
}
|
|
|
|
/// Give up ownership of a private room, which deletes it (code 137).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn disband_private_room(&self, room: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_cancel_room_ownership(
|
|
room,
|
|
))
|
|
}
|
|
|
|
/// Say whether we accept private-room invitations (code 141).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_enable_room_invitations(
|
|
enabled,
|
|
))
|
|
}
|
|
|
|
/// Make `username` an operator of our private room (code 143).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_add_room_operator(
|
|
room, username,
|
|
))
|
|
}
|
|
|
|
/// Take operator status away from `username` (code 144).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn remove_room_operator(
|
|
&self,
|
|
room: &str,
|
|
username: &str,
|
|
) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_remove_room_operator(
|
|
room, username,
|
|
))
|
|
}
|
|
|
|
/// Give up our own operator status in `room` (code 147).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn resign_room_operatorship(&self, room: &str) -> Result<()> {
|
|
self.send_server_message(
|
|
MessageFactory::build_cancel_room_operatorship(room),
|
|
)
|
|
}
|
|
|
|
/// Who may enter the private room `room`, as the server last reported.
|
|
#[must_use]
|
|
pub fn private_room_members(&self, room: &str) -> Vec<String> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.private_room_members(room),
|
|
Err(e) => {
|
|
error!("[client] private_room_members: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Who runs the private room `room`.
|
|
#[must_use]
|
|
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.private_room_operators(room),
|
|
Err(e) => {
|
|
error!("[client] private_room_operators: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The private rooms we belong to.
|
|
#[must_use]
|
|
pub fn private_rooms(&self) -> Vec<String> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.private_rooms(),
|
|
Err(e) => {
|
|
error!("[client] private_rooms: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set our online status (server code 28): away, or back to online.
|
|
///
|
|
/// Other clients see it in their `GetUserStatus` and watch replies, and
|
|
/// the server uses it when it decides who to hand a search to.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn set_away(&self, away: bool) -> Result<()> {
|
|
let status = u32::from(!away) + 1; // away = 1, online = 2
|
|
self.send_server_message(MessageFactory::build_set_status_message(
|
|
status,
|
|
))
|
|
}
|
|
|
|
/// Ask `username` what it says about itself (peer code 15): its
|
|
/// description, upload slots and queue length — a user's profile as other
|
|
/// clients show it.
|
|
///
|
|
/// Any previous answer is dropped first, so [`Client::peer_info`] reports
|
|
/// the reply to this request rather than a stale one.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
|
|
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
|
|
pub fn request_peer_info(&self, username: &str) -> Result<()> {
|
|
self.context.write_safe()?.invalidate_peer_info(username);
|
|
let request = crate::message::peer::build_user_info_request();
|
|
self.send_to_peer_or_queue(username, request)
|
|
}
|
|
|
|
/// What `username` last said about itself.
|
|
#[must_use]
|
|
pub fn peer_info(
|
|
&self,
|
|
username: &str,
|
|
) -> Option<crate::message::peer::PeerInfo> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.peer_info(username))
|
|
}
|
|
|
|
/// Ask `username` for the contents of one folder of their shares (peer
|
|
/// code 36) — what "download folder" in other clients asks for, without
|
|
/// pulling their whole listing.
|
|
///
|
|
/// The answer arrives asynchronously; take it with
|
|
/// [`Client::take_folder_contents`].
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
|
|
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
|
|
pub fn request_folder_contents(
|
|
&self,
|
|
username: &str,
|
|
folder: &str,
|
|
) -> Result<()> {
|
|
let request = crate::message::peer::build_folder_contents_request(
|
|
crate::client::next_search_token(),
|
|
folder,
|
|
);
|
|
self.send_to_peer_or_queue(username, request)
|
|
}
|
|
|
|
/// Remove and return the listing of `folder` from `username`, once their
|
|
/// answer to [`Client::request_folder_contents`] has arrived.
|
|
#[must_use]
|
|
pub fn take_folder_contents(
|
|
&self,
|
|
username: &str,
|
|
folder: &str,
|
|
) -> Option<Vec<crate::SharedDirectory>> {
|
|
self.context
|
|
.write_safe()
|
|
.ok()
|
|
.and_then(|mut ctx| ctx.take_folder_contents(username, folder))
|
|
}
|
|
|
|
/// Ask `username` where the file we queued with them sits (peer code 51).
|
|
///
|
|
/// The answer arrives asynchronously and lands on the download, readable
|
|
/// through [`Client::downloads`] as its queue position. A peer we have no
|
|
/// connection to is resolved and the request delivered once it is up, the
|
|
/// same way a browse request is.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection,
|
|
/// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned.
|
|
pub fn request_place_in_queue(
|
|
&self,
|
|
username: &str,
|
|
filename: &str,
|
|
) -> Result<()> {
|
|
let request = MessageFactory::build_place_in_queue_request(filename);
|
|
self.send_to_peer_or_queue(username, request)
|
|
}
|
|
|
|
/// Set our ticker in `room` — the one-line message other members see.
|
|
/// Passing an empty `ticker` clears ours.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_set_room_ticker(
|
|
room, ticker,
|
|
))
|
|
}
|
|
|
|
/// The ticker board of `room`: what the server sent on join (code 113),
|
|
/// kept current by the later add/remove events.
|
|
#[must_use]
|
|
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.room_tickers(room),
|
|
Err(e) => {
|
|
error!("[client] room_tickers: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Subscribe to the global room feed (code 150): every public room message
|
|
/// on the server arrives as a [`crate::RoomEvent::GlobalMessage`].
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn join_global_room(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_join_global_room())
|
|
}
|
|
|
|
/// Stop the global room feed (code 151).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn leave_global_room(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_leave_global_room())
|
|
}
|
|
|
|
/// Add an interest (code 51). Interests are what the recommendation and
|
|
/// similar-user queries below are computed from.
|
|
///
|
|
/// The server holds interests only for the session that set them, so the
|
|
/// list is kept here and sent again after every login. An interest added
|
|
/// before there is a connection is kept all the same — the error says
|
|
/// only that it could not go out yet, not that it was dropped.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn add_interest(&self, item: &str) -> Result<()> {
|
|
let Some(item) =
|
|
self.context.write_safe()?.add_own_interest(item, true)
|
|
else {
|
|
return Ok(()); // nothing to like or unlike
|
|
};
|
|
self.send_server_message(MessageFactory::build_add_thing_i_like(&item))
|
|
}
|
|
|
|
/// Drop an interest (code 52).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn remove_interest(&self, item: &str) -> Result<()> {
|
|
let Some(item) =
|
|
self.context.write_safe()?.remove_own_interest(item, true)
|
|
else {
|
|
return Ok(()); // nothing to like or unlike
|
|
};
|
|
self.send_server_message(MessageFactory::build_remove_thing_i_like(
|
|
&item,
|
|
))
|
|
}
|
|
|
|
/// Add a dislike (code 117).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn add_dislike(&self, item: &str) -> Result<()> {
|
|
let Some(item) =
|
|
self.context.write_safe()?.add_own_interest(item, false)
|
|
else {
|
|
return Ok(()); // nothing to like or unlike
|
|
};
|
|
self.send_server_message(MessageFactory::build_add_thing_i_hate(&item))
|
|
}
|
|
|
|
/// Drop a dislike (code 118).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn remove_dislike(&self, item: &str) -> Result<()> {
|
|
let Some(item) =
|
|
self.context.write_safe()?.remove_own_interest(item, false)
|
|
else {
|
|
return Ok(()); // nothing to like or unlike
|
|
};
|
|
self.send_server_message(MessageFactory::build_remove_thing_i_hate(
|
|
&item,
|
|
))
|
|
}
|
|
|
|
/// Ask for recommendations drawn from our own interests (code 54). The
|
|
/// reply arrives asynchronously; read it with
|
|
/// [`Client::recommendations`].
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_recommendations(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_get_recommendations())
|
|
}
|
|
|
|
/// Ask for the server-wide recommendations (code 56); read the reply with
|
|
/// [`Client::global_recommendations`].
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_global_recommendations(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_global_recommendations())
|
|
}
|
|
|
|
/// The upload speed the server records for us, once it has reported it.
|
|
/// It is what the distributed child limit is derived from, and it only
|
|
/// becomes known after an upload has finished and been reported.
|
|
#[must_use]
|
|
pub fn own_average_speed(&self) -> Option<u32> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.own_average_speed())
|
|
}
|
|
|
|
/// What we ourselves like and hate, as set through this client. The
|
|
/// server forgets these when the session ends, so they are re-sent after
|
|
/// each login.
|
|
#[must_use]
|
|
pub fn own_interests(&self) -> UserInterests {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.own_interests(),
|
|
Err(e) => {
|
|
error!("[client] own_interests: {}", e);
|
|
UserInterests::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The last reply to [`Client::request_recommendations`] as
|
|
/// (recommended, recommended-against), or `None` until one arrives.
|
|
#[must_use]
|
|
pub fn recommendations(
|
|
&self,
|
|
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.recommendations(false))
|
|
}
|
|
|
|
/// The last reply to [`Client::request_global_recommendations`].
|
|
#[must_use]
|
|
pub fn global_recommendations(
|
|
&self,
|
|
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.recommendations(true))
|
|
}
|
|
|
|
/// Ask what else people who like `item` like (code 111).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_item_recommendations(&self, item: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_item_recommendations(
|
|
item,
|
|
))
|
|
}
|
|
|
|
/// The last per-item recommendations received for `item`.
|
|
#[must_use]
|
|
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.item_recommendations(item),
|
|
Err(e) => {
|
|
error!("[client] item_recommendations: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask which users share our interests (code 110).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_similar_users(&self) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_similar_users())
|
|
}
|
|
|
|
/// The last similar-user answer, weighted by how much we have in common.
|
|
#[must_use]
|
|
pub fn similar_users(&self) -> Vec<SimilarUser> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.similar_users(),
|
|
Err(e) => {
|
|
error!("[client] similar_users: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask which users like `item` (code 112).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_item_similar_users(&self, item: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_item_similar_users(item))
|
|
}
|
|
|
|
/// The last answer to [`Client::request_item_similar_users`] for `item`.
|
|
#[must_use]
|
|
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
|
|
match self.context.read_safe() {
|
|
Ok(ctx) => ctx.item_similar_users(item),
|
|
Err(e) => {
|
|
error!("[client] item_similar_users: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask what `username` likes and hates (code 57).
|
|
///
|
|
/// Any previous answer for `username` is dropped first, so
|
|
/// [`Client::user_interests`] reports the reply to *this* request rather
|
|
/// than a stale one.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn request_user_interests(&self, username: &str) -> Result<()> {
|
|
self.context
|
|
.write_safe()?
|
|
.invalidate_user_interests(username);
|
|
self.send_server_message(MessageFactory::build_user_interests(username))
|
|
}
|
|
|
|
/// What the server last told us `username` likes and hates.
|
|
#[must_use]
|
|
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.user_interests(username))
|
|
}
|
|
|
|
/// Give `days` of our own privileges to `username` (code 123).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_give_privileges(
|
|
username, days,
|
|
))
|
|
}
|
|
|
|
/// Change our account password (code 142).
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn change_password(&self, password: &str) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_change_password(
|
|
password,
|
|
))
|
|
}
|
|
|
|
/// Send one private message to several users at once (code 149). Each
|
|
/// recipient receives it as an ordinary private message.
|
|
///
|
|
/// # Errors
|
|
/// [`crate::SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn send_private_message_to_many(
|
|
&self,
|
|
usernames: &[String],
|
|
message: &str,
|
|
) -> Result<()> {
|
|
self.send_server_message(MessageFactory::build_message_users(
|
|
usernames, message,
|
|
))
|
|
}
|
|
}
|