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:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
Reference in New Issue
Block a user