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
+83 -12
View File
@@ -168,6 +168,10 @@ pub enum Commands {
/// List the files a user shares
Browse(BrowseArgs),
/// Standing searches the server re-runs on its own schedule
#[command(subcommand)]
Wish(WishCommand),
/// Chat rooms
#[command(subcommand)]
Room(RoomCommand),
@@ -265,6 +269,15 @@ pub struct ServeArgs {
/// Stay online until interrupted, ignoring --duration
#[arg(long, action = ArgAction::SetTrue)]
pub follow: bool,
/// Also re-run the wishlist on the interval the server sets, reporting
/// matches as they arrive
#[arg(long, action = ArgAction::SetTrue)]
pub wishlist: bool,
/// How many uploads to run at once; the rest queue, privileged users first
#[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(1..=64))]
pub upload_slots: Option<u8>,
}
#[derive(Args, Debug)]
@@ -353,11 +366,10 @@ pub enum Pick {
All,
}
#[derive(Args, Debug)]
pub struct SearchArgs {
/// What to search for
pub query: String,
/// The result restrictions every command that searches accepts, so `search`,
/// `get` and `wish run` all narrow a result set the same way.
#[derive(Args, Debug, Default, Clone)]
pub struct FilterArgs {
/// Drop results below this bitrate in kbps
#[arg(long, value_name = "KBPS")]
pub min_bitrate: Option<u32>,
@@ -366,6 +378,31 @@ pub struct SearchArgs {
#[arg(long, action = ArgAction::SetTrue)]
pub free_slots: bool,
/// Drop results whose path contains this term; repeat for several
#[arg(long, value_name = "TERM", action = ArgAction::Append)]
pub exclude: Vec<String>,
/// Keep only this file extension, with or without a dot; repeat for several
#[arg(long, value_name = "EXT", action = ArgAction::Append)]
pub extension: Vec<String>,
/// Drop results smaller than this many bytes
#[arg(long, value_name = "BYTES")]
pub min_size: Option<u64>,
/// Drop results larger than this many bytes
#[arg(long, value_name = "BYTES")]
pub max_size: Option<u64>,
}
#[derive(Args, Debug)]
pub struct SearchArgs {
/// What to search for
pub query: String,
#[command(flatten)]
pub filter: FilterArgs,
/// Print at most this many results (0 means all)
#[arg(short = 'n', long, default_value_t = 0)]
pub limit: usize,
@@ -417,13 +454,8 @@ pub struct GetArgs {
#[arg(short = 'n', long, default_value_t = 0)]
pub limit: usize,
/// Drop results below this bitrate in kbps
#[arg(long, value_name = "KBPS")]
pub min_bitrate: Option<u32>,
/// Keep only peers advertising a free upload slot
#[arg(long, action = ArgAction::SetTrue)]
pub free_slots: bool,
#[command(flatten)]
pub filter: FilterArgs,
/// Seconds to wait for each transfer before giving up
#[arg(
@@ -450,6 +482,45 @@ pub struct BrowseArgs {
pub timeout: u64,
}
#[derive(Subcommand, Debug)]
pub enum WishCommand {
/// Store a query and search for it from now on
Add {
/// What to keep looking for
query: String,
},
/// Stop looking for a query
Remove {
/// The wish to drop, as shown by `wish list`
query: String,
},
/// Print the stored queries
List,
/// Search every stored query once and print what came back
Run(WishRunArgs),
}
#[derive(Args, Debug)]
pub struct WishRunArgs {
/// Only run this wish instead of all of them
#[arg(long, value_name = "QUERY")]
pub only: Option<String>,
#[command(flatten)]
pub filter: FilterArgs,
/// Print at most this many results per wish (0 means all)
#[arg(short = 'n', long, default_value_t = 0)]
pub limit: usize,
/// Result order
#[arg(long, value_enum, default_value_t = SortKey::Best)]
pub sort: SortKey,
}
#[derive(Subcommand, Debug)]
pub enum RoomCommand {
/// List public rooms and their user counts