1use {
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#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
22pub struct Album {
23 pub id: Option<String>,
25 pub title: Option<String>,
27 pub version: Option<String>,
29 pub upc: Option<String>,
31 pub url: Option<String>,
33 pub artist: Option<Box<Artist>>,
35 pub artists: Option<Vec<Box<Artist>>>,
37 pub composer: Option<Box<Artist>>,
39 pub label: Option<Label>,
41 pub genre: Option<Genre>,
43 pub genres: Option<Vec<Genre>>,
45 pub image: Option<Image>,
47 pub duration: Option<i32>,
49 pub tracks_count: Option<i32>,
51 pub media_count: Option<i32>,
53 pub release_date_original: Option<String>,
55 pub release_date_stream: Option<String>,
57 pub release_date_download: Option<String>,
59 pub product_type: Option<String>,
61 pub release_type: Option<String>,
63 pub hires: Option<bool>,
65 pub hires_streamable: Option<bool>,
67 pub downloadable: Option<bool>,
69 pub streamable: Option<bool>,
71 pub track_ids: Option<Vec<i32>>,
73 pub product_url: Option<String>,
75 pub released_at: Option<i64>,
77 pub copyright: Option<String>,
79 pub product_sales_factors: Option<Value>,
81 pub maximum_bit_depth: Option<i32>,
83 pub maximum_sampling_rate: Option<f64>,
85 pub maximum_channel_count: Option<i32>,
87}
88
89impl Album {
90 #[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#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
110pub struct Genre {
111 pub id: Option<i32>,
113 pub name: Option<String>,
115 pub slug: Option<String>,
117 pub color: Option<String>,
119}
120
121#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
123pub struct Image {
124 pub small: Option<String>,
126 pub thumbnail: Option<String>,
128 pub medium: Option<String>,
130 pub large: Option<String>,
132 #[serde(rename = "extralarge")]
134 pub extra_large: Option<String>,
135 pub mega: Option<String>,
137 pub back: Option<String>,
139 pub url: Option<String>,
141}
142
143#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
145pub struct Label {
146 pub id: Option<i32>,
148 pub name: Option<String>,
150 pub slug: Option<String>,
152}
153
154pub 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
180pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}