qobuz-api 2.0.0

A Rust client library for the Qobuz music streaming API
Documentation
//! Artist search and browse operations.

use serde::{Deserialize, Serialize};

use crate::{
    api::{
        content::{
            albums::{Album, Image},
            catalog::{ArtistSearchResponse, ItemSearchResult},
            get_by_id, paginated, search,
        },
        response::{deserialize_flexible_name, deserialize_picture},
        service::QobuzApiService,
    },
    errors::QobuzApiError,
};

/// A music artist.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct Artist {
    /// Unique artist identifier.
    pub id: Option<i32>,
    /// Artist name (may be a plain string or `{"display":"Name"}` from the API).
    #[serde(default, deserialize_with = "deserialize_flexible_name")]
    pub name: Option<String>,
    /// URL-friendly name.
    pub slug: Option<String>,
    /// Artist picture (may be a string URL or an Image object from the API).
    #[serde(default, deserialize_with = "deserialize_picture")]
    pub picture: Option<Image>,
    /// Artist image (alternate field).
    pub image: Option<Image>,
    /// Biography text.
    pub biography: Option<Biography>,
    /// Number of albums.
    pub albums_count: Option<i32>,
    /// Artist roles (main-artist, composer, etc.).
    pub roles: Option<Vec<String>>,
    /// Associated albums.
    pub albums: Option<ItemSearchResult<Box<Album>>>,
}

/// Artist biography text.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct Biography {
    /// Biography text content.
    pub text: Option<String>,
    /// Summary text.
    pub summary: Option<String>,
}

/// Searches for artists matching the query.
///
/// # Arguments
///
/// * `service` - Authenticated API service
/// * `query` - Search query string
/// * `limit` - Maximum number of results to return
/// * `offset` - Pagination offset
///
/// # Returns
///
/// A paginated `ItemSearchResult` containing matching artists.
///
/// # Errors
///
/// Returns a `QobuzApiError` if not authenticated or the API request fails.
pub async fn search_artists(
    service: &QobuzApiService,
    query: &str,
    limit: Option<i32>,
    offset: Option<i32>,
) -> Result<ItemSearchResult<Box<Artist>>, QobuzApiError> {
    let resp: ArtistSearchResponse =
        search(service, "/artist/search", query, limit, offset).await?;
    Ok(resp.artists)
}

/// Retrieves artist details by ID.
///
/// # Arguments
///
/// * `service` - Authenticated API service
/// * `artist_id` - Artist identifier
/// * `extra` - Optional extra fields to include
///
/// # Returns
///
/// The artist details.
///
/// # Errors
///
/// Returns a `QobuzApiError` if not authenticated or the API request fails.
pub async fn get_artist(
    service: &QobuzApiService,
    artist_id: i32,
    extra: Option<&str>,
) -> Result<Artist, QobuzApiError> {
    get_by_id(service, "/artist/get", "artist_id", artist_id, extra).await
}

/// Retrieves an artist's release list (discography).
///
/// # Arguments
///
/// * `service` - Authenticated API service
/// * `artist_id` - Artist identifier
/// * `limit` - Maximum number of releases to return
/// * `offset` - Pagination offset
///
/// # Returns
///
/// A paginated `ItemSearchResult` containing the artist's albums.
///
/// # Errors
///
/// Returns a `QobuzApiError` if not authenticated or the API request fails.
pub async fn get_release_list(
    service: &QobuzApiService,
    artist_id: i32,
    limit: Option<i32>,
    offset: Option<i32>,
) -> Result<ItemSearchResult<Box<Album>>, QobuzApiError> {
    paginated(
        service,
        "/artist/getReleasesList",
        "artist_id",
        artist_id,
        limit,
        offset,
    )
    .await
}

#[cfg(test)]
mod tests {
    use {
        anyhow::{Result, anyhow, ensure},
        tokio::runtime::Runtime,
    };

    use crate::{
        api::{
            content::artists::{get_artist, get_release_list, search_artists},
            fixture::{MockServer, make_service},
        },
        assert_empty_search_test,
    };

    /// Tests search artists deserializes results.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn search_artists_deserializes_results() -> Result<()> {
        let body = r#"{"artists":{"items":[{"id":1,"name":"Miles Davis"}],"total":1}}"#;
        let server = MockServer::start(200, body)?;
        let service = make_service(&server.base_url())?;
        let rt = Runtime::new()?;
        let result = rt.block_on(search_artists(&service, "Miles", Some(5), None))?;
        let items = result.items.ok_or_else(|| anyhow!("no items"))?;
        ensure!(items.len() == 1);
        ensure!(items.first().and_then(|i| i.name.as_deref()) == Some("Miles Davis"));
        Ok(())
    }

    /// Tests search artists empty results.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn search_artists_empty_results() -> Result<()> {
        assert_empty_search_test!(
            search_artists,
            "Nobody",
            r#"{"artists":{"items":[],"total":0}}"#
        );
        Ok(())
    }

    /// Tests get artist by id.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn get_artist_by_id() -> Result<()> {
        let body = r#"{"id":42,"name":"Coltrane"}"#;
        let server = MockServer::start(200, body)?;
        let service = make_service(&server.base_url())?;
        let rt = Runtime::new()?;
        let artist = rt.block_on(get_artist(&service, 42, None))?;
        ensure!(artist.name.as_deref() == Some("Coltrane"));
        Ok(())
    }

    /// Tests get artist error response.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn get_artist_error_response() -> Result<()> {
        let body = r#"{"status":"error","code":404,"message":"Not found"}"#;
        let server = MockServer::start(404, body)?;
        let service = make_service(&server.base_url())?;
        let rt = Runtime::new()?;
        let result = rt.block_on(get_artist(&service, 99999, None));
        ensure!(result.is_err());
        Ok(())
    }

    /// Tests get artist with extra.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn get_artist_with_extra() -> Result<()> {
        let body = r#"{"id":42,"name":"Coltrane","albums_count":10}"#;
        let server = MockServer::start(200, body)?;
        let service = make_service(&server.base_url())?;
        let rt = Runtime::new()?;
        let artist = rt.block_on(get_artist(&service, 42, Some("albums")))?;
        ensure!(artist.name.as_deref() == Some("Coltrane"));
        Ok(())
    }

    /// Tests get release list returns albums.
    ///
    /// # Errors
    ///
    /// Returns an error if the test setup or assertion fails.
    #[test]
    fn get_release_list_returns_albums() -> Result<()> {
        let body = r#"{"items":[{"id":"a1","title":"A Love Supreme"}],"total":1}"#;
        let server = MockServer::start(200, body)?;
        let service = make_service(&server.base_url())?;
        let rt = Runtime::new()?;
        let result = rt.block_on(get_release_list(&service, 42, Some(5), None))?;
        let items = result.items.ok_or_else(|| anyhow!("no items"))?;
        ensure!(items.len() == 1);
        ensure!(items.first().and_then(|i| i.title.as_deref()) == Some("A Love Supreme"));
        Ok(())
    }
}