use reqwest::Client as ReqwestClient;
use reqwest::Url;
use serde_json;
use media::NowPlaying;
use query::Query;
use response::Response;
use search::{SearchPage, SearchResult};
use {Album, Artist, Error, Genre, Hls, Lyrics, MusicFolder, Result, Song, UrlError, Version};
const SALT_SIZE: usize = 36;
#[derive(Debug)]
pub struct Client {
url: Url,
auth: SubsonicAuth,
reqclient: ReqwestClient,
pub ver: Version,
pub target_ver: Version,
}
#[derive(Debug)]
struct SubsonicAuth {
user: String,
password: String,
}
impl SubsonicAuth {
fn new(user: &str, password: &str) -> SubsonicAuth {
SubsonicAuth {
user: user.into(),
password: password.into(),
}
}
fn to_url(&self, ver: Version) -> String {
let auth = if ver >= "1.13.0".into() {
use md5;
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use std::iter;
let mut rng = thread_rng();
let salt: String = iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.take(SALT_SIZE)
.collect();
let pre_t = self.password.to_string() + &salt;
let token = format!("{:x}", md5::compute(pre_t.as_bytes()));
format!("u={u}&t={t}&s={s}", u = self.user, t = token, s = salt)
} else {
format!("u={u}&p={p}", u = self.user, p = self.password)
};
let format = "json";
let crate_name = env!("CARGO_PKG_NAME");
format!(
"{auth}&v={v}&c={c}&f={f}",
auth = auth,
v = ver,
c = crate_name,
f = format
)
}
}
impl Client {
pub fn new(url: &str, user: &str, password: &str) -> Result<Client> {
let auth = SubsonicAuth::new(user, password);
let url = url.parse::<Url>()?;
let ver = Version::from("1.14.0");
let target_ver = ver;
let reqclient = ReqwestClient::builder().build()?;
Ok(Client {
url,
auth,
reqclient,
ver,
target_ver,
})
}
pub fn with_target(self, ver: Version) -> Client {
let mut cli = self;
cli.target_ver = ver;
cli
}
#[cfg_attr(feature = "cargo-clippy", allow(needless_pass_by_value))]
pub(crate) fn build_url(&self, query: &str, args: Query) -> Result<String> {
let scheme = self.url.scheme();
let addr = self
.url
.host_str()
.ok_or_else(|| Error::Url(UrlError::Address))?;
let mut url = [scheme, "://", addr, "/rest/"].concat();
url.push_str(query);
url.push_str("?");
url.push_str(&self.auth.to_url(self.target_ver));
url.push_str("&");
url.push_str(&args.to_string());
Ok(url)
}
pub(crate) fn get(&self, query: &str, args: Query) -> Result<serde_json::Value> {
let uri: Url = self.build_url(query, args)?.parse().unwrap();
info!("Connecting to {}", uri);
let mut res = self.reqclient.get(uri).send()?;
if res.status().is_success() {
let response = res.json::<Response>()?;
if response.is_ok() {
Ok(match response.into_value() {
Some(v) => v,
None => serde_json::Value::Null,
})
} else {
Err(response
.into_error()
.map(|e| e.into())
.ok_or_else(|| Error::Other("unable to retrieve error"))?)
}
} else {
Err(Error::Connection(res.status()))
}
}
pub(crate) fn get_raw(&self, query: &str, args: Query) -> Result<String> {
let uri: Url = self.build_url(query, args)?.parse().unwrap();
let mut res = self.reqclient.get(uri).send()?;
Ok(res.text()?)
}
pub(crate) fn get_bytes(&self, query: &str, args: Query) -> Result<Vec<u8>> {
use std::io::Read;
let uri: Url = self.build_url(query, args)?.parse().unwrap();
let res = self.reqclient.get(uri).send()?;
Ok(res.bytes().map(|b| b.unwrap()).collect())
}
pub fn hls_bytes(&self, hls: &Hls) -> Result<Vec<u8>> {
use std::io::Read;
let url: Url = self.url.join(&hls.url)?;
let res = self.reqclient.get(url).send()?;
Ok(res.bytes().map(|b| b.unwrap()).collect())
}
pub fn ping(&self) -> Result<()> {
self.get("ping", Query::none())?;
Ok(())
}
pub fn check_license(&self) -> Result<License> {
let res = self.get("getLicense", Query::none())?;
Ok(serde_json::from_value::<License>(res)?)
}
pub fn scan_library(&self) -> Result<()> {
self.get("startScan", Query::none())?;
Ok(())
}
pub fn scan_status(&self) -> Result<(bool, u64)> {
let res = self.get("getScanStatus", Query::none())?;
#[derive(Deserialize)]
struct ScanStatus {
count: u64,
scanning: bool,
}
let sc = serde_json::from_value::<ScanStatus>(res)?;
Ok((sc.scanning, sc.count))
}
pub fn music_folders(&self) -> Result<Vec<MusicFolder>> {
#[allow(non_snake_case)]
let musicFolder = self.get("getMusicFolders", Query::none())?;
Ok(get_list_as!(musicFolder, MusicFolder))
}
pub fn genres(&self) -> Result<Vec<Genre>> {
let genre = self.get("getGenres", Query::none())?;
Ok(get_list_as!(genre, Genre))
}
pub fn now_playing(&self) -> Result<Vec<NowPlaying>> {
let entry = self.get("getNowPlaying", Query::none())?;
Ok(get_list_as!(entry, NowPlaying))
}
pub fn lyrics<'a, S>(&self, artist: S, title: S) -> Result<Option<Lyrics>>
where
S: Into<Option<&'a str>>,
{
let args = Query::with("artist", artist.into())
.arg("title", title.into())
.build();
let res = self.get("getLyrics", args)?;
if res.get("value").is_some() {
Ok(Some(serde_json::from_value(res)?))
} else {
Ok(None)
}
}
pub fn search(
&self,
query: &str,
artist_page: SearchPage,
album_page: SearchPage,
song_page: SearchPage,
) -> Result<SearchResult> {
let args = Query::with("query", query)
.arg("artistCount", artist_page.count)
.arg("artistOffset", artist_page.offset)
.arg("albumCount", album_page.count)
.arg("albumOffset", album_page.offset)
.arg("songCount", song_page.count)
.arg("songOffset", song_page.offset)
.build();
let res = self.get("search3", args)?;
Ok(serde_json::from_value::<SearchResult>(res)?)
}
pub fn starred<U>(&self, folder_id: U) -> Result<SearchResult>
where
U: Into<Option<usize>>,
{
let res = self.get("getStarred", Query::with("musicFolderId", folder_id.into()))?;
Ok(serde_json::from_value::<SearchResult>(res)?)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct License {
pub valid: bool,
pub email: String,
pub trial_expires: Option<String>,
pub license_expires: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use test_util;
#[test]
fn test_token_auth() {
let cli = test_util::demo_site().unwrap();
let token_addr = cli.build_url("ping", Query::none()).unwrap();
let legacy_cli = cli.with_target("1.8.0".into());
let legacy_addr = legacy_cli.build_url("ping", Query::none()).unwrap();
assert!(token_addr != legacy_addr);
assert_eq!(
legacy_addr,
"http://demo.subsonic.org/rest/ping?u=guest3&p=guest&v=1.8.0&c=sunk&f=json&"
);
}
#[test]
fn demo_ping() {
let cli = test_util::demo_site().unwrap();
cli.ping().unwrap();
}
#[test]
fn demo_license() {
let cli = test_util::demo_site().unwrap();
let license = cli.check_license().unwrap();
assert!(license.valid);
assert_eq!(license.email, String::from("demo@subsonic.org"));
}
#[test]
fn demo_scan_status() {
let cli = test_util::demo_site().unwrap();
let (status, n) = cli.scan_status().unwrap();
assert_eq!(status, false);
assert_eq!(n, 521);
}
#[test]
fn demo_search() {
let cli = test_util::demo_site().unwrap();
let s = SearchPage::new().with_size(1);
let r = cli.search("dada", s, s, s).unwrap();
assert_eq!(r.artists[0].id, 14);
assert_eq!(r.artists[0].name, String::from("The Dada Weatherman"));
assert_eq!(r.artists[0].album_count, 4);
assert_eq!(r.albums[0].id, 23);
assert_eq!(r.albums[0].name, String::from("The Green Waltz"));
assert_eq!(r.songs[0].id, 222);
}
}