mod schema; fn runtime() -> &'static tokio::runtime::Runtime { static RUNTIME: std::sync::OnceLock = std::sync::OnceLock::new(); RUNTIME.get_or_init(|| { tokio::runtime::Runtime::new().expect("could not initialize the tokio runtime") }) } #[derive(Debug)] pub enum Error { UrlParseError(url::ParseError), ReqwestError(reqwest::Error), SubsonicError(schema::Error), OtherError(&'static str), } impl From for Error { fn from(err: reqwest::Error) -> Self { // don't print secret salt/token combo Self::ReqwestError(err.without_url()) } } pub struct Client { client: reqwest::Client, base_url: reqwest::Url, } fn to_hex_str(bytes: &[u8]) -> String { let mut hex = vec![0u8; 2 * bytes.len()]; for (i, byte) in bytes.iter().enumerate() { hex[2 * i] = b"0123456789abcdef"[*byte as usize >> 4]; hex[2 * i + 1] = b"0123456789abcdef"[*byte as usize & 0xf]; } String::from_utf8(hex).unwrap() // is literally just ascii } impl Client { pub fn with_password(url: &str, username: &str, password: &[u8]) -> Result { const SALT_BYTES: usize = 8; // subsonic docs say to generate a salt per request, but that's completely unnecessary let mut salt = vec![0u8; SALT_BYTES]; openssl::rand::rand_bytes(&mut salt).expect("could not generate random salt"); let salt_hex = to_hex_str(&salt); let mut hasher = openssl::hash::Hasher::new(openssl::hash::MessageDigest::md5()) .expect("could not create hasher object"); hasher .update(password) .expect("could not feed password to hasher"); hasher .update(salt_hex.as_bytes()) .expect("could not feed random salt to hasher"); let token = hasher .finish() .expect("could not finish hashing password with random salt"); let token_hex = to_hex_str(&token); Self::with_token(url, username, &token_hex, &salt_hex) } pub fn with_token(url: &str, username: &str, token: &str, salt: &str) -> Result { let base_url = reqwest::Url::parse_with_params( url, &[ ("u", username), ("t", token), ("s", salt), ("v", "1.16.1"), ("c", crate::APP_ID), ("f", "json"), ], ) .map_err(Error::UrlParseError)?; if base_url.scheme() != "http" && base_url.scheme() != "https" { return Err(Error::OtherError("Url scheme is not HTTP(s)")); } Ok(Client { client: reqwest::Client::builder() .user_agent(crate::USER_AGENT) .build()?, base_url, }) } async fn send( &self, request: reqwest::RequestBuilder, ) -> Result { // FIXME: is an entire channel per request overkill? maybe pool them? let (sender, receiver) = async_channel::bounded(1); // let tokio take care of the request + further json parsing // this is because reqwest doesn't like the glib main loop // note that this is why those silly bounds on T are needed, because we're // sending back the result of the query from another thread let future = request.send(); runtime().spawn(async move { // wrap this logic in a fn so we can use ? async fn perform( response: Result, ) -> Result, reqwest::Error> { Ok(response? .error_for_status()? .json::>() .await? .subsonic_response) } sender .send(perform(future.await).await) .await .expect("could not send subsonic response back to the main loop"); }); let response = receiver .recv() .await .expect("could not receive subsonic response from tokio")?; match response { schema::SubsonicResponse::Ok { inner } => Ok(inner), schema::SubsonicResponse::Failed { error } => Err(Error::SubsonicError(error)), } } async fn get( &self, path: &[&str], query: &[(&str, &str)], ) -> Result { let mut url = self.base_url.clone(); url.path_segments_mut() // literally can't fail .unwrap_or_else(|_| unsafe { std::hint::unreachable_unchecked() }) .extend(path); self.send(self.client.get(url).query(query)).await } pub async fn ping(&self) -> Result<(), Error> { self.get(&["rest", "ping"], &[]).await } pub async fn get_random_songs(&self, size: u32) -> Result, Error> { self.get::( &["rest", "getRandomSongs"], &[("size", &size.to_string())], ) .await .map(|response| response.random_songs.song) } }