use reqwest::Client;
use tracing::{debug, info};
use url::Url;
use super::auth::generate_auth_params;
use super::models::*;
use crate::error::SubsonicError;
const CLIENT_NAME: &str = "ferrosonic-rs";
const API_VERSION: &str = "1.16.1";
#[derive(Clone)]
pub struct SubsonicClient {
base_url: Url,
username: String,
password: String,
http: Client,
}
impl SubsonicClient {
pub fn new(base_url: &str, username: &str, password: &str) -> Result<Self, SubsonicError> {
let base_url = Url::parse(base_url)?;
let http = Client::builder()
.user_agent(CLIENT_NAME)
.build()
.map_err(SubsonicError::Http)?;
Ok(Self {
base_url,
username: username.to_string(),
password: password.to_string(),
http,
})
}
fn build_url(&self, endpoint: &str) -> Result<Url, SubsonicError> {
let mut url = self.base_url.join(&format!("rest/{}", endpoint))?;
let (salt, token) = generate_auth_params(&self.password);
url.query_pairs_mut()
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME)
.append_pair("f", "json");
Ok(url)
}
async fn request<T>(&self, endpoint: &str) -> Result<T, SubsonicError>
where
T: serde::de::DeserializeOwned,
{
let url = self.build_url(endpoint)?;
debug!(
"Requesting: {}",
url.as_str().split('?').next().unwrap_or("")
);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<T> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse response: {}", e)))?;
let inner = parsed.subsonic_response;
if inner.status != "ok" {
if let Some(error) = inner.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
return Err(SubsonicError::Api {
code: 0,
message: "Unknown error".to_string(),
});
}
inner
.data
.ok_or_else(|| SubsonicError::Parse("Empty response data".to_string()))
}
pub async fn ping(&self) -> Result<(), SubsonicError> {
let url = self.build_url("ping")?;
debug!("Pinging server");
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<PingData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse ping response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
info!("Server ping successful");
Ok(())
}
pub async fn get_starred_songs(&self) -> Result<Vec<Child>, SubsonicError> {
let data: StarredSongsData = self.request("getStarred2").await?;
let songs = data.starred_songs.song;
debug!("Fetched {} songs", songs.len());
Ok(songs)
}
pub async fn get_random_songs(
&self,
random_songs_count: usize,
) -> Result<Vec<Child>, SubsonicError> {
let data: RandomSongsData = self
.request(&format!("getRandomSongs?size={}", random_songs_count))
.await?;
let songs = data.random_songs.song;
debug!("Fetched {} songs", songs.len());
Ok(songs)
}
pub async fn get_artists(&self) -> Result<Vec<Artist>, SubsonicError> {
let data: ArtistsData = self.request("getArtists").await?;
let artists: Vec<Artist> = data
.artists
.index
.into_iter()
.flat_map(|idx| idx.artist)
.collect();
debug!("Fetched {} artists", artists.len());
Ok(artists)
}
pub async fn get_artist(&self, id: &str) -> Result<(Artist, Vec<Album>), SubsonicError> {
let url = self.build_url(&format!("getArtist?id={}", id))?;
debug!("Fetching artist: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<ArtistData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse artist response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty artist data".to_string()))?
.artist;
let artist = Artist {
id: detail.id,
name: detail.name.clone(),
album_count: Some(detail.album.len() as i32),
cover_art: None,
starred: None,
};
debug!(
"Fetched artist {} with {} albums",
detail.name,
detail.album.len()
);
Ok((artist, detail.album))
}
pub async fn get_album(&self, id: &str) -> Result<(Album, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("getAlbum?id={}", id))?;
debug!("Fetching album: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<AlbumData> = serde_json::from_str(&text)
.map_err(|e| SubsonicError::Parse(format!("Failed to parse album response: {}", e)))?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty album data".to_string()))?
.album;
let album = Album {
id: detail.id,
name: detail.name.clone(),
artist: detail.artist,
artist_id: detail.artist_id,
cover_art: None,
song_count: Some(detail.song.len() as i32),
duration: None,
year: detail.year,
genre: None,
starred: None,
};
debug!(
"Fetched album {} with {} songs",
detail.name,
detail.song.len()
);
Ok((album, detail.song))
}
pub async fn get_playlists(&self) -> Result<Vec<Playlist>, SubsonicError> {
let data: PlaylistsData = self.request("getPlaylists").await?;
let playlists = data.playlists.playlist;
debug!("Fetched {} playlists", playlists.len());
Ok(playlists)
}
pub async fn get_playlist(&self, id: &str) -> Result<(Playlist, Vec<Child>), SubsonicError> {
let url = self.build_url(&format!("getPlaylist?id={}", id))?;
debug!("Fetching playlist: {}", id);
let response = self.http.get(url).send().await?;
let text = response.text().await?;
let parsed: SubsonicResponse<PlaylistData> = serde_json::from_str(&text).map_err(|e| {
SubsonicError::Parse(format!("Failed to parse playlist response: {}", e))
})?;
if parsed.subsonic_response.status != "ok" {
if let Some(error) = parsed.subsonic_response.error {
return Err(SubsonicError::Api {
code: error.code,
message: error.message,
});
}
}
let detail = parsed
.subsonic_response
.data
.ok_or_else(|| SubsonicError::Parse("Empty playlist data".to_string()))?
.playlist;
let playlist = Playlist {
id: detail.id,
name: detail.name.clone(),
owner: detail.owner,
song_count: detail.song_count,
duration: detail.duration,
cover_art: None,
public: None,
comment: None,
};
debug!(
"Fetched playlist {} with {} songs",
detail.name,
detail.entry.len()
);
Ok((playlist, detail.entry))
}
pub fn get_stream_url(&self, song_id: &str) -> Result<String, SubsonicError> {
let mut url = self.base_url.join("rest/stream")?;
let (salt, token) = generate_auth_params(&self.password);
url.query_pairs_mut()
.append_pair("id", song_id)
.append_pair("u", &self.username)
.append_pair("t", &token)
.append_pair("s", &salt)
.append_pair("v", API_VERSION)
.append_pair("c", CLIENT_NAME);
Ok(url.to_string())
}
pub async fn unstar_song(&self, song_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("unstar?id={}", song_id)).await
}
pub async fn star_song(&self, song_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("star?id={}", song_id)).await
}
pub async fn unstar_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("unstar?artistId={}", artist_id))
.await
}
pub async fn star_artist(&self, artist_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("star?artistId={}", artist_id))
.await
}
pub async fn unstar_album(&self, album_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("unstar?albumId={}", album_id))
.await
}
pub async fn star_album(&self, album_id: &str) -> Result<(), SubsonicError> {
self.request::<()>(&format!("star?albumId={}", album_id))
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
impl SubsonicClient {
fn parse_song_id_from_url(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
parsed
.query_pairs()
.find(|(k, _)| k == "id")
.map(|(_, v)| v.to_string())
}
}
#[test]
fn test_parse_song_id() {
let url = "https://example.com/rest/stream?id=12345&u=user&t=token&s=salt&v=1.16.1&c=test";
let id = SubsonicClient::parse_song_id_from_url(url);
assert_eq!(id, Some("12345".to_string()));
}
#[test]
fn test_parse_song_id_missing() {
let url = "https://example.com/rest/stream?u=user";
let id = SubsonicClient::parse_song_id_from_url(url);
assert_eq!(id, None);
}
}