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:
@@ -1,11 +1,104 @@
|
||||
use super::{
|
||||
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, RwLock,
|
||||
RwLockExt, collect_failed_tokens, error, thread,
|
||||
ActiveUpload, Arc, Client, ClientContext, DownloadStatus, PeerMessage,
|
||||
RwLock, RwLockExt, collect_failed_tokens, error, next_upload_token, thread,
|
||||
};
|
||||
use crate::types::UploadStatus;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64};
|
||||
|
||||
/// The peer behind a registry key, which may carry a `:direct` suffix the
|
||||
/// server never sees.
|
||||
pub fn downloader_of(requester_key: &str) -> &str {
|
||||
requester_key
|
||||
.strip_suffix(":direct")
|
||||
.unwrap_or(requester_key)
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Whether the server listed `username` as privileged. Privileged peers are
|
||||
/// served before everyone else.
|
||||
#[must_use]
|
||||
pub fn is_privileged(&self, username: &str) -> bool {
|
||||
self.context
|
||||
.read_safe()
|
||||
.is_ok_and(|ctx| ctx.is_privileged(username))
|
||||
}
|
||||
|
||||
/// Ask the server how much of our own privilege time is left (code 92). The
|
||||
/// answer arrives asynchronously; read it with
|
||||
/// [`Client::own_privilege_seconds`].
|
||||
///
|
||||
/// # Errors
|
||||
/// [`crate::error::SoulseekRs::NotConnected`] without a server connection.
|
||||
pub fn check_privileges(&self) -> crate::error::Result<()> {
|
||||
let Some(handle) = &self.server_handle else {
|
||||
return Err(crate::error::SoulseekRs::NotConnected);
|
||||
};
|
||||
let _ = handle.send(super::ServerMessage::CheckPrivileges);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Seconds of our own privileges left, or `None` until the server has
|
||||
/// answered a [`Client::check_privileges`].
|
||||
#[must_use]
|
||||
pub fn own_privilege_seconds(&self) -> Option<u32> {
|
||||
self.context
|
||||
.read_safe()
|
||||
.ok()
|
||||
.and_then(|ctx| ctx.own_privileges)
|
||||
}
|
||||
|
||||
/// How many uploads run at once. Lowering it does not interrupt transfers
|
||||
/// already in flight; the queue simply refills more slowly.
|
||||
pub fn set_upload_slots(&self, slots: usize) {
|
||||
if let Ok(mut ctx) = self.context.write_safe() {
|
||||
ctx.upload_slots = slots.max(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop everything `username` was queued or offered, then hand their slot to
|
||||
/// whoever is next.
|
||||
pub(crate) fn release_upload_slots(
|
||||
client_context: &Arc<RwLock<ClientContext>>,
|
||||
username: &str,
|
||||
) {
|
||||
let freed = client_context
|
||||
.write_safe()
|
||||
.is_ok_and(|mut ctx| ctx.release_upload_slots(username));
|
||||
if freed {
|
||||
Self::pump_upload_queue(client_context);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill every free upload slot from the queue and send the offers.
|
||||
///
|
||||
/// Called whenever the queue or the slot count could have changed: a new
|
||||
/// request, a finished transfer, or a privileged list that re-ranked who is
|
||||
/// waiting.
|
||||
pub(crate) fn pump_upload_queue(
|
||||
client_context: &Arc<RwLock<ClientContext>>,
|
||||
) {
|
||||
let (registry, offers) = match client_context.write_safe() {
|
||||
Ok(mut ctx) => ctx.pump_uploads(next_upload_token),
|
||||
Err(e) => {
|
||||
error!("[client] pump_upload_queue write: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(registry) = registry else {
|
||||
return;
|
||||
};
|
||||
for offer in offers {
|
||||
let _ = registry.send_to_peer(
|
||||
&offer.requester_key,
|
||||
PeerMessage::ServeUpload {
|
||||
token: offer.token,
|
||||
filename: offer.virtual_path,
|
||||
size: offer.size,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume the upload job for `token` and stream the file to `host:port`
|
||||
/// on a background thread.
|
||||
pub(crate) fn spawn_serve(
|
||||
@@ -64,6 +157,9 @@ impl Client {
|
||||
{
|
||||
upload.status = status;
|
||||
}
|
||||
// The slot this transfer held is free now, so whoever is next in
|
||||
// line gets it without waiting for another request to arrive.
|
||||
Self::pump_upload_queue(&context);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user