156 lines
5.3 KiB
Rust
156 lines
5.3 KiB
Rust
mod schema;
|
|
|
|
fn runtime() -> &'static tokio::runtime::Runtime {
|
|
static RUNTIME: std::sync::OnceLock<tokio::runtime::Runtime> = 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<reqwest::Error> 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<Self, Error> {
|
|
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<Self, Error> {
|
|
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<T: serde::de::DeserializeOwned + Send + 'static>(
|
|
&self,
|
|
request: reqwest::RequestBuilder,
|
|
) -> Result<T, Error> {
|
|
// 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<T: serde::de::DeserializeOwned + Send + 'static>(
|
|
response: Result<reqwest::Response, reqwest::Error>,
|
|
) -> Result<schema::SubsonicResponseOuter<T>, reqwest::Error> {
|
|
response?
|
|
.error_for_status()?
|
|
.json::<schema::SubsonicResponseOuter<T>>()
|
|
.await
|
|
}
|
|
|
|
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")?
|
|
.subsonic_response;
|
|
|
|
match response {
|
|
schema::SubsonicResponse::Ok { inner } => Ok(inner),
|
|
schema::SubsonicResponse::Failed { error } => Err(Error::SubsonicError(error)),
|
|
}
|
|
}
|
|
|
|
async fn get<T: serde::de::DeserializeOwned + Send + 'static>(
|
|
&self,
|
|
path: &[&str],
|
|
query: &[(&str, &str)],
|
|
) -> Result<T, Error> {
|
|
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<Vec<schema::Child>, Error> {
|
|
self.get::<schema::RandomSongsOuter>(
|
|
&["rest", "getRandomSongs"],
|
|
&[("size", &size.to_string())],
|
|
)
|
|
.await
|
|
.map(|response| response.random_songs.song)
|
|
}
|
|
}
|