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