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.
263 lines
8.9 KiB
Rust
263 lines
8.9 KiB
Rust
use super::{
|
|
Arc, AtomicBool, Client, DEFAULT_WISHLIST_INTERVAL, Duration, HashMap,
|
|
Instant, Ordering, Result, RwLockExt, Search, SearchResult, ServerMessage,
|
|
SoulseekRs, info, next_search_token, sleep,
|
|
};
|
|
|
|
impl Client {
|
|
pub fn search(
|
|
&self,
|
|
query: &str,
|
|
timeout: Duration,
|
|
) -> Result<Vec<SearchResult>> {
|
|
self.search_with_cancel(query, timeout, None)
|
|
}
|
|
|
|
/// Send `query` as a wishlist search (server code 103) and return at once.
|
|
///
|
|
/// Results accumulate under `query`, so the caller reads them back with
|
|
/// [`Client::get_search_results`] after waiting however long it wants to.
|
|
/// Starting several wishes and then waiting once is the whole point: waiting
|
|
/// per wish would cost one full search window each.
|
|
///
|
|
/// The server rate-limits these to the interval it announced — see
|
|
/// [`Client::wishlist_interval`] — so a wish re-sent sooner than that comes
|
|
/// back empty.
|
|
///
|
|
/// # Errors
|
|
/// [`SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn start_wishlist_search(&self, query: &str) -> Result<()> {
|
|
self.send_search(query, true)
|
|
}
|
|
|
|
/// How long to wait between wishlist searches: what the server announced in
|
|
/// code 104, or [`DEFAULT_WISHLIST_INTERVAL`] until it has.
|
|
#[must_use]
|
|
pub fn wishlist_interval(&self) -> Duration {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| ctx.wishlist_interval)
|
|
.map_or(DEFAULT_WISHLIST_INTERVAL, |seconds| {
|
|
Duration::from_secs(u64::from(seconds))
|
|
})
|
|
}
|
|
|
|
pub fn search_with_cancel(
|
|
&self,
|
|
query: &str,
|
|
timeout: Duration,
|
|
cancel_flag: Option<Arc<AtomicBool>>,
|
|
) -> Result<Vec<SearchResult>> {
|
|
self.send_search(query, false)?;
|
|
Self::collect_for(timeout, cancel_flag);
|
|
Ok(self.get_search_results(query))
|
|
}
|
|
|
|
/// Register `query` and put its search on the wire. Returns as soon as the
|
|
/// message is queued; nothing has answered yet.
|
|
fn send_search(&self, query: &str, wishlist: bool) -> Result<()> {
|
|
info!("Searching for {}", query);
|
|
|
|
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(
|
|
query.to_string(),
|
|
Search {
|
|
token,
|
|
results: Vec::new(),
|
|
},
|
|
);
|
|
|
|
let query = query.to_string();
|
|
let _ = handle.send(if wishlist {
|
|
ServerMessage::WishlistSearch { token, query }
|
|
} else {
|
|
ServerMessage::FileSearch { token, query }
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// Search one user's shares (server code 42) and return at once.
|
|
///
|
|
/// Results arrive from that user alone and accumulate under `query`, read
|
|
/// back with [`Client::get_search_results`] — the same place a plain
|
|
/// search's results land, since the replies are ordinary
|
|
/// `FileSearchResponse` frames.
|
|
///
|
|
/// # Errors
|
|
/// [`SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn search_user(&self, username: &str, query: &str) -> Result<()> {
|
|
let token = self.register_search(query)?;
|
|
self.send_server_message(
|
|
crate::message::server::MessageFactory::build_user_search(
|
|
username, token, query,
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Search the shares of everyone in `room` (server code 120), returning at
|
|
/// once. Results accumulate under `query` exactly as above.
|
|
///
|
|
/// # Errors
|
|
/// [`SoulseekRs::NotConnected`] when there is no server connection.
|
|
pub fn search_room(&self, room: &str, query: &str) -> Result<()> {
|
|
let token = self.register_search(query)?;
|
|
self.send_server_message(
|
|
crate::message::server::MessageFactory::build_room_search(
|
|
room, token, query,
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Take a token for `query` and register it, so replies quoting that token
|
|
/// are filed under `query` when they arrive.
|
|
fn register_search(&self, query: &str) -> Result<u32> {
|
|
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(),
|
|
Search {
|
|
token,
|
|
results: Vec::new(),
|
|
},
|
|
);
|
|
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
|
|
/// to it, so there is nothing to await — the window is the whole protocol.
|
|
/// Late responders are often the better sources, which is why this runs the
|
|
/// window out rather than returning on the first hit.
|
|
pub fn collect_for(
|
|
timeout: Duration,
|
|
cancel_flag: Option<Arc<AtomicBool>>,
|
|
) {
|
|
let start = Instant::now();
|
|
while start.elapsed() < timeout {
|
|
sleep(Duration::from_millis(100));
|
|
if let Some(flag) = &cancel_flag
|
|
&& flag.load(Ordering::Relaxed)
|
|
{
|
|
info!("Search cancelled by user");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_search_results_count(&self, search_key: &str) -> usize {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| {
|
|
ctx.searches.get(search_key).map(|s| s.results.len())
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_search_results(&self, search_key: &str) -> Vec<SearchResult> {
|
|
self.context
|
|
.read_safe()
|
|
.ok()
|
|
.and_then(|ctx| {
|
|
ctx.searches.get(search_key).map(|s| s.results.clone())
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Non-blocking variant that returns None if the lock is unavailable
|
|
#[must_use]
|
|
pub fn try_get_search_results(
|
|
&self,
|
|
search_key: &str,
|
|
) -> Option<Vec<SearchResult>> {
|
|
self.context.try_read().ok().and_then(|ctx| {
|
|
ctx.searches.get(search_key).map(|s| s.results.clone())
|
|
})
|
|
}
|
|
|
|
/// Drop a search and everything it collected.
|
|
///
|
|
/// Returns whether there was one to drop. A client that stays up for days
|
|
/// would otherwise hold every result set it has ever seen, and a caller
|
|
/// that has dismissed a search has no other way to say so.
|
|
#[must_use]
|
|
pub fn forget_search(&self, search_key: &str) -> bool {
|
|
self.context
|
|
.write_safe()
|
|
.is_ok_and(|mut ctx| ctx.searches.remove(search_key).is_some())
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn get_all_searches(&self) -> HashMap<String, Search> {
|
|
self.context
|
|
.read_safe()
|
|
.map(|ctx| ctx.searches.clone())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Every registered search and how many files it has collected, without
|
|
/// copying the result sets.
|
|
///
|
|
/// This is the accessor to poll. [`Client::get_all_searches`] clones every
|
|
/// result of every search, which for a popular query is megabytes per
|
|
/// call — a caller that only wants to know what exists and whether
|
|
/// anything new arrived reads the counts instead.
|
|
#[must_use]
|
|
pub fn search_file_counts(&self) -> Vec<(String, usize)> {
|
|
self.context
|
|
.read_safe()
|
|
.map(|ctx| {
|
|
ctx.searches
|
|
.iter()
|
|
.map(|(query, search)| {
|
|
(
|
|
query.clone(),
|
|
search
|
|
.results
|
|
.iter()
|
|
.map(|result| result.files.len())
|
|
.sum(),
|
|
)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
}
|