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