qobuz_api_rust/api/content/
albums.rs

1use std::fs::create_dir_all;
2
3use crate::{
4    api::service::QobuzApiService,
5    errors::QobuzApiError::{self, ApiErrorResponse, IoError},
6    metadata::MetadataConfig,
7    models::{Album, SearchResult},
8    utils::sanitize_filename,
9};
10
11impl QobuzApiService {
12    /// Retrieves an album with the specified ID.
13    ///
14    /// This method fetches detailed information about a specific album from the Qobuz API,
15    /// including metadata, track listing, and other album-related information.
16    ///
17    /// # Arguments
18    ///
19    /// * `album_id` - The unique identifier of the album to retrieve
20    /// * `with_auth` - Optional boolean to execute request with or without user authentication token.
21    ///   When `None`, defaults to `false` (no authentication).
22    /// * `extra` - Optional string specifying additional album information to include in the response,
23    ///   such as "items", "tracks", "release_tags", etc.
24    /// * `limit` - Optional integer specifying the maximum number of tracks to include in the response.
25    ///   When `None`, defaults to 1200.
26    /// * `offset` - Optional integer specifying the offset of the first track to include in the response.
27    ///   When `None`, defaults to 0.
28    ///
29    /// # Returns
30    ///
31    /// * `Ok(Album)` - Contains the complete album information if the request is successful
32    /// * `Err(QobuzApiError)` - If the API request fails due to network issues, invalid parameters,
33    ///   or other API-related errors
34    ///
35    /// # Example
36    ///
37    /// ```
38    /// # use qobuz_api_rust::QobuzApiService;
39    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
40    /// let service = QobuzApiService::new().await?;
41    /// let album = service.get_album("12345", None, Some("tracks"), Some(10), None).await?;
42    /// println!("Album title: {}", album.title.unwrap_or_default());
43    /// # Ok(())
44    /// # }
45    /// ```
46    pub async fn get_album(
47        &self,
48        album_id: &str,
49        with_auth: Option<bool>,
50        extra: Option<&str>,
51        limit: Option<i32>,
52        offset: Option<i32>,
53    ) -> Result<Album, QobuzApiError> {
54        let mut params = vec![("album_id".to_string(), album_id.to_string())];
55
56        if let Some(extra_val) = extra {
57            params.push(("extra".to_string(), extra_val.to_string()));
58        }
59
60        params.push(("limit".to_string(), limit.unwrap_or(1200).to_string()));
61        params.push(("offset".to_string(), offset.unwrap_or(0).to_string()));
62
63        let _use_auth = with_auth.unwrap_or(false);
64
65        self.get("/album/get", &params).await
66    }
67
68    /// Searches for albums using the specified query.
69    ///
70    /// This method allows searching for albums based on a text query, with optional pagination
71    /// parameters to control the number of results returned.
72    ///
73    /// # Arguments
74    ///
75    /// * `query` - The search query string (e.g., album title, artist name)
76    /// * `limit` - Optional integer specifying the maximum number of results to return.
77    ///   When `None`, defaults to 50.
78    /// * `offset` - Optional integer specifying the offset of the first result to return.
79    ///   When `None`, defaults to 0.
80    /// * `with_auth` - Optional boolean to execute search with or without user authentication token.
81    ///   When `None`, defaults to `false` (no authentication).
82    ///
83    /// # Returns
84    ///
85    /// * `Ok(SearchResult)` - Contains the search results with albums matching the query
86    /// * `Err(QobuzApiError)` - If the API request fails due to network issues, invalid parameters,
87    ///   or other API-related errors
88    ///
89    /// # Example
90    ///
91    /// ```
92    /// # use qobuz_api_rust::QobuzApiService;
93    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
94    /// let service = QobuzApiService::new().await?;
95    /// let results = service.search_albums("radiohead", Some(10), None, None).await?;
96    /// if let Some(albums) = results.albums {
97    ///     println!("Found {} albums", albums.total.unwrap_or(0));
98    /// }
99    /// # Ok(())
100    /// # }
101    /// ```
102    pub async fn search_albums(
103        &self,
104        query: &str,
105        limit: Option<i32>,
106        offset: Option<i32>,
107        with_auth: Option<bool>,
108    ) -> Result<SearchResult, QobuzApiError> {
109        let params = vec![
110            ("query".to_string(), query.to_string()),
111            ("limit".to_string(), limit.unwrap_or(50).to_string()),
112            ("offset".to_string(), offset.unwrap_or(0).to_string()),
113        ];
114
115        let _use_auth = with_auth.unwrap_or(false);
116
117        self.get("/album/search", &params).await
118    }
119
120    /// Downloads an entire album to the specified path.
121    ///
122    /// This method downloads all tracks of an album to a specified directory, with options for
123    /// different audio quality formats. The method handles track-by-track downloads and includes
124    /// automatic credential refresh if signature errors occur during the download process.
125    ///
126    /// # Arguments
127    ///
128    /// * `album_id` - The unique identifier of the album to download
129    /// * `format_id` - The format ID specifying audio quality:
130    ///   - "5": MP3 320 kbps
131    ///   - "6": FLAC Lossless (16-bit/44.1kHz)
132    ///   - "7": FLAC Hi-Res (24-bit/96kHz)
133    ///   - "27": FLAC Hi-Res (24-bit/192kHz)
134    /// * `path` - The directory path where the album should be saved. The directory will be created
135    ///   if it doesn't exist. The path should already include artist/album folder structure.
136    ///
137    /// # Returns
138    ///
139    /// * `Ok(())` - If all tracks in the album are downloaded successfully
140    /// * `Err(QobuzApiError)` - If the API request fails, download fails for any track, or other
141    ///   errors occur during the process
142    ///
143    /// # Note
144    ///
145    /// This method includes automatic retry with credential refresh if signature errors occur.
146    /// Each track is downloaded with progress reporting and metadata embedding.
147    ///
148    /// # Example
149    ///
150    /// ```
151    /// # use qobuz_api_rust::QobuzApiService;
152    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
153    /// let service = QobuzApiService::new().await?;
154    /// service.download_album("12345", "6", "./downloads/Artist/Album Title").await?;
155    /// println!("Album downloaded successfully!");
156    /// # Ok(())
157    /// # }
158    /// ```
159    pub async fn download_album(
160        &self,
161        album_id: &str,
162        format_id: &str,
163        path: &str,
164        config: &MetadataConfig,
165    ) -> Result<(), QobuzApiError> {
166        let album = self
167            .get_album(album_id, None, Some("track_ids"), None, None)
168            .await?;
169
170        if let Some(track_ids) = album.track_ids {
171            let total_tracks = track_ids.len();
172            println!();
173            println!("Album contains {} tracks", total_tracks);
174            println!();
175
176            // Create the directory structure as provided in path parameter
177            let album_dir = path;
178            create_dir_all(album_dir).map_err(IoError)?;
179
180            for (index, track_id) in track_ids.iter().enumerate() {
181                let track = self.get_track(&track_id.to_string(), None).await?;
182                let file_extension = match format_id {
183                    "5" => "mp3",
184                    "6" | "7" | "27" => "flac",
185                    _ => "flac", // default to flac
186                };
187
188                // Get track number and title for the filename
189                let track_number = track.track_number.unwrap_or(0);
190                let track_title = track
191                    .title
192                    .as_ref()
193                    .unwrap_or(&format!("Track {}", track_id))
194                    .clone();
195
196                // Create filename following MusicBrainz Picard style: [Titelnr.]. [Titel]
197                let track_filename = format!("{:02}. {}", track_number, track_title);
198                let sanitized_filename = sanitize_filename(&track_filename);
199                let track_path = format!("{}/{}.{}", album_dir, sanitized_filename, file_extension);
200
201                println!(
202                    "Downloading track {}/{}: {} - {}",
203                    index + 1,
204                    total_tracks,
205                    track_number,
206                    track_title
207                );
208
209                // Attempt to download the track, with credential refresh on signature errors
210                match self
211                    .download_track(&track_id.to_string(), format_id, &track_path, config)
212                    .await
213                {
214                    Ok(()) => {
215                        // Success, continue to next track
216                    }
217
218                    Err(ApiErrorResponse { message, .. })
219                        if message.contains("Invalid Request Signature parameter") =>
220                    {
221                        eprintln!(
222                            "Invalid signature detected during album download, attempting to refresh app credentials..."
223                        );
224
225                        // Refresh credentials and retry the track download
226                        match self.refresh_app_credentials().await {
227                            Ok(new_service) => {
228                                // Use the new service instance to download the track
229                                match new_service
230                                    .download_track(
231                                        &track_id.to_string(),
232                                        format_id,
233                                        &track_path,
234                                        config,
235                                    )
236                                    .await
237                                {
238                                    Ok(()) => {
239                                        // Successfully downloaded with new credentials
240                                    }
241
242                                    Err(e) => {
243                                        // If it still fails, return the error
244                                        return Err(e);
245                                    }
246                                }
247                            }
248
249                            Err(e) => {
250                                eprintln!("Failed to refresh credentials: {}", e);
251                                return Err(ApiErrorResponse {
252                                    code: 400.to_string(),
253                                    message,
254                                    status: "error".to_string(),
255                                });
256                            }
257                        }
258                    }
259
260                    Err(e) => {
261                        // Any other error, return immediately
262                        return Err(e);
263                    }
264                }
265            }
266
267            println!();
268            println!(
269                "Album download completed: {}/{} tracks downloaded",
270                total_tracks, total_tracks
271            );
272            println!();
273        }
274
275        Ok(())
276    }
277}