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", &params).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, &timestamp);
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", &params).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", &params).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}