feat!: wishlist, upload-queue privilege recognition, and search filters (#20)

Closes the last two gaps in Soulseek's list of features an alternative client
must implement to be tolerated, and adds the result filters Nicotine+ has had
all along.

Wishlist: server code 103 is sent and code 104's announced interval is kept
rather than logged and discarded, so the re-search timer runs off the schedule
the server set. `wish add|remove|list|run` plus `serve --wishlist`; wishes
persist in config.toml as a TOML array so a comma inside a query survives.
`wish run` prints search-shaped records, so it pipes into `download --stdin`.

Privilege recognition: the code-69 user list is parsed instead of counted and
dropped, and code 92 answers `whoami` as `privilege_seconds`. Uploads now go
through a queue with a slot cap (`--upload-slots`, default 2) that serves
privileged users first — the cap is what makes the ordering mean anything, so
they are one change. `PlaceInQueueRequest` (peer code 51) is answered, where it
previously had no handler at all, and a departed peer's slot is released so a
stalled peer cannot wedge uploads shut.

Search filters: `--exclude`, `--extension`, `--min-size`, `--max-size` beside
the existing `--min-bitrate` and `--free-slots`, in one flattened arg group
shared by `search`, `get` and `wish run`.

Also serialises the library e2e suite behind the same gate `cli_e2e` already
uses: the new queue tests hold peer sockets open on purpose, which starved
concurrently-spawned soulfinds and failed unrelated download tests with
`login: Timeout`.

BREAKING CHANGE: `UploadStatus` gains a `Queued(u32)` variant, so an exhaustive
match on it no longer compiles, and `cli::Commands` gains `Wish`.
This commit is contained in:
Michel de Graaf
2026-07-26 14:20:07 +02:00
committed by GitHub
parent eda762d067
commit 9b87bd6a0f
29 changed files with 2991 additions and 145 deletions
+103
View File
@@ -23,6 +23,10 @@ pub struct FileConfig {
/// Command whose stdout is the password (headless fallback, like mutt's
/// `password_cmd`). Never store the password itself in the file.
pub password_cmd: Option<String>,
/// Standing searches the server lets us repeat once per wishlist interval.
/// A TOML array rather than a `config set` key, because a query may itself
/// contain a comma.
pub wishlist: Option<Vec<String>>,
}
impl FileConfig {
@@ -131,6 +135,54 @@ impl FileConfig {
Ok(())
}
/// The standing searches, in the order they were added.
#[must_use]
pub fn wishes(&self) -> Vec<String> {
self.wishlist.clone().unwrap_or_default()
}
/// Add `query` to the wishlist. Returns whether it changed anything: a
/// blank query and a case-insensitive repeat are both refused, so adding
/// the same wish twice cannot fill the file with duplicates the server
/// would then be asked about twice per interval.
pub fn add_wish(&mut self, query: &str) -> bool {
let query = query.trim();
if query.is_empty() || self.find_wish(query).is_some() {
return false;
}
self.wishlist
.get_or_insert_with(Vec::new)
.push(query.to_string());
true
}
/// Remove `query`, matched the same way [`Self::add_wish`] compares.
/// Returns whether it was there.
pub fn remove_wish(&mut self, query: &str) -> bool {
let Some(index) = self.find_wish(query.trim()) else {
return false;
};
self.wishlist.get_or_insert_with(Vec::new).remove(index);
true
}
/// The stored wish matching `query`, compared the way [`Self::add_wish`]
/// compares. Callers that need to act on a named wish go through this so
/// "the same wish" means one thing everywhere.
#[must_use]
pub fn wish(&self, query: &str) -> Option<&String> {
let index = self.find_wish(query.trim())?;
self.wishlist.as_ref()?.get(index)
}
fn find_wish(&self, query: &str) -> Option<usize> {
let wanted = query.to_lowercase();
self.wishlist
.as_ref()?
.iter()
.position(|wish| wish.to_lowercase() == wanted)
}
/// Load from `path`; a missing file is an empty config, a malformed file
/// is an error (silently ignoring a typo'd config would be worse).
pub fn load(path: &Path) -> Result<Self> {
@@ -430,6 +482,7 @@ mod tests {
max_concurrent_downloads: Some(2),
search_timeout: Some(30),
password_cmd: Some("pass show slsk".into()),
wishlist: None,
};
let resolved = resolve(&bare_cli(), &file);
assert_eq!(resolved.username.as_deref(), Some("alice"));
@@ -577,6 +630,56 @@ mod tests {
assert_eq!(FileConfig::load(&path).unwrap(), config);
}
#[test]
fn a_wish_is_added_once_however_it_is_spelled() {
let mut config = FileConfig::default();
assert!(config.add_wish("Aphex Twin"));
assert!(!config.add_wish("aphex twin"), "a repeat is not a new wish");
assert!(!config.add_wish(" Aphex Twin "), "surrounding space too");
assert_eq!(config.wishes(), ["Aphex Twin"]);
}
#[test]
fn an_empty_wish_is_refused() {
let mut config = FileConfig::default();
assert!(!config.add_wish(" "));
assert!(config.wishes().is_empty());
}
#[test]
fn removing_a_wish_ignores_case_and_reports_whether_it_was_there() {
let mut config = FileConfig::default();
config.add_wish("Boards of Canada");
assert!(config.remove_wish("BOARDS OF CANADA"));
assert!(config.wishes().is_empty());
assert!(!config.remove_wish("never added"));
}
#[test]
fn wishes_keep_the_order_they_were_added_in() {
let mut config = FileConfig::default();
for wish in ["first", "second", "third"] {
config.add_wish(wish);
}
config.remove_wish("second");
assert_eq!(config.wishes(), ["first", "third"]);
}
#[test]
fn a_wishlist_survives_a_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut config = FileConfig::default();
config.add_wish("autechre, incunabula");
config.add_wish("plaid");
config.save(&path).unwrap();
assert_eq!(
FileConfig::load(&path).unwrap().wishes(),
["autechre, incunabula", "plaid"],
"a comma inside a wish must survive the round trip"
);
}
#[test]
fn unknown_keys_and_partial_files_are_tolerated() {
let dir = tempfile::tempdir().unwrap();