Skip to main content

qobuz_api/api/content/
albums.rs

1//! Album search and browse operations.
2
3use {
4    serde::{Deserialize, Serialize},
5    serde_json::Value,
6};
7
8use crate::{
9    api::{
10        content::{
11            artists::Artist,
12            catalog::{AlbumSearchResponse, ItemSearchResult},
13            get_by_id, search,
14        },
15        service::QobuzApiService,
16    },
17    errors::QobuzApiError,
18};
19
20/// A music album with full metadata.
21#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
22pub struct Album {
23    /// Unique album identifier.
24    pub id: Option<String>,
25    /// Album title.
26    pub title: Option<String>,
27    /// Version/subtitle.
28    pub version: Option<String>,
29    /// Universal Product Code.
30    pub upc: Option<String>,
31    /// Qobuz web URL.
32    pub url: Option<String>,
33    /// Primary artist.
34    pub artist: Option<Box<Artist>>,
35    /// All artists.
36    pub artists: Option<Vec<Box<Artist>>>,
37    /// Primary composer.
38    pub composer: Option<Box<Artist>>,
39    /// Record label.
40    pub label: Option<Label>,
41    /// Primary genre.
42    pub genre: Option<Genre>,
43    /// All genres.
44    pub genres: Option<Vec<Genre>>,
45    /// Cover art URLs.
46    pub image: Option<Image>,
47    /// Total duration in seconds.
48    pub duration: Option<i32>,
49    /// Number of tracks.
50    pub tracks_count: Option<i32>,
51    /// Number of discs.
52    pub media_count: Option<i32>,
53    /// Original release date.
54    pub release_date_original: Option<String>,
55    /// Streaming availability date.
56    pub release_date_stream: Option<String>,
57    /// Download availability date.
58    pub release_date_download: Option<String>,
59    /// Product type identifier.
60    pub product_type: Option<String>,
61    /// Release type (album, single, etc.).
62    pub release_type: Option<String>,
63    /// Hi-Res audio available.
64    pub hires: Option<bool>,
65    /// Hi-Res streaming available.
66    pub hires_streamable: Option<bool>,
67    /// Download available.
68    pub downloadable: Option<bool>,
69    /// Streaming available.
70    pub streamable: Option<bool>,
71    /// List of track IDs (when requested with extra).
72    pub track_ids: Option<Vec<i32>>,
73    /// Product URL for commercial information.
74    pub product_url: Option<String>,
75    /// Unix timestamp of when the album was released.
76    pub released_at: Option<i64>,
77    /// Copyright notice.
78    pub copyright: Option<String>,
79    /// Sales metadata.
80    pub product_sales_factors: Option<Value>,
81    /// Maximum available bit depth.
82    pub maximum_bit_depth: Option<i32>,
83    /// Maximum available sample rate.
84    pub maximum_sampling_rate: Option<f64>,
85    /// Maximum channel count.
86    pub maximum_channel_count: Option<i32>,
87}
88
89impl Album {
90    /// Returns the file extension for the given quality format ID.
91    ///
92    /// # Arguments
93    ///
94    /// * `format_id` - Quality format ID (5=MP3, everything else=FLAC)
95    ///
96    /// # Returns
97    ///
98    /// The file extension as a static string slice (`"mp3"` or `"flac"`).
99    #[must_use]
100    pub const fn extension_for_format(format_id: i32) -> &'static str {
101        match format_id {
102            5 => "mp3",
103            _ => "flac",
104        }
105    }
106}
107
108/// Music genre.
109#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
110pub struct Genre {
111    /// Genre ID.
112    pub id: Option<i32>,
113    /// Genre name.
114    pub name: Option<String>,
115    /// URL-friendly name.
116    pub slug: Option<String>,
117    /// Display color.
118    pub color: Option<String>,
119}
120
121/// Cover art URLs in multiple sizes.
122#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
123pub struct Image {
124    /// Small thumbnail URL.
125    pub small: Option<String>,
126    /// Thumbnail URL.
127    pub thumbnail: Option<String>,
128    /// Medium size URL.
129    pub medium: Option<String>,
130    /// Large size URL.
131    pub large: Option<String>,
132    /// Extra-large URL.
133    #[serde(rename = "extralarge")]
134    pub extra_large: Option<String>,
135    /// Highest resolution URL.
136    pub mega: Option<String>,
137    /// Back cover URL.
138    pub back: Option<String>,
139    /// Generic/poster URL (returned by some endpoints instead of the size-specific map).
140    pub url: Option<String>,
141}
142
143/// Record label.
144#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
145pub struct Label {
146    /// Label ID.
147    pub id: Option<i32>,
148    /// Label name.
149    pub name: Option<String>,
150    /// URL-friendly name.
151    pub slug: Option<String>,
152}
153
154/// Searches for albums matching the query.
155///
156/// # Arguments
157///
158/// * `service` - Authenticated API service
159/// * `query` - Search query string
160/// * `limit` - Maximum number of results to return
161/// * `offset` - Pagination offset
162///
163/// # Returns
164///
165/// A paginated `ItemSearchResult` containing matching albums.
166///
167/// # Errors
168///
169/// Returns a `QobuzApiError` if not authenticated or the API request fails.
170pub async fn search_albums(
171    service: &QobuzApiService,
172    query: &str,
173    limit: Option<i32>,
174    offset: Option<i32>,
175) -> Result<ItemSearchResult<Box<Album>>, QobuzApiError> {
176    let resp: AlbumSearchResponse = search(service, "/album/search", query, limit, offset).await?;
177    Ok(resp.albums)
178}
179
180/// Retrieves album details by ID.
181///
182/// # Arguments
183///
184/// * `service` - Authenticated API service
185/// * `album_id` - Album identifier
186/// * `extra` - Optional extra fields to include (e.g., `"track_ids"`)
187///
188/// # Returns
189///
190/// The album details.
191///
192/// # Errors
193///
194/// Returns a `QobuzApiError` if not authenticated or the API request fails.
195pub async fn get_album(
196    service: &QobuzApiService,
197    album_id: &str,
198    extra: Option<&str>,
199) -> Result<Album, QobuzApiError> {
200    get_by_id(service, "/album/get", "album_id", album_id, extra).await
201}
202
203#[cfg(test)]
204mod tests {
205    use {
206        anyhow::{Result, anyhow, ensure},
207        tokio::runtime::Runtime,
208    };
209
210    use crate::{
211        api::{
212            content::albums::{Album, get_album, search_albums},
213            fixture::{MockServer, make_service},
214        },
215        assert_empty_search_test,
216    };
217
218    /// Tests file extension mapping for quality IDs.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if the assertion fails.
223    #[test]
224    fn extension_for_format_maps_types() -> Result<()> {
225        ensure!(
226            Album::extension_for_format(5) == "mp3",
227            "MP3 should map to mp3"
228        );
229        ensure!(
230            Album::extension_for_format(6) == "flac",
231            "FLAC should map to flac"
232        );
233        ensure!(
234            Album::extension_for_format(27) == "flac",
235            "hi-res should map to flac"
236        );
237        Ok(())
238    }
239
240    /// Tests search albums deserializes results.
241    ///
242    /// # Errors
243    ///
244    /// Returns an error if the test setup or assertion fails.
245    #[test]
246    fn search_albums_deserializes_results() -> Result<()> {
247        let body = r#"{"albums":{"items":[{"id":"123","title":"Test Album"}],"total":1}}"#;
248        let server = MockServer::start(200, body)?;
249        let service = make_service(&server.base_url())?;
250        let rt = Runtime::new()?;
251        let result = rt.block_on(search_albums(&service, "Test", Some(5), None))?;
252        let items = result.items.ok_or_else(|| anyhow!("no items"))?;
253        ensure!(items.len() == 1);
254        ensure!(items.first().and_then(|i| i.title.as_deref()) == Some("Test Album"));
255        Ok(())
256    }
257
258    /// Tests search albums empty results.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the test setup or assertion fails.
263    #[test]
264    fn search_albums_empty_results() -> Result<()> {
265        assert_empty_search_test!(
266            search_albums,
267            "Nonexistent",
268            r#"{"albums":{"items":[],"total":0}}"#
269        );
270        Ok(())
271    }
272
273    /// Tests search albums error response.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the test setup or assertion fails.
278    #[test]
279    fn search_albums_error_response() -> Result<()> {
280        let body = r#"{"status":"error","code":400,"message":"Bad request"}"#;
281        let server = MockServer::start(400, body)?;
282        let service = make_service(&server.base_url())?;
283        let rt = Runtime::new()?;
284        let result = rt.block_on(search_albums(&service, "Test", None, None));
285        ensure!(result.is_err());
286        Ok(())
287    }
288
289    /// Tests get album by id.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if the test setup or assertion fails.
294    #[test]
295    fn get_album_by_id() -> Result<()> {
296        let body = r#"{"id":"sr6843","title":"Kind of Blue","tracks_count":5}"#;
297        let server = MockServer::start(200, body)?;
298        let service = make_service(&server.base_url())?;
299        let rt = Runtime::new()?;
300        let album = rt.block_on(get_album(&service, "sr6843", None))?;
301        ensure!(album.title.as_deref() == Some("Kind of Blue"));
302        ensure!(album.tracks_count == Some(5));
303        Ok(())
304    }
305
306    /// Tests get album with extra param.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if the test setup or assertion fails.
311    #[test]
312    fn get_album_with_extra_param() -> Result<()> {
313        let body = r#"{"id":"sr6843","title":"Kind of Blue","track_ids":[1,2,3]}"#;
314        let server = MockServer::start(200, body)?;
315        let service = make_service(&server.base_url())?;
316        let rt = Runtime::new()?;
317        let album = rt.block_on(get_album(&service, "sr6843", Some("track_ids")))?;
318        let ids = album.track_ids.ok_or_else(|| anyhow!("no track_ids"))?;
319        ensure!(ids == vec![1, 2, 3]);
320        Ok(())
321    }
322
323    /// Tests get album not found.
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if the test setup or assertion fails.
328    #[test]
329    fn get_album_not_found() -> Result<()> {
330        let body = r#"{"status":"error","code":404,"message":"Album not found"}"#;
331        let server = MockServer::start(404, body)?;
332        let service = make_service(&server.base_url())?;
333        let rt = Runtime::new()?;
334        let result = rt.block_on(get_album(&service, "nonexistent", None));
335        ensure!(result.is_err());
336        Ok(())
337    }
338}