Skip to main content

qobuz_api/api/content/
stream.rs

1//! Track streaming URL resolution.
2
3/// Quality format IDs for track downloads.
4pub mod quality {
5    /// MP3 320kbps.
6    pub const MP3_320: i32 = 5;
7    /// FLAC 16-bit/44.1kHz (CD quality).
8    pub const FLAC_16_44: i32 = 6;
9    /// FLAC 24-bit/96kHz (Hi-Res).
10    pub const FLAC_24_96: i32 = 7;
11    /// FLAC 24-bit/192kHz (Hi-Res).
12    pub const FLAC_24_192: i32 = 27;
13}
14
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::{Deserialize, Serialize};
18
19use crate::{
20    api::{
21        http_client::HttpClient,
22        requests::{RequestAuth, build_url_with_params, retry_with_backoff},
23        response::parse_response,
24        service::QobuzApiService,
25    },
26    errors::QobuzApiError,
27    signing::sign_track_file_url,
28};
29
30/// Download URL for a track at a specific quality level.
31#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
32pub struct FileUrl {
33    /// Track identifier.
34    pub track_id: Option<i32>,
35    /// Track duration in seconds.
36    pub duration: Option<f64>,
37    /// Download URL.
38    pub url: Option<String>,
39    /// Quality format ID.
40    pub format_id: Option<i32>,
41    /// MIME type of the audio file.
42    pub mime_type: Option<String>,
43    /// Sample rate in kHz.
44    pub sampling_rate: Option<f64>,
45    /// Bit depth.
46    pub bit_depth: Option<i32>,
47    /// Response status code.
48    pub status: Option<i32>,
49    /// Error message if applicable.
50    pub message: Option<String>,
51    /// Error code if applicable.
52    pub code: Option<String>,
53}
54
55/// Gets the download URL for a track at the specified quality.
56///
57/// # Arguments
58///
59/// * `service` - Authenticated API service
60/// * `track_id` - Track identifier
61/// * `format_id` - Quality format ID (5=MP3, 6=FLAC 16-bit, 7=FLAC 24-bit/96kHz, 27=FLAC
62///   24-bit/192kHz)
63///
64/// # Returns
65///
66/// The download URL and metadata for the track.
67///
68/// # Errors
69///
70/// Returns a `QobuzApiError` if not authenticated or the API request fails.
71pub async fn get_track_file_url(
72    service: &QobuzApiService,
73    track_id: i32,
74    format_id: i32,
75) -> Result<FileUrl, QobuzApiError> {
76    let token = service.require_auth_token()?;
77
78    get_track_file_url_raw(
79        service.http_client(),
80        service.base_url(),
81        &RequestAuth {
82            app_id: &service.app_id,
83            app_secret: service.app_secret(),
84            user_auth_token: token,
85        },
86        track_id,
87        format_id,
88    )
89    .await
90}
91
92/// Internal function to get a track file URL (used by download operations).
93///
94/// # Arguments
95///
96/// * `client` - HTTP client implementation
97/// * `base_url` - API base URL
98/// * `auth` - Application credentials and user authentication token
99/// * `track_id` - Track identifier
100/// * `format_id` - Quality format ID
101///
102/// # Returns
103///
104/// The signed download URL and metadata for the track.
105///
106/// # Errors
107///
108/// Returns a `QobuzApiError` if the signed API request fails.
109pub async fn get_track_file_url_raw(
110    client: &dyn HttpClient,
111    base_url: &str,
112    auth: &RequestAuth<'_>,
113    track_id: i32,
114    format_id: i32,
115) -> Result<FileUrl, QobuzApiError> {
116    let ts = SystemTime::now()
117        .duration_since(UNIX_EPOCH)
118        .unwrap_or_default()
119        .as_secs()
120        .to_string();
121
122    let sig = sign_track_file_url(format_id, track_id, &ts, auth.app_secret);
123
124    let params: Vec<(String, String)> = vec![
125        ("track_id".to_string(), track_id.to_string()),
126        ("format_id".to_string(), format_id.to_string()),
127        ("intent".to_string(), "stream".to_string()),
128        ("request_ts".to_string(), ts),
129        ("request_sig".to_string(), sig),
130        ("app_id".to_string(), auth.app_id.to_string()),
131    ];
132
133    let url = build_url_with_params(base_url, "/track/getFileUrl", &params);
134    let response = retry_with_backoff(client, &url, auth.user_auth_token).await?;
135
136    parse_response::<FileUrl>(response, "/track/getFileUrl").await
137}
138
139#[cfg(test)]
140mod tests {
141    use {
142        anyhow::{Result, ensure},
143        serde_json::from_str,
144    };
145
146    use crate::api::content::stream::{
147        FileUrl,
148        quality::{FLAC_16_44, FLAC_24_96, FLAC_24_192, MP3_320},
149    };
150
151    /// Tests quality constants are distinct.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the assertion fails.
156    #[test]
157    fn quality_constants_are_distinct() -> Result<()> {
158        ensure!(MP3_320 == 5, "MP3 constant mismatch");
159        ensure!(FLAC_16_44 == 6, "FLAC 16/44 constant mismatch");
160        ensure!(FLAC_24_96 == 7, "FLAC 24/96 constant mismatch");
161        ensure!(FLAC_24_192 == 27, "FLAC 24/192 constant mismatch");
162        Ok(())
163    }
164
165    /// Tests file URL deserializes from JSON.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if deserialization fails.
170    #[test]
171    fn file_url_deserializes() -> Result<()> {
172        let body = r#"{"url":"https://example.com/file.flac","format_id":6}"#;
173        let file_url: FileUrl = from_str(body)?;
174        ensure!(
175            file_url.url.as_deref() == Some("https://example.com/file.flac"),
176            "URL mismatch"
177        );
178        ensure!(file_url.format_id == Some(6), "format mismatch");
179        Ok(())
180    }
181}