qobuz_api_rust/api/content/tracks.rs
1use std::{
2 fs::{File, create_dir_all},
3 io::{BufWriter, Write, stdout},
4 path::Path,
5};
6
7use {reqwest::header::CONTENT_LENGTH, tokio_stream::StreamExt};
8
9use crate::{
10 api::service::QobuzApiService,
11 errors::QobuzApiError::{
12 self, ApiErrorResponse, DownloadError, HttpError, MetadataError, ResourceNotFoundError,
13 },
14 metadata::{MetadataConfig, embedder::embed_metadata_in_file},
15 models::{FileUrl, SearchResult, Track},
16 utils::{get_current_timestamp, get_md5_hash},
17};
18
19impl QobuzApiService {
20 /// Generates the signature for the getFileUrl endpoint.
21 ///
22 /// This internal function creates a signature required to access track file URLs from the Qobuz API.
23 /// The signature is created by concatenating specific parameters with the app secret and hashing
24 /// the result using MD5.
25 ///
26 /// # Arguments
27 /// * `format_id` - The format ID for the desired audio quality
28 /// * `track_id` - The unique identifier of the track
29 /// * `timestamp` - The current timestamp to ensure request freshness
30 ///
31 /// # Returns
32 /// A hexadecimal string representing the MD5 hash of the signature data
33 fn generate_get_file_url_signature(
34 &self,
35 format_id: &str,
36 track_id: &str,
37 timestamp: &str,
38 ) -> String {
39 let data_to_sign = format!(
40 "trackgetFileUrlformat_id{}intentstreamtrack_id{}{}{}",
41 format_id, track_id, timestamp, self.app_secret
42 );
43
44 get_md5_hash(&data_to_sign)
45 }
46
47 /// Retrieves detailed information about a specific track by its ID.
48 ///
49 /// This function fetches comprehensive metadata about a track including its title,
50 /// duration, album information, performer details, and audio specifications.
51 /// The request can optionally be made with authentication for access to additional
52 /// content or features.
53 ///
54 /// # Arguments
55 /// * `track_id` - The unique identifier of the track to retrieve
56 /// * `with_auth` - Whether to execute the request with the user authentication token
57 /// (optional, defaults to false). When `true`, the request includes
58 /// the user's authentication token if available.
59 ///
60 /// # Returns
61 /// * `Ok(Track)` - The complete track information if the request succeeds
62 /// * `Err(QobuzApiError)` - If the API request fails due to network issues,
63 /// invalid parameters, or API errors
64 ///
65 /// # Example
66 /// ```no_run
67 /// # use qobuz_api_rust::{QobuzApiService, QobuzApiError};
68 /// # async fn example() -> Result<(), QobuzApiError> {
69 /// let service = QobuzApiService::new().await?;
70 /// let track = service.get_track("12345", None).await?;
71 /// println!("Track title: {:?}", track.title);
72 /// # Ok(())
73 /// # }
74 /// ```
75 pub async fn get_track(
76 &self,
77 track_id: &str,
78 with_auth: Option<bool>,
79 ) -> Result<Track, QobuzApiError> {
80 let params = vec![("track_id".to_string(), track_id.to_string())];
81
82 let _use_auth = with_auth.unwrap_or(false);
83
84 self.get("/track/get", ¶ms).await
85 }
86
87 /// Retrieves the download URL for a track in a specific audio format.
88 ///
89 /// This function obtains a direct URL to download the audio file for a track in the specified format.
90 /// The Qobuz API requires a signature for this endpoint, which is automatically generated and
91 /// validated. If the signature is invalid (which may happen with expired app credentials),
92 /// the function will attempt to refresh the credentials and retry the request.
93 ///
94 /// # Arguments
95 /// * `track_id` - The unique identifier of the track
96 /// * `format_id` - The format ID specifying the audio quality:
97 /// - `5` for MP3 320 kbps
98 /// - `6` for FLAC Lossless (16-bit/44.1kHz)
99 /// - `7` for FLAC Hi-Res (24-bit, ≤96kHz)
100 /// - `27` for FLAC Hi-Res (24-bit, >96kHz & ≤192kHz)
101 ///
102 /// # Returns
103 /// * `Ok(FileUrl)` - Contains the download URL and metadata about the audio file if successful
104 /// * `Err(QobuzApiError)` - If the API request fails, credentials are invalid, or the track/format is unavailable
105 ///
106 /// # Note
107 /// This endpoint requires authentication and may automatically refresh app credentials if needed.
108 ///
109 /// # Example
110 /// ```no_run
111 /// # use qobuz_api_rust::{QobuzApiService, QobuzApiError};
112 /// # async fn example() -> Result<(), QobuzApiError> {
113 /// let service = QobuzApiService::new().await?;
114 /// let file_url = service.get_track_file_url("12345", "6").await?;
115 /// if let Some(url) = file_url.url {
116 /// println!("Download URL: {}", url);
117 /// }
118 /// # Ok(())
119 /// # }
120 /// ```
121 pub async fn get_track_file_url(
122 &self,
123 track_id: &str,
124 format_id: &str,
125 ) -> Result<FileUrl, QobuzApiError> {
126 let timestamp = get_current_timestamp();
127 let signature = self.generate_get_file_url_signature(format_id, track_id, ×tamp);
128
129 let params = vec![
130 ("track_id".to_string(), track_id.to_string()),
131 ("format_id".to_string(), format_id.to_string()),
132 ("intent".to_string(), "stream".to_string()),
133 ("request_ts".to_string(), timestamp),
134 ("request_sig".to_string(), signature),
135 ];
136
137 // This endpoint requires authentication
138 match self.get("/track/getFileUrl", ¶ms).await {
139 Ok(result) => Ok(result),
140 Err(ApiErrorResponse {
141 code,
142 message,
143 status,
144 }) => {
145 // Check if this is the signature error that indicates invalid app credentials
146 if message.contains("Invalid Request Signature parameter") {
147 eprintln!(
148 "Invalid signature detected, attempting to refresh app credentials..."
149 );
150
151 // Fetch new credentials
152 match self.refresh_app_credentials().await {
153 Ok(new_service) => {
154 // Retry the request with new credentials
155 let new_timestamp = get_current_timestamp();
156 let new_signature = new_service.generate_get_file_url_signature(
157 format_id,
158 track_id,
159 &new_timestamp,
160 );
161
162 let new_params = vec![
163 ("track_id".to_string(), track_id.to_string()),
164 ("format_id".to_string(), format_id.to_string()),
165 ("intent".to_string(), "stream".to_string()),
166 ("request_ts".to_string(), new_timestamp),
167 ("request_sig".to_string(), new_signature),
168 ];
169
170 new_service.get("/track/getFileUrl", &new_params).await
171 }
172
173 Err(e) => {
174 eprintln!("Failed to refresh credentials: {}", e);
175 Err(ApiErrorResponse {
176 code,
177 message,
178 status,
179 })
180 }
181 }
182 } else {
183 // Return the original error if it's not a signature error
184 Err(ApiErrorResponse {
185 code,
186 message,
187 status,
188 })
189 }
190 }
191
192 Err(e) => Err(e),
193 }
194 }
195
196 /// Searches for tracks based on a text query with optional pagination and authentication.
197 ///
198 /// This function performs a text-based search across Qobuz's track catalog, allowing users
199 /// to find tracks by title, artist, album, or other metadata. The search can be customized
200 /// with pagination parameters and optional authentication for enhanced results.
201 ///
202 /// # Arguments
203 /// * `query` - The search term to look for in track metadata (title, artist, etc.)
204 /// * `limit` - The maximum number of results to return (optional, defaults to 50, maximum 500)
205 /// * `offset` - The offset of the first result to return (optional, defaults to 0)
206 /// Use this for pagination to retrieve subsequent result sets
207 /// * `with_auth` - Whether to execute the search with the user authentication token
208 /// (optional, defaults to false). When `true`, the request includes
209 /// the user's authentication token if available, potentially returning
210 /// personalized or higher-quality results.
211 ///
212 /// # Returns
213 /// * `Ok(SearchResult)` - Contains the search results with track information and metadata
214 /// * `Err(QobuzApiError)` - If the API request fails due to network issues, invalid
215 /// parameters, or API errors
216 ///
217 /// # Example
218 /// ```no_run
219 /// # use qobuz_api_rust::{QobuzApiService, QobuzApiError};
220 /// # async fn example() -> Result<(), QobuzApiError> {
221 /// let service = QobuzApiService::new().await?;
222 /// let results = service.search_tracks("Bohemian Rhapsody", Some(10), None, None).await?;
223 /// if let Some(tracks) = results.tracks {
224 /// println!("Found {} tracks", tracks.total.unwrap_or(0));
225 /// }
226 /// # Ok(())
227 /// # }
228 /// ```
229 pub async fn search_tracks(
230 &self,
231 query: &str,
232 limit: Option<i32>,
233 offset: Option<i32>,
234 with_auth: Option<bool>,
235 ) -> Result<SearchResult, QobuzApiError> {
236 let params = vec![
237 ("query".to_string(), query.to_string()),
238 ("limit".to_string(), limit.unwrap_or(50).to_string()),
239 ("offset".to_string(), offset.unwrap_or(0).to_string()),
240 ];
241
242 let _use_auth = with_auth.unwrap_or(false);
243
244 self.get("/track/search", ¶ms).await
245 }
246
247 /// Downloads a track to the specified file path with embedded metadata.
248 ///
249 /// This function downloads a track from Qobuz in the specified audio format and saves it
250 /// to the provided file path. After downloading, it automatically embeds comprehensive
251 /// metadata (title, artist, album, cover art, etc.) into the audio file using the
252 /// metadata embedding functionality.
253 ///
254 /// # Arguments
255 /// * `track_id` - The unique identifier of the track to download
256 /// * `format_id` - The format ID specifying the audio quality:
257 /// - `5` for MP3 320 kbps
258 /// - `6` for FLAC Lossless (16-bit/44.1kHz)
259 /// - `7` for FLAC Hi-Res (24-bit, ≤96kHz)
260 /// - `27` for FLAC Hi-Res (24-bit, >96kHz & ≤192kHz)
261 /// * `path` - The file system path where the track should be saved
262 ///
263 /// # Returns
264 /// * `Ok(())` - If the track was successfully downloaded and metadata was embedded
265 /// * `Err(QobuzApiError)` - If the API request fails, download fails, directory creation
266 /// fails, or metadata embedding fails
267 ///
268 /// # Note
269 /// This function displays download progress in the console. The function will attempt
270 /// to create the target directory if it doesn't exist.
271 ///
272 /// # Example
273 /// ```no_run
274 /// # use qobuz_api_rust::{QobuzApiService, QobuzApiError};
275 /// # async fn example() -> Result<(), QobuzApiError> {
276 /// let service = QobuzApiService::new().await?;
277 /// service.download_track("12345", "6", "./downloads/track.flac").await?;
278 /// println!("Track downloaded successfully!");
279 /// # Ok(())
280 /// # }
281 /// ```
282 pub async fn download_track(
283 &self,
284 track_id: &str,
285 format_id: &str,
286 path: &str,
287 config: &MetadataConfig,
288 ) -> Result<(), QobuzApiError> {
289 match self.get_track_file_url(track_id, format_id).await {
290 Ok(file_url) => {
291 if let Some(url) = file_url.url {
292 let response =
293 self.client
294 .get(&url)
295 .send()
296 .await
297 .map_err(|e| DownloadError {
298 message: format!("Failed to initiate download: {}", e),
299 })?;
300
301 // Check if the response is successful
302 if !response.status().is_success() {
303 return Err(HttpError(response.error_for_status().unwrap_err()));
304 }
305
306 // Create the directory if it doesn't exist
307 if let Some(parent) = Path::new(path).parent() {
308 create_dir_all(parent).map_err(|e| DownloadError {
309 message: format!("Failed to create directory: {}", e),
310 })?;
311 }
312
313 // Get the total content length if available
314 let content_length = response
315 .headers()
316 .get(CONTENT_LENGTH)
317 .and_then(|len| len.to_str().ok())
318 .and_then(|len| len.parse::<u64>().ok());
319
320 // Create a file to write the response to
321 let mut dest =
322 BufWriter::new(File::create(path).map_err(|e| DownloadError {
323 message: format!("Failed to create file: {}", e),
324 })?);
325
326 // Get the response body as bytes stream
327 let mut stream = response.bytes_stream();
328
329 let mut downloaded: u64 = 0;
330
331 while let Some(chunk_result) = stream.next().await {
332 let chunk = chunk_result.map_err(|e| DownloadError {
333 message: format!("Failed to read chunk from response stream: {}", e),
334 })?;
335 dest.write_all(&chunk).map_err(|e| DownloadError {
336 message: format!("Failed to write chunk to file: {}", e),
337 })?;
338 downloaded += chunk.len() as u64;
339
340 // Print progress if we know the total size
341 if let Some(total) = content_length {
342 print!(
343 "\rProgress: {}/{} bytes ({:.2}%)",
344 downloaded,
345 total,
346 (downloaded as f64 / total as f64) * 100.0
347 );
348 } else {
349 print!("\rDownloaded: {} bytes", downloaded);
350 }
351 stdout().flush().map_err(|e| DownloadError {
352 message: format!("Failed to flush stdout: {}", e),
353 })?;
354 }
355
356 // Add a new line after progress display
357 println!();
358
359 // Flush the writer to ensure all data is written
360 dest.flush().map_err(|e| DownloadError {
361 message: format!("Failed to flush file writer: {}", e),
362 })?;
363
364 // After downloading, fetch track, album, and artist details to embed metadata
365 let track =
366 self.get_track(track_id, None)
367 .await
368 .map_err(|e| DownloadError {
369 message: format!("Failed to get track details for metadata: {}", e),
370 })?;
371 let album = if let Some(ref track_album) = track.album {
372 track_album.as_ref().clone()
373 } else {
374 return Err(ResourceNotFoundError {
375 resource_type: "album".to_string(),
376 resource_id: track_id.to_string(),
377 });
378 };
379
380 let artist = if let Some(ref track_artist) = track.performer {
381 track_artist.as_ref().clone()
382 } else if let Some(album_artist) = &album.artist {
383 album_artist.as_ref().clone()
384 } else {
385 return Err(ResourceNotFoundError {
386 resource_type: "artist".to_string(),
387 resource_id: track_id.to_string(),
388 });
389 };
390
391 // Embed metadata in the downloaded file
392 println!("Embedding metadata in {}", path);
393 embed_metadata_in_file(path, &track, &album, &artist, config)
394 .await
395 .map_err(|e| MetadataError {
396 source: Box::new(e),
397 })?;
398
399 Ok(())
400 } else {
401 Err(DownloadError {
402 message: "No download URL found for the track".to_string(),
403 })
404 }
405 }
406
407 Err(e) => Err(e),
408 }
409 }
410}