A clean-room, native Rust client for the Soulseek peer-to-peer network.
RustSoSeek is a library, not an application: you embed it, spawn a client,
and drive it with async calls. It implements the Soulseek wire protocol
directly over TCP — no slskd sidecar, no HTTP API, no external process.
What it does today:
- Login to the Soulseek server (documented MD5 password hash scheme)
- Search — server file search, result normalization (bitrate/duration/ extension parsing), health-sorted results
- Downloads — queue a file from a peer and stream it to disk, including all the connection-direction interop needed behind firewalls/NAT (see How downloads work)
- Distributed search plumbing — parent/child
Dconnections and search relaying - Sharing (uploads) — configure
shared_dirs, get indexed once (with clean-room FLAC/MP3 metadata extraction), answer server and distributed searches, serve downloads over a slot/FIFO upload queue (upload_status,cancel_upload,take_failed_uploads) - Browsing — look up any user's stats (
user_stats), their full share tree (browse_user), or one folder's contents (folder_contents); turn a single search result into its whole source folder withresult_source_folder
What it deliberately does not do (v1): chat rooms, private messaging, wishlist/recommendations, resume support, and the "Rotated" (type 1) obfuscation cipher (only obfuscation type 0, matching Nicotine+).
Licensed under Apache-2.0.
This is a clean-room implementation: it was written from the public
Soulseek protocol documentation only (SLSKPROTOCOL.md, Museek+ wiki) and
contains no code translated or copied from slskd (AGPL-3.0),
Soulseek.NET (GPL-3.0), Nicotine+ (GPL-3.0), aioslsk, or museek+.
[dependencies]
tokio = { version = "1", features = ["full"] }
rustsoseek = { git = "https://github.com/Scarlet-Raine/RustSoSeek", tag = "v0.2.0" }Requires Rust 1.88+ (tokio MSRV baseline for this workspace).
Connect, search, pick a result, download it:
use rustsoseek::{NativeClient, NativeConfig};
#[tokio::main]
async fn main() -> Result<(), rustsoseek::Error> {
let client = NativeClient::connect(NativeConfig {
username: "my-username".into(),
password: "my-password".into(),
// everything else has a sane default (see Configuration below)
..NativeConfig::default()
})
.await?;
// 1. Start a search; returns a token used to collect results.
let token = client.start_search("artist album flac").await?;
// 2. Results accumulate asynchronously as peers answer (~15 s covers
// most of the first wave).
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
let results = client.results(token, 100);
// 3. Pick a candidate. free_upload_slots=true peers answer immediately;
// smaller files finish faster; low queue_length helps otherwise.
let Some(pick) = results
.iter()
.find(|r| r.free_upload_slots == Some(true) && r.size.unwrap_or(0) > 0)
else {
return Ok(()); // nothing viable yet
};
// 4. Queue the download. Pass the filename exactly as the result
// reported it (any mix of / and \ separators works).
client
.download(&pick.username, &pick.filename, pick.size.unwrap_or(0))
.await?;
// 5. Poll progress until the entry disappears (complete or cleaned up).
loop {
let done = {
let status = client.download_status();
let mine = status.iter().find(|s| s.username == pick.username);
match mine {
Some(s) => {
println!("{} / {}", s.offset, s.size);
false
}
None => true, // finished (or refused — see below)
}
};
if done {
break;
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// 6. Check whether the peer actually delivered. A refusal lands here
// instead of hanging forever as "queued".
if !client.take_failed_downloads().is_empty() {
eprintln!("peer refused the download; try another result");
}
Ok(())
}Files land inside download_dir, named after the final path segment of the
peer's filename. When the expected size has been received the client closes
the connection — closing is always the downloader's job.
| Field | Type | Default | Meaning |
|---|---|---|---|
server_addr |
String |
vps.slsknet.org:2242 |
Soulseek server |
username |
String |
— | account name |
password |
String |
— | account password |
listen_port |
u16 |
2234 |
local TCP port for inbound peer connections (search responses, direct file delivery) |
download_dir |
String |
downloads |
where completed files are written |
shared_dirs |
Vec<String> |
[] |
local directories shared with the network; indexed at connect and by rescan_shares() |
max_upload_slots |
usize |
1 |
simultaneous uploads to peers |
max_uploads_per_user |
usize |
1 |
per-peer cap on concurrent accepted uploads |
major_version |
u32 |
177 |
reserved — see below |
minor_version |
u32 |
710 |
reserved — see below |
connect() performs login, announces listen_port, opens the listen socket,
and spawns the background keep-alive/server/peer/relay tasks. It fails fast
on unreachable servers (Error::Unavailable) and bad credentials
(Error::AuthenticationFailed). The returned NativeClient is cheap to
Clone.
agpeer logs in with major version 177, minor version 710. Do not reuse
these numbers elsewhere.
// --- search ---
pub async fn start_search(&self, query: &str) -> Result<u32> // -> search token
pub fn results(&self, token: u32, max_results: usize) -> Vec<SearchResult>
pub fn stop_search(&self, token: u32)
// --- downloads ---
pub async fn download(&self, username: &str, filename: &str, size: u64) -> Result<()>
pub fn download_status(&self) -> Vec<DownloadStatus>
pub fn take_failed_downloads(&self) -> Vec<(String, String)>
pub fn cancel(&self, filename: &str, delete_data: bool)
// --- sharing (uploads) ---
pub fn rescan_shares(&self) -> Option<(usize, usize)> // (roots, files), Some when shares configured
pub fn upload_status(&self) -> Vec<UploadStatus> // active + queued uploads
pub fn take_failed_uploads(&self) -> Vec<(String, String)>
pub fn cancel_upload(&self, username: &str, filename: &str)
// --- browsing users / folders ---
pub async fn user_stats(&self, username: &str) -> Result<UserStatsInfo>
pub async fn browse_user(&self, username: &str) -> Result<BrowseResultInfo>
pub async fn folder_contents(&self, username: &str, dir: &str) -> Result<FolderContentsResult>
pub async fn result_source_folder(&self, result: &SearchResult) -> Result<SourceFolderView>
// --- introspection ---
pub fn listen_addr(&self) -> SocketAddr // your advertised peer address
pub fn parent(&self) -> Option<SocketAddr> // distributed parent, if any
pub fn excluded_phrases(&self) -> Vec<String> // server-provided search exclusionsSemantics that matter:
resultsreturns up tomax_resultsentries sorted by health: free upload slots first, then faster uploaders, then shorter queues. Each call re-snapshots accumulated state; searches keep collecting until stopped.download_statuscontains an entry per accepted, unfinished download. Entries disappear once the transfer completes (or is cancelled/refused), so "no matching entry" means finished — cross-checktake_failed_downloadsto distinguish success from refusal.take_failed_downloadsdrains(username, filename)pairs for every time a peer answeredUploadFailed(file gone from their share, policy rejection, or they gave up on delivering). Filenames echo the peer's own separator form; compare paths separator-insensitively.cancelmatches by filename (separator-insensitive). Stops any active write immediately;delete_data = truealso deletes the partial file.- No resume: a fresh download always starts from offset 0. Killing a
transfer mid-stream leaves a partial file; start over or cancel with
delete_data.
pub struct SearchResult {
pub username: String,
pub filename: String, // peer's share-index path (either separator style)
pub size: Option<u64>,
pub extension: Option<String>, // e.g. Some("flac")
pub bitrate: Option<u32>,
pub duration: Option<u32>,
pub queue_length: Option<u32>,
pub free_upload_slots: Option<bool>,
pub upload_speed: Option<u64>,
pub token: u32,
}pub struct DownloadStatus {
pub username: String,
pub filename: String, // normalized (forward-slash) form
pub size: u64,
pub offset: u64, // bytes written so far
}Unavailable — cannot reach/login sequence failed pre-auth
AuthenticationFailed — server rejected credentials
InvalidMessage(String)— malformed protocol data
Io(String) — socket/filesystem failure
Internal(String) — invariant violation, please report
Understanding this section explains every log line starting with
soulseek: (target rustsoseek::native).
The queue handshake runs over a P (peer) connection:
- We resolve the peer's address via the server (
GetPeerAddress), dial it, introduce ourselves withPeerInit(P). - We send
QueueUpload(filename)— separators normalized to forward slashes, which every mainstream uploader accepts regardless of how their share index is formatted. - The peer eventually replies
TransferRequest(direction=Upload, token, filename, size)— often seconds later for free-slot peers, minutes for queued ones. We match it against pending downloads (separator-insensitively — Windows peers echo backslash paths) and accept withTransferResponse.
Then the file arrives over an F connection, via whichever of three paths
the peer supports — you don't choose, the client handles all of them:
| Path | Who dials | Typical source |
|---|---|---|
| Direct inbound | peer connects to our listen_port |
port-forwarded hosts |
Relayed (PierceFireWall) |
we connect out to the peer after a ConnectToPeer(conn_type="F") nudge via the server — the standard path when either side is behind NAT |
most uploaders, no port forwarding needed anywhere |
| Outbound fallback | we connect out ~800 ms after accepting, send PeerInit(F) + FileTransferInit + FileOffset |
slskd-style uploaders that expect the downloader to dial |
On any established F connection the downloader declares its resume offset
(FileOffset, 8 bytes little-endian, 0 for fresh transfers) and the
uploader streams raw file bytes until the announced TransferRequest size is
reached. Exactly one delivery path wins per transfer (a claim guard prevents
two paths writing the same file); the loser aborts quietly — look for
outbound F aborted; transfer already streaming at DEBUG level.
Failure modes surface honestly:
- Peer never answers → the download stays visible in
download_statusindefinitely (their slot may still open). Cancel it yourself if you'd rather not wait. - Peer refuses (
UploadFailed) → entry cleaned up and the refusal recorded fortake_failed_downloads.
Downloads require no port forwarding: relayed delivery covers firewalled hosts for the download direction.
When shared_dirs is non-empty the client indexes every file once at
connect and announces folder/file totals (SharedFoldersFiles). Virtual
paths shown to peers are forward-slash paths rooted at each shared
directory's own name — sharing D:\music exposes music/artist/song.flac.
- Searches arrive via server relays (code 26) and distributed children;
matches are substring/term-based, filtered by
excluded_phrases(), capped at 100 files per response, and sent to the searcher over a directPconnection. - Uploads:
QueueUploadfor a shared path is offered immediately when a slot is free (uploader-sideTransferRequest), otherwise it enters a FIFO queue answered withPlaceInQueueResponse. Offered-but-unaccepted offers expire after 5 minutes; unfinished streams are freed by the same sweep. Completing an upload promotes the next queued peer automatically. - Audio metadata is extracted by minimal in-house parsers: FLAC
STREAMINFO(exact duration/bitrate from sample count) and MP3 frame headers (CBR estimate). Files that fail to parse simply share without attributes.
Browsing uses direct peer connections with token correlation:
browse_user returns the full folder tree, folder_contents a single
directory listing, and result_source_folder combines one search result
with browse_user to return the containing folder plus all siblings.
The crate logs through tracing under target rustsoseek::native: INFO for
connection lifecycle and transfer outcomes, DEBUG for per-message traffic.
A useful filter when debugging transfers:
RUST_LOG=info,rustsoseek=debugNever log at TRACE in production — DEBUG already includes usernames and filenames.
Offline unit and integration tests use an in-process mock server and mock
peer (src/mocknet.rs) covering login, search, both file-delivery paths
(direct inbound and PierceFireWall relay), and refusal handling. They never
touch the live network:
cargo test
cargo clippy --all-targets --all-features -- -D warnings
cargo fmt -- --checkAn opt-in live test exercises login + search against the real network. It is
#[ignore]d and gated behind environment variables:
AGPEER_LIVE_SOULSEEK=1 \
AGPEER_SOULSEEK_USERNAME=... \
AGPEER_SOULSEEK_PASSWORD=... \
cargo test --test live_soulseek -- --ignoredagpeer logs in with major version 177, minor version 710. Do not reuse
these numbers elsewhere.