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
@@ -87,6 +87,38 @@ impl MessageFactory {
.clone()
}
/// Ask the server (code 92) how much of our own privilege time is left.
#[must_use]
pub fn build_check_privileges() -> Message {
Message::new().write_int32(92).clone()
}
/// Tell a peer where their queued file sits (peer code 44). Place counts
/// from 1.
#[must_use]
pub fn build_place_in_queue_response(
filename: &str,
place: u32,
) -> Message {
Message::new()
.write_int32(44)
.write_string(filename)
.write_int32(place)
.clone()
}
/// A wishlist search (server code 103). Same shape as a plain `FileSearch`,
/// but the server rate-limits it to the interval it announced in code 104
/// and does not count it against the normal search allowance.
#[must_use]
pub fn build_wishlist_search(token: u32, query: &str) -> Message {
Message::new()
.write_int32(103)
.write_int32(token)
.write_string(query)
.clone()
}
#[must_use]
pub fn build_set_status_message(status_code: u32) -> Message {
Message::new()
@@ -368,6 +400,37 @@ fn test_build_room_list_request() {
assert_eq!(vec![64, 0, 0, 0], message.get_data());
}
#[test]
fn test_build_check_privileges() {
assert_eq!(
vec![92, 0, 0, 0],
MessageFactory::build_check_privileges().get_data()
);
}
#[test]
fn test_build_place_in_queue_response() {
let message = MessageFactory::build_place_in_queue_response("song.mp3", 3);
let expect: Vec<u8> = [
44, 0, 0, 0, // code
8, 0, 0, 0, 115, 111, 110, 103, 46, 109, 112, 51, // "song.mp3"
3, 0, 0, 0, // place
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_wishlist_search() {
let message = MessageFactory::build_wishlist_search(12, "trance wax");
let expect: Vec<u8> = [
103, 0, 0, 0, 12, 0, 0, 0, 10, 0, 0, 0, 116, 114, 97, 110, 99, 101, 32,
119, 97, 120,
]
.to_vec();
assert_eq!(expect, message.get_data());
}
#[test]
fn test_build_file_search_message() {
let message = MessageFactory::build_file_search_message(12, "trance wax");