use std::time::{Duration, Instant};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::acquire::error::{BackendError, Result};
use crate::acquire::http;
use crate::acquire::types::BackendId;
const ID: BackendId = BackendId::Soulseek;
pub const MIN_SEARCH_WINDOW_SECS: u64 = 5;
const SEARCH_POLL: Duration = Duration::from_millis(500);
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct File {
pub filename: String,
pub size: u64,
#[serde(default)]
pub extension: Option<String>,
#[serde(default)]
pub bit_rate: Option<u32>,
#[serde(default)]
pub bit_depth: Option<u32>,
#[serde(default)]
pub sample_rate: Option<u32>,
#[serde(default)]
pub is_variable_bit_rate: Option<bool>,
#[serde(default)]
pub length: Option<i64>,
#[serde(default)]
pub is_locked: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
pub username: String,
#[serde(default)]
pub has_free_upload_slot: bool,
#[serde(default)]
pub upload_speed: u32,
#[serde(default)]
pub queue_length: u64,
#[serde(default)]
pub files: Vec<File>,
#[serde(default)]
pub locked_files: Vec<File>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Search {
#[serde(default)]
pub is_complete: bool,
#[serde(default)]
pub response_count: u32,
#[serde(default)]
pub file_count: u32,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchRequest<'a> {
pub id: String,
pub search_text: &'a str,
pub search_timeout: u64,
pub response_limit: usize,
pub filter_responses: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Transfer {
pub id: String,
pub username: String,
pub filename: String,
#[serde(default)]
pub size: u64,
pub state: TransferState,
#[serde(default)]
pub bytes_transferred: u64,
#[serde(default)]
pub average_speed: f64,
#[serde(default)]
pub place_in_queue: Option<u64>,
#[serde(default)]
pub exception: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct TransferState(pub String);
impl TransferState {
fn has(&self, flag: &str) -> bool {
self.0
.split(',')
.any(|f| f.trim().eq_ignore_ascii_case(flag))
}
pub fn succeeded(&self) -> bool {
self.has("Completed") && self.has("Succeeded")
}
pub fn is_terminal(&self) -> bool {
self.has("Completed")
}
pub fn failed(&self) -> bool {
self.is_terminal() && !self.succeeded()
}
pub fn is_queued(&self) -> bool {
self.has("Queued")
}
pub fn in_progress(&self) -> bool {
self.has("InProgress")
}
pub fn raw(&self) -> &str {
&self.0
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchRequest<'a> {
pub id: String,
pub username: &'a str,
pub files: Vec<BatchItem<'a>>,
pub options: BatchOptions<'a>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchItem<'a> {
pub filename: &'a str,
pub size: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchOptions<'a> {
pub destination: &'a str,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchResponse {
#[serde(default)]
pub batch: Option<Batch>,
#[serde(default)]
pub failures: Vec<BatchFailure>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchFailure {
#[serde(default)]
pub filename: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Batch {
pub id: String,
#[serde(default)]
pub transfers: Vec<Transfer>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilesystemDirectory {
#[serde(default)]
pub files: Vec<FilesystemFile>,
#[serde(default)]
pub directories: Vec<FilesystemDirectory>,
}
impl FilesystemDirectory {
pub fn walk(&self) -> Vec<&FilesystemFile> {
let mut out: Vec<&FilesystemFile> = self.files.iter().collect();
for d in &self.directories {
out.extend(d.walk());
}
out
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FilesystemFile {
pub name: String,
#[serde(default)]
pub full_name: String,
#[serde(default)]
pub length: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Options {
#[serde(default)]
pub directories: Directories,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Directories {
#[serde(default)]
pub downloads: String,
#[serde(default)]
pub incomplete: String,
}
pub struct Client {
base: String,
api_key: String,
budget: Duration,
}
impl Client {
pub fn new(base: &str, api_key: &str, budget: Duration) -> Self {
Self {
base: base.trim().trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
budget,
}
}
fn url(&self, path: &str) -> String {
format!("{}/api/v0/{path}", self.base)
}
pub fn search(&self, req: &SearchRequest<'_>, deadline: Instant) -> Result<Vec<Response>> {
let url = self.url("searches");
let agent = http::agent(self.budget);
http::with_retries(3, || {
agent
.post(&url)
.header("X-API-Key", &self.api_key)
.send_json(req)
.map_err(|e| http::map_err(ID, &url, e))?;
Ok(())
})?;
let started = Instant::now();
let mut complete = false;
while Instant::now() < deadline {
std::thread::sleep(SEARCH_POLL);
let s: Search = self.get_json(&format!("searches/{}", req.id), "search state")?;
if s.is_complete {
complete = true;
break;
}
}
if !complete {
return Err(BackendError::Timeout {
backend: ID,
op: "search",
elapsed: started.elapsed(),
});
}
let url = self.url(&format!("searches/{}/responses", req.id));
let agent = http::download_agent(self.budget);
let body = http::with_retries(3, || {
agent
.get(&url)
.header("X-API-Key", &self.api_key)
.call()
.map_err(|e| http::map_err(ID, &url, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(ID, &url, e))
})?;
serde_json::from_str(&body)
.map_err(|e| BackendError::parse(ID, "search responses", e.to_string()))
}
pub fn forget_search(&self, id: &str) {
let url = self.url(&format!("searches/{id}"));
let agent = http::agent(self.budget);
let _ = agent.delete(&url).header("X-API-Key", &self.api_key).call();
}
pub fn enqueue(&self, req: &BatchRequest<'_>) -> Result<BatchResponse> {
let url = self.url("transfers/downloads/batches");
let agent = http::agent(self.budget);
let body = http::with_retries(3, || {
agent
.post(&url)
.header("X-API-Key", &self.api_key)
.send_json(req)
.map_err(|e| http::map_err(ID, &url, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(ID, &url, e))
})?;
serde_json::from_str(&body)
.map_err(|e| BackendError::parse(ID, "enqueue response", e.to_string()))
}
pub fn batch(&self, id: &str) -> Result<Batch> {
self.get_json(&format!("transfers/downloads/batches/{id}"), "batch")
}
pub fn options(&self) -> Result<Options> {
self.get_json("options", "options")
}
pub fn list_downloads(&self, subdirectory: &str) -> Result<FilesystemDirectory> {
let path = if subdirectory.is_empty() {
"files/downloads/directories?recursive=true".to_string()
} else {
format!(
"files/downloads/directories/{}?recursive=true",
encode_path_segment(&base64_standard(subdirectory.as_bytes()))
)
};
self.get_json(&path, "download listing")
}
pub fn delete_download_subdirectory(&self, subdirectory: &str) -> bool {
let url = self.url(&format!(
"files/downloads/directories/{}",
encode_path_segment(&base64_standard(subdirectory.as_bytes()))
));
let agent = http::agent(self.budget);
agent
.delete(&url)
.header("X-API-Key", &self.api_key)
.call()
.is_ok()
}
fn get_json<T: DeserializeOwned>(&self, path: &str, what: &'static str) -> Result<T> {
let url = self.url(path);
let agent = http::agent(self.budget);
let body = http::with_retries(3, || {
agent
.get(&url)
.header("X-API-Key", &self.api_key)
.call()
.map_err(|e| http::map_err(ID, &url, e))?
.body_mut()
.read_to_string()
.map_err(|e| http::map_err(ID, &url, e))
})?;
serde_json::from_str(&body).map_err(|e| BackendError::parse(ID, what, e.to_string()))
}
}
pub fn is_conflict(e: &BackendError) -> bool {
matches!(e, BackendError::Http { status: 409, .. })
}
pub fn base64_standard(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
pub fn encode_path_segment(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(*b as char)
}
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
pub fn basic_auth(user: &str, password: &str) -> String {
format!(
"Basic {}",
base64_standard(format!("{user}:{password}").as_bytes())
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_completed_and_succeeded_is_a_finished_download() {
let ok = TransferState("Completed, Succeeded".into());
assert!(ok.succeeded());
assert!(ok.is_terminal());
assert!(!ok.failed());
}
#[test]
fn every_other_completed_pairing_is_a_failure() {
for flag in ["Cancelled", "TimedOut", "Errored", "Rejected", "Aborted"] {
let s = TransferState(format!("Completed, {flag}"));
assert!(s.is_terminal(), "{flag} is terminal");
assert!(s.failed(), "{flag} is a failure");
assert!(!s.succeeded(), "{flag} must not read as success");
}
}
#[test]
fn an_unknown_or_bare_completed_state_fails_closed() {
let bare = TransferState("Completed".into());
assert!(bare.is_terminal());
assert!(bare.failed());
let future = TransferState("Completed, SomethingNew".into());
assert!(future.is_terminal());
assert!(!future.succeeded());
}
#[test]
fn in_flight_states_are_not_terminal() {
for raw in [
"Requested",
"Queued, Remotely",
"Queued, Locally",
"InProgress",
"Initializing",
] {
let s = TransferState(raw.into());
assert!(!s.is_terminal(), "{raw} should not be terminal");
assert!(!s.succeeded(), "{raw} should not be a success");
}
assert!(TransferState("Queued, Remotely".into()).is_queued());
assert!(TransferState("InProgress".into()).in_progress());
}
#[test]
fn flags_parse_regardless_of_spacing_and_case() {
assert!(TransferState("completed,succeeded".into()).succeeded());
assert!(TransferState("Completed , Succeeded".into()).succeeded());
}
#[test]
fn files_deserialize_with_camel_case_and_omitted_nulls() {
let f: File = serde_json::from_str(
r#"{"filename":"@@a\\b - c.mp3","size":100,"extension":".mp3",
"bitRate":320,"isVariableBitRate":false,"length":210,"isLocked":false}"#,
)
.unwrap();
assert_eq!(f.bit_rate, Some(320));
assert_eq!(f.extension.as_deref(), Some(".mp3"));
assert_eq!(f.length, Some(210));
let sparse: File = serde_json::from_str(r#"{"filename":"x.flac","size":1}"#).unwrap();
assert_eq!(sparse.bit_rate, None);
assert_eq!(sparse.extension, None);
assert!(!sparse.is_locked);
}
#[test]
fn a_response_carries_the_peer_facts_used_for_ranking() {
let r: Response = serde_json::from_str(
r#"{"username":"peer","hasFreeUploadSlot":true,"uploadSpeed":900,
"queueLength":7,"files":[],"lockedFiles":[]}"#,
)
.unwrap();
assert!(r.has_free_upload_slot);
assert_eq!(r.queue_length, 7);
assert_eq!(r.upload_speed, 900);
}
#[test]
fn a_transfer_deserializes_including_its_queue_position() {
let t: Transfer = serde_json::from_str(
r#"{"id":"abc","username":"peer","filename":"a\\b.flac","size":10,
"state":"Queued, Remotely","bytesTransferred":0,"averageSpeed":0,
"placeInQueue":12}"#,
)
.unwrap();
assert_eq!(t.place_in_queue, Some(12));
assert!(t.state.is_queued());
}
#[test]
fn the_download_tree_flattens() {
let d: FilesystemDirectory = serde_json::from_str(
r#"{"files":[{"name":"a.flac","fullName":"/d/a.flac","length":1}],
"directories":[{"files":[{"name":"b.flac","fullName":"/d/s/b.flac","length":2}],
"directories":[]}]}"#,
)
.unwrap();
let all = d.walk();
assert_eq!(all.len(), 2);
assert!(all.iter().any(|f| f.name == "b.flac" && f.length == 2));
}
#[test]
fn base64_matches_the_rfc_vectors() {
assert_eq!(base64_standard(b""), "");
assert_eq!(base64_standard(b"f"), "Zg==");
assert_eq!(base64_standard(b"fo"), "Zm8=");
assert_eq!(base64_standard(b"foo"), "Zm9v");
assert_eq!(base64_standard(b"foobar"), "Zm9vYmFy");
}
#[test]
fn path_segments_escape_everything_a_soulseek_name_can_contain() {
assert_eq!(
encode_path_segment("plain-file_1.flac"),
"plain-file_1.flac"
);
assert_eq!(encode_path_segment("with space"), "with%20space");
assert_eq!(encode_path_segment("a/b"), "a%2Fb");
assert_eq!(encode_path_segment("q?x=1&y"), "q%3Fx%3D1%26y");
assert_eq!(encode_path_segment("#hash"), "%23hash");
assert_eq!(encode_path_segment("[1992]"), "%5B1992%5D");
assert_eq!(encode_path_segment("it's"), "it%27s");
assert_eq!(encode_path_segment("Zm8+/A=="), "Zm8%2B%2FA%3D%3D");
assert_eq!(encode_path_segment("é"), "%C3%A9");
}
#[test]
fn basic_auth_is_a_base64_user_colon_password() {
assert_eq!(
basic_auth("Aladdin", "open sesame"),
"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ=="
);
}
#[test]
fn a_conflict_is_recognised_as_the_attach_signal() {
assert!(is_conflict(&BackendError::Http {
backend: ID,
status: 409,
url: String::new(),
}));
assert!(!is_conflict(&BackendError::Http {
backend: ID,
status: 404,
url: String::new(),
}));
}
#[test]
fn urls_join_without_doubling_slashes() {
let c = Client::new("https://host:5030/", "k", Duration::from_secs(5));
assert_eq!(c.url("searches"), "https://host:5030/api/v0/searches");
}
}