feat(protocol): away status, excluded phrases, queue-place requests

Three features the client half-had. SetStatus (28) was only ever sent as
"online" at login, so there was no way to go away; excluded search phrases
(160) were parsed and logged into nowhere, so every client kept spending
searches on queries the server was never going to answer; and a queued
download could answer a peer asking about its place but could never ask
about its own.

Each is now driven end to end: the away status seen by another user, a
refused query on a stub server that announces phrases (soulfind keeps its
filters in a database the suite cannot seed), and a peer's answer landing
on the download as its queue position.
This commit is contained in:
Michel de Graaf
2026-09-09 16:27:20 +00:00
parent 60a0f8fc91
commit 65c44cc66b
10 changed files with 429 additions and 3 deletions
@@ -227,6 +227,8 @@ pub enum ServerMessage {
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
}
pub struct ServerActor {
@@ -692,6 +694,11 @@ impl ServerActor {
token,
});
}
ServerMessage::ExcludedSearchPhrases(phrases) => {
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
phrases,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
+31
View File
@@ -368,6 +368,8 @@ pub enum ClientOperation {
CantConnectToPeer {
token: u32,
},
/// Phrases the server refuses to search for (code 160).
ExcludedSearchPhrases(Vec<String>),
/// A parent candidate told us how deep it sits. `link` says which dial
/// to that user is talking.
ParentBranchLevel {
@@ -469,6 +471,9 @@ pub struct ClientContext {
privileged_users: HashSet<String>,
/// Seconds of our own privileges left (code 92), once we have asked.
own_privileges: Option<u32>,
/// Phrases the server will not search for (code 160). A query carrying one
/// is refused locally rather than spent on the server.
excluded_search_phrases: Vec<String>,
/// Peers waiting for one of our upload slots.
upload_queue: Vec<QueuedUpload>,
/// Arrival counter for the queue's first-come tie-break.
@@ -575,6 +580,7 @@ impl ClientContext {
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
@@ -725,6 +731,31 @@ impl ClientContext {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server refuses to search for (code 160).
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases = phrases;
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// The first excluded phrase `query` contains, if any. Matching is
/// case-insensitive and on substrings, which is how the server applies its
/// own filter.
#[must_use]
pub fn excluded_phrase_in(&self, query: &str) -> Option<String> {
let haystack = query.to_lowercase();
self.excluded_search_phrases
.iter()
.find(|phrase| {
!phrase.is_empty() && haystack.contains(&phrase.to_lowercase())
})
.cloned()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
+5
View File
@@ -816,6 +816,11 @@ impl Client {
ctx.apply_user_interests(interests);
}
}
ClientOperation::ExcludedSearchPhrases(phrases) => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.set_excluded_search_phrases(phrases);
}
}
ClientOperation::CantConnectToPeer { token } => {
// The peer we asked the server to broker gave up. The
// token is the correlation we sent, so it names the
+15 -2
View File
@@ -234,9 +234,22 @@ impl Client {
pub fn browse_user(&self, username: &str) -> Result<()> {
let request =
crate::message::server::MessageFactory::build_get_share_file_list();
self.context.write_safe()?.mark_browse_pending(username);
self.send_to_peer_or_queue(username, request)
}
/// Send `request` to `username` over their control connection, or hold it
/// until one exists — asking the server for their address so it does.
///
/// # Errors
/// [`SoulseekRs::LockPoisoned`] if the client state is poisoned.
pub(super) fn send_to_peer_or_queue(
&self,
username: &str,
request: crate::message::Message,
) -> Result<()> {
let (connected, registry) = {
let mut ctx = self.context.write_safe()?;
ctx.mark_browse_pending(username);
let ctx = self.context.read_safe()?;
(
ctx.peer_registry
.as_ref()
+26
View File
@@ -62,6 +62,9 @@ impl Client {
let Some(handle) = &self.server_handle else {
return Err(SoulseekRs::NotConnected);
};
if let Some(phrase) = self.excluded_phrase_in(query) {
return Err(SoulseekRs::SearchPhraseExcluded(phrase));
}
let token = next_search_token();
self.context.write_safe()?.searches.insert(
@@ -119,6 +122,9 @@ impl Client {
if self.server_handle.is_none() {
return Err(SoulseekRs::NotConnected);
}
if let Some(phrase) = self.excluded_phrase_in(query) {
return Err(SoulseekRs::SearchPhraseExcluded(phrase));
}
let token = next_search_token();
self.context.write_safe()?.searches.insert(
query.to_string(),
@@ -130,6 +136,26 @@ impl Client {
Ok(token)
}
/// The phrases the server refuses to search for (code 160), as last
/// announced. Empty until the server has said.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.context
.read_safe()
.map(|ctx| ctx.excluded_search_phrases())
.unwrap_or_default()
}
/// The excluded phrase `query` carries, if any — what a search would be
/// refused for.
#[must_use]
pub fn excluded_phrase_in(&self, query: &str) -> Option<String> {
self.context
.read_safe()
.ok()
.and_then(|ctx| ctx.excluded_phrase_in(query))
}
/// Let responses accumulate for `timeout`, or until cancelled.
///
/// Peers answer a search over their own connections whenever they get round
+33
View File
@@ -21,6 +21,39 @@ impl Client {
self.send_server_message(MessageFactory::build_server_ping())
}
/// 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` 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.
///
+6
View File
@@ -20,6 +20,9 @@ pub enum SoulseekRs {
CompressionError(String),
/// A lock was poisoned by a panic in another thread
LockPoisoned,
/// The query carries a phrase the server refuses to search for (code 160),
/// so sending it would spend a search on a query nothing answers.
SearchPhraseExcluded(String),
}
impl fmt::Display for SoulseekRs {
@@ -41,6 +44,9 @@ impl fmt::Display for SoulseekRs {
Self::CompressionError(msg) => {
write!(f, "Compression error: {msg}")
}
Self::SearchPhraseExcluded(phrase) => {
write!(f, "The server excludes searches for \"{phrase}\"")
}
Self::LockPoisoned => {
write!(f, "Lock poisoned by panicking thread")
}
@@ -12,7 +12,7 @@ impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
160
}
fn handle(&self, message: &mut Message, _sender: Sender<ServerMessage>) {
fn handle(&self, message: &mut Message, sender: Sender<ServerMessage>) {
let item_count = message.read_int32();
let mut exluded_phrases: Vec<String> = Vec::new();
@@ -25,6 +25,11 @@ impl MessageHandler<ServerMessage> for ExcludedSearchPhrasesHandler {
exluded_phrases.push(phrase);
}
debug!("Excluded search phrases: {:?}", exluded_phrases);
// Kept, not just logged: the server rejects a search carrying one of
// these, so a client that forgets them spends its search allowance on
// queries that were never going to be answered.
let _ =
sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases));
}
}
@@ -43,4 +48,22 @@ mod tests {
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
}
#[test]
fn the_phrases_are_reported_not_only_logged() {
let (tx, rx) = std::sync::mpsc::channel();
let mut message = framed(|m| {
m.write_int32(2);
m.write_string("banned");
m.write_string("blocked");
});
ExcludedSearchPhrasesHandler.handle(&mut message, tx);
match rx.try_recv() {
Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => {
assert_eq!(phrases, ["banned", "blocked"]);
}
other => panic!("unexpected: {other:?}"),
}
}
}
@@ -301,6 +301,15 @@ impl MessageFactory {
.clone()
}
/// Ask a peer where the file we queued with them sits (peer code 51).
#[must_use]
pub fn build_place_in_queue_request(filename: &str) -> Message {
Message::new()
.write_int32(51)
.write_string(filename)
.clone()
}
/// A keepalive ping (server code 32, no body). The server sends no reply;
/// its only job is to keep a quiet connection from being reaped by a NAT
/// or the server's own idle timeout, the way other clients ping.
@@ -687,6 +696,15 @@ fn an_upload_speed_report_carries_the_rate() {
assert_eq!(decoded.read_int32(), 900);
}
#[test]
fn a_place_in_queue_request_names_the_file() {
let message = MessageFactory::build_place_in_queue_request("a\\b.mp3");
let mut decoded = Message::new_with_data(message.get_buffer());
assert_eq!(decoded.get_message_code(), 51);
decoded.set_pointer(8);
assert_eq!(decoded.read_string(), "a\\b.mp3");
}
#[test]
fn a_ping_is_just_its_code() {
let message = MessageFactory::build_server_ping();