audrey/src/subsonic.rs
2024-11-01 10:22:36 +01:00

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)
}
}