feat(protocol): folder requests, and heuristics matched to other clients

Reading Nicotine+ and slskd against what was just written turned up three
places where this client had the message right and the behaviour wrong.

Excluded search phrases (code 160) are not a filter on the searches we
send — the server already drops those. Nicotine+ applies them to the files
it offers, leaving out any whose path carries an excluded phrase, and a
reply with nothing left is not sent. Ours now does the same, and the
local refusal of outgoing searches is gone.

Serving children now follows the same rules as Nicotine+: the limit comes
from the server's ParentMinSpeed and ParentSpeedRatio against the upload
speed the server records for us (capped at ten, none at all below the
minimum), children are refused until something feeds us the search stream
and let go when it stops, a second link from a child we already carry is
refused, and AcceptChildren is re-sent whenever that answer changes. Where
the server has never reported our speed there is nothing to apply the
formula to, so the configured cap stands, which is how slskd runs.

Queued downloads now ask where they sit every five minutes, as Nicotine+
does, instead of only when a caller asks; a brokered file connection we
cannot complete reports CantConnectToPeer like a control connection does;
and a peer's shares can be asked for one folder at a time (peer code 36
and its reply, 37), which is what "download folder" is built on.
This commit is contained in:
Michel de Graaf
2026-09-09 17:01:19 +00:00
parent bf3e3ec4ad
commit 0630b548b9
16 changed files with 751 additions and 107 deletions
+3 -18
View File
@@ -62,9 +62,6 @@ 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(
@@ -122,9 +119,6 @@ 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(),
@@ -136,8 +130,9 @@ impl Client {
Ok(token)
}
/// The phrases the server refuses to search for (code 160), as last
/// announced. Empty until the server has said.
/// The phrases the server excludes from the search network (code 160), as
/// last announced. Files whose path carries one are left out of the
/// replies this client sends. Empty until the server has said.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.context
@@ -146,16 +141,6 @@ impl Client {
.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