use serde::Deserialize;
use url::Url;
use crate::auth::Auth;
use crate::error::{Error, SubsonicApiError};
const DEFAULT_API_VERSION: &str = "1.16.1";
const DEFAULT_CLIENT_NAME: &str = "opensubsonic-rs";
#[derive(Debug, Clone)]
pub struct Client {
base_url: Url,
auth: Auth,
client_name: String,
api_version: String,
pub(crate) http: reqwest::Client,
}
impl Client {
pub fn new(base_url: &str, auth: Auth) -> Result<Self, Error> {
let base_url = Url::parse(base_url)?;
Ok(Self {
base_url,
auth,
client_name: DEFAULT_CLIENT_NAME.to_owned(),
api_version: DEFAULT_API_VERSION.to_owned(),
http: reqwest::Client::new(),
})
}
#[must_use]
pub fn with_client_name(mut self, name: &str) -> Self {
self.client_name = name.to_owned();
self
}
#[must_use]
pub fn with_api_version(mut self, version: &str) -> Self {
self.api_version = version.to_owned();
self
}
#[must_use]
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = client;
self
}
pub fn with_danger_accept_invalid_certs(mut self) -> Result<Self, Error> {
self.http = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.build()?;
Ok(self)
}
}
impl Client {
pub(crate) fn build_url(&self, endpoint: &str, params: &[(&str, &str)]) -> Result<Url, Error> {
let mut url = self.base_url.clone();
{
let mut path = url.path().to_owned();
if !path.ends_with('/') {
path.push('/');
}
path.push_str("rest/");
path.push_str(endpoint);
url.set_path(&path);
}
{
let mut query = url.query_pairs_mut();
if let Some(username) = self.auth.username() {
query.append_pair("u", username);
}
for (k, v) in self.auth.params() {
query.append_pair(k, &v);
}
query.append_pair("v", &self.api_version);
query.append_pair("c", &self.client_name);
query.append_pair("f", "json");
for &(k, v) in params {
query.append_pair(k, v);
}
}
Ok(url)
}
pub(crate) async fn get_response(
&self,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<serde_json::Map<String, serde_json::Value>, Error> {
let url = self.build_url(endpoint, params)?;
log::debug!("GET {url}");
let resp = self.http.get(url).send().await?.error_for_status()?;
let text = resp.text().await?;
let wrapper: SubsonicResponseWrapper =
serde_json::from_str(&text).map_err(|e| Error::Parse(format!("{e}: {text}")))?;
let inner = wrapper.response;
if inner.status != "ok" {
let api_err = inner.error.map_or_else(
|| SubsonicApiError {
code: 0,
message: "Unknown API error (status != ok but no error object)".into(),
help_url: None,
},
|e| SubsonicApiError {
code: e.code,
message: e.message.unwrap_or_default(),
help_url: e.help_url,
},
);
return Err(Error::Api(api_err));
}
Ok(inner.data)
}
pub(crate) async fn get_bytes(
&self,
endpoint: &str,
params: &[(&str, &str)],
) -> Result<bytes::Bytes, Error> {
let url = self.build_url(endpoint, params)?;
log::debug!("GET (bytes) {url}");
let resp = self.http.get(url).send().await?.error_for_status()?;
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_lowercase();
if content_type.contains("application/json") || content_type.contains("text/json") {
let text = resp.text().await?;
let wrapper: SubsonicResponseWrapper =
serde_json::from_str(&text).map_err(|e| Error::Parse(format!("{e}: {text}")))?;
let inner = wrapper.response;
if inner.status != "ok" {
let api_err = inner.error.map_or_else(
|| SubsonicApiError {
code: 0,
message: "Unknown API error on binary endpoint".into(),
help_url: None,
},
|e| SubsonicApiError {
code: e.code,
message: e.message.unwrap_or_default(),
help_url: e.help_url,
},
);
return Err(Error::Api(api_err));
}
return Err(Error::Parse(
"Expected binary response but got JSON with status=ok".into(),
));
}
Ok(resp.bytes().await?)
}
}
#[derive(Deserialize)]
struct SubsonicResponseWrapper {
#[serde(rename = "subsonic-response")]
response: SubsonicResponseInner,
}
#[derive(Deserialize)]
struct SubsonicResponseInner {
status: String,
#[serde(default)]
#[allow(dead_code)]
version: Option<String>,
#[serde(rename = "type", default)]
#[allow(dead_code)]
server_type: Option<String>,
#[serde(rename = "serverVersion", default)]
#[allow(dead_code)]
server_version: Option<String>,
#[serde(rename = "openSubsonic", default)]
#[allow(dead_code)]
open_subsonic: Option<bool>,
error: Option<ApiErrorResponse>,
#[serde(flatten)]
data: serde_json::Map<String, serde_json::Value>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiErrorResponse {
code: i32,
message: Option<String>,
help_url: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::Auth;
#[test]
fn build_url_contains_required_params() {
let client =
Client::new("https://music.example.com", Auth::token("admin", "pass")).unwrap();
let url = client.build_url("ping", &[]).unwrap();
let query: String = url.query().unwrap().to_string();
assert_eq!(url.path(), "/rest/ping");
assert!(query.contains("u=admin"));
assert!(query.contains("v=1.16.1"));
assert!(query.contains("c=opensubsonic-rs"));
assert!(query.contains("f=json"));
assert!(query.contains("t="));
assert!(query.contains("s="));
}
#[test]
fn build_url_preserves_base_path() {
let client = Client::new(
"https://host.example.com/music",
Auth::token("admin", "pass"),
)
.unwrap();
let url = client.build_url("ping", &[]).unwrap();
assert_eq!(url.path(), "/music/rest/ping");
}
#[test]
fn build_url_preserves_base_path_with_trailing_slash() {
let client = Client::new(
"https://host.example.com/music/",
Auth::token("admin", "pass"),
)
.unwrap();
let url = client.build_url("getArtists", &[]).unwrap();
assert_eq!(url.path(), "/music/rest/getArtists");
}
#[test]
fn build_url_with_extra_params() {
let client =
Client::new("https://music.example.com", Auth::plain("admin", "pass")).unwrap();
let url = client.build_url("getAlbum", &[("id", "42")]).unwrap();
let query = url.query().unwrap().to_string();
assert!(query.contains("id=42"));
assert!(query.contains("p=enc%3A70617373") || query.contains("p=enc:70617373"));
}
#[test]
fn build_url_api_key_auth() {
let client =
Client::new("https://music.example.com", Auth::api_key("my-api-key-123")).unwrap();
let url = client.build_url("ping", &[]).unwrap();
let query = url.query().unwrap().to_string();
assert_eq!(url.path(), "/rest/ping");
assert!(!query.contains("u="));
assert!(query.contains("apiKey=my-api-key-123"));
assert!(query.contains("v=1.16.1"));
assert!(query.contains("c=opensubsonic-rs"));
assert!(query.contains("f=json"));
}
#[test]
fn builder_methods() {
let client = Client::new("https://example.com", Auth::token("u", "p"))
.unwrap()
.with_client_name("my-app")
.with_api_version("1.15.0");
assert_eq!(client.client_name, "my-app");
assert_eq!(client.api_version, "1.15.0");
}
#[test]
fn parse_ok_response() {
let json = r#"{
"subsonic-response": {
"status": "ok",
"version": "1.16.1",
"type": "navidrome",
"serverVersion": "0.49.3",
"openSubsonic": true
}
}"#;
let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
assert_eq!(wrapper.response.status, "ok");
assert_eq!(wrapper.response.version.as_deref(), Some("1.16.1"));
assert_eq!(wrapper.response.server_type.as_deref(), Some("navidrome"));
assert!(wrapper.response.error.is_none());
}
#[test]
fn parse_error_response() {
let json = r#"{
"subsonic-response": {
"status": "failed",
"version": "1.16.1",
"error": {
"code": 40,
"message": "Wrong username or password"
}
}
}"#;
let wrapper: SubsonicResponseWrapper = serde_json::from_str(json).unwrap();
assert_eq!(wrapper.response.status, "failed");
let err = wrapper.response.error.unwrap();
assert_eq!(err.code, 40);
assert_eq!(err.message.as_deref(), Some("Wrong username or password"));
}
}