Skip to main content

qobuz_api/api/content/
tracks.rs

1//! Track search, browse, and download operations.
2
3use std::{
4    fs::create_dir_all,
5    path::{Path, PathBuf},
6    sync::atomic::{AtomicBool, Ordering::Relaxed},
7    time::Duration,
8};
9
10use {
11    serde::{Deserialize, Serialize},
12    serde_json::Value,
13    tokio::time::sleep,
14    tracing::{info, warn},
15};
16
17use crate::{
18    api::{
19        content::{
20            albums::Album,
21            artists::Artist,
22            catalog::{ItemSearchResult, TrackSearchResponse},
23            cover::fetch_track_cover,
24            get_by_id,
25            persistence::{
26                DOWNLOAD_RETRY_BASE_DELAY_MS, MAX_DOWNLOAD_RETRIES, attempt_download,
27                is_retryable_network_error,
28            },
29            search,
30        },
31        service::QobuzApiService,
32    },
33    errors::QobuzApiError::{self, Canceled},
34    metadata::{
35        config::MetadataConfig, embedder::embed_metadata_in_file,
36        extractor::extract_comprehensive_metadata,
37    },
38    sanitize::sanitize_filename,
39};
40
41/// Audio technical details.
42#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
43pub struct AudioInfo {
44    /// Bit depth.
45    pub bit_depth: Option<i32>,
46    /// Sample rate in kHz.
47    pub sampling_rate: Option<f64>,
48    /// Channel count.
49    pub channels: Option<i32>,
50    /// Audio codec.
51    pub codec: Option<String>,
52}
53
54/// An individual music track.
55#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
56pub struct Track {
57    /// Unique track identifier.
58    pub id: Option<i32>,
59    /// Track title.
60    pub title: Option<String>,
61    /// Version subtitle.
62    pub version: Option<String>,
63    /// ISRC code.
64    pub isrc: Option<String>,
65    /// Track position in album.
66    pub track_number: Option<i32>,
67    /// Duration in seconds.
68    pub duration: Option<i32>,
69    /// Disc number.
70    pub media_number: Option<i32>,
71    /// Classical work title.
72    pub work: Option<String>,
73    /// Parent album.
74    pub album: Option<Box<Album>>,
75    /// Primary performer.
76    pub performer: Option<Box<Artist>>,
77    /// All performers (formatted string).
78    pub performers: Option<String>,
79    /// Primary composer.
80    pub composer: Option<Box<Artist>>,
81    /// Audio technical details.
82    pub audio_info: Option<AudioInfo>,
83    /// Copyright notice.
84    pub copyright: Option<String>,
85    /// Streaming available.
86    pub streamable: Option<bool>,
87    /// Download available.
88    pub downloadable: Option<bool>,
89    /// Hi-Res available.
90    pub hires: Option<bool>,
91    /// Max bit depth.
92    pub maximum_bit_depth: Option<i32>,
93    /// Max sample rate.
94    pub maximum_sampling_rate: Option<f64>,
95    /// Max channel count.
96    pub maximum_channel_count: Option<i32>,
97    /// Original release date.
98    pub release_date_original: Option<String>,
99    /// Streaming date.
100    pub release_date_stream: Option<String>,
101    /// Explicit content flag.
102    pub parental_warning: Option<bool>,
103    /// Sales metadata.
104    pub product_sales_factors: Option<Value>,
105}
106
107/// Searches for tracks matching the query.
108///
109/// # Arguments
110///
111/// * `service` - Authenticated API service
112/// * `query` - Search query string
113/// * `limit` - Maximum number of results to return
114/// * `offset` - Pagination offset
115///
116/// # Returns
117///
118/// A paginated `ItemSearchResult` containing matching tracks.
119///
120/// # Errors
121///
122/// Returns a `QobuzApiError` if not authenticated or the API request fails.
123pub async fn search_tracks(
124    service: &QobuzApiService,
125    query: &str,
126    limit: Option<i32>,
127    offset: Option<i32>,
128) -> Result<ItemSearchResult<Box<Track>>, QobuzApiError> {
129    let resp: TrackSearchResponse = search(service, "/track/search", query, limit, offset).await?;
130    Ok(resp.tracks)
131}
132
133/// Retrieves track details by ID.
134///
135/// # Arguments
136///
137/// * `service` - Authenticated API service
138/// * `track_id` - Track identifier
139///
140/// # Returns
141///
142/// The track details.
143///
144/// # Errors
145///
146/// Returns a `QobuzApiError` if not authenticated or the API request fails.
147pub async fn get_track(service: &QobuzApiService, track_id: i32) -> Result<Track, QobuzApiError> {
148    get_by_id(service, "/track/get", "track_id", track_id, None).await
149}
150
151/// Downloads a single track to the specified directory.
152///
153/// Retries up to [`MAX_DOWNLOAD_RETRIES`] times on transient network errors,
154/// resuming from the partial file on disk via HTTP Range requests.
155///
156/// # Arguments
157///
158/// * `service` - Authenticated API service
159/// * `track_id` - Track identifier
160/// * `format_id` - Quality format ID
161/// * `output_dir` - Directory to save the downloaded file
162/// * `config` - Optional metadata configuration for tagging
163///
164/// # Returns
165///
166/// The path to the downloaded file.
167///
168/// # Errors
169///
170/// Returns a `QobuzApiError` if URL retrieval, download, or I/O fails.
171pub async fn download_track(
172    service: &QobuzApiService,
173    track_id: i32,
174    format_id: i32,
175    output_dir: &Path,
176    config: Option<&MetadataConfig>,
177    cancel: Option<&AtomicBool>,
178) -> Result<PathBuf, QobuzApiError> {
179    if cancel.is_some_and(|c| c.load(Relaxed)) {
180        return Err(Canceled);
181    }
182
183    let track = get_track(service, track_id).await?;
184
185    if cancel.is_some_and(|c| c.load(Relaxed)) {
186        return Err(Canceled);
187    }
188
189    let ext = Album::extension_for_format(format_id);
190    let track_num = track.track_number.unwrap_or(track_id);
191    let title = track.title.as_deref().unwrap_or("Unknown");
192    let safe_name = sanitize_filename(&format!("{track_num:02}. {title}"));
193    let filename = format!("{safe_name}.{ext}");
194
195    create_dir_all(output_dir)?;
196    let path = output_dir.join(&filename);
197
198    let mut resumed = false;
199
200    for attempt in 0..=MAX_DOWNLOAD_RETRIES {
201        if cancel.is_some_and(|c| c.load(Relaxed)) {
202            return Err(Canceled);
203        }
204
205        match attempt_download(service, track_id, format_id, &path, cancel).await {
206            Ok(r) => {
207                resumed = r;
208                break;
209            }
210            Err(e) if is_retryable_network_error(&e) && attempt < MAX_DOWNLOAD_RETRIES => {
211                let delay = DOWNLOAD_RETRY_BASE_DELAY_MS.saturating_mul(2u64.pow(attempt));
212                warn!(
213                    track_id,
214                    attempt,
215                    delay_ms = delay,
216                    path = %path.display(),
217                    error = %e,
218                    "Download failed, retrying with resume"
219                );
220                sleep(Duration::from_millis(delay)).await;
221            }
222            Err(e) => return Err(e),
223        }
224    }
225
226    if cancel.is_some_and(|c| c.load(Relaxed)) {
227        return Err(Canceled);
228    }
229
230    info!(
231        track_id,
232        format_id,
233        path = %path.display(),
234        resumed,
235        "Track downloaded"
236    );
237
238    if let Some(cfg) = config {
239        let token = service.require_auth_token()?;
240        let album_info = track.album.as_ref().map(AsRef::as_ref);
241        let mut meta = extract_comprehensive_metadata(&track, album_info, None);
242
243        meta.cover_art_data = fetch_track_cover(service, &meta, token).await;
244        embed_metadata_in_file(&path, &meta, cfg)?;
245    }
246
247    Ok(path)
248}
249
250#[cfg(test)]
251mod tests {
252    use std::fs::write;
253
254    use {
255        anyhow::{Result, anyhow, ensure},
256        tempfile::TempDir,
257        tokio::runtime::Runtime,
258    };
259
260    use crate::{
261        api::{
262            content::{
263                persistence::detect_partial_file,
264                tracks::{get_track, search_tracks},
265            },
266            fixture::{MockServer, make_service},
267        },
268        assert_empty_search_test,
269    };
270
271    /// Tests search tracks deserializes results.
272    ///
273    /// # Errors
274    ///
275    /// Returns an error if the test setup or assertion fails.
276    #[test]
277    fn search_tracks_deserializes_results() -> Result<()> {
278        let body = r#"{"tracks":{"items":[{"id":1,"title":"So What"}],"total":1}}"#;
279        let server = MockServer::start(200, body)?;
280        let service = make_service(&server.base_url())?;
281        let rt = Runtime::new()?;
282        let result = rt.block_on(search_tracks(&service, "So What", Some(5), None))?;
283        let items = result.items.ok_or_else(|| anyhow!("no items"))?;
284        ensure!(items.len() == 1);
285        ensure!(items.first().and_then(|i| i.title.as_deref()) == Some("So What"));
286        Ok(())
287    }
288
289    /// Tests search tracks empty results.
290    ///
291    /// # Errors
292    ///
293    /// Returns an error if the test setup or assertion fails.
294    #[test]
295    fn search_tracks_empty_results() -> Result<()> {
296        assert_empty_search_test!(
297            search_tracks,
298            "Nothing",
299            r#"{"tracks":{"items":[],"total":0}}"#
300        );
301        Ok(())
302    }
303
304    /// Tests get track by id.
305    ///
306    /// # Errors
307    ///
308    /// Returns an error if the test setup or assertion fails.
309    #[test]
310    fn get_track_by_id() -> Result<()> {
311        let body = r#"{"id":42,"title":"Blue in Green"}"#;
312        let server = MockServer::start(200, body)?;
313        let service = make_service(&server.base_url())?;
314        let rt = Runtime::new()?;
315        let track = rt.block_on(get_track(&service, 42))?;
316        ensure!(track.title.as_deref() == Some("Blue in Green"));
317        Ok(())
318    }
319
320    /// Tests search tracks error response.
321    ///
322    /// # Errors
323    ///
324    /// Returns an error if the test setup or assertion fails.
325    #[test]
326    fn search_tracks_error_response() -> Result<()> {
327        let body = r#"{"status":"error","code":500,"message":"Server error"}"#;
328        let server = MockServer::start(500, body)?;
329        let service = make_service(&server.base_url())?;
330        let rt = Runtime::new()?;
331        let result = rt.block_on(search_tracks(&service, "fail", None, None));
332        ensure!(result.is_err());
333        Ok(())
334    }
335
336    /// Tests get track not found.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the test setup or assertion fails.
341    #[test]
342    fn get_track_not_found() -> Result<()> {
343        let body = r#"{"status":"error","code":404,"message":"Track not found"}"#;
344        let server = MockServer::start(404, body)?;
345        let service = make_service(&server.base_url())?;
346        let rt = Runtime::new()?;
347        let result = rt.block_on(get_track(&service, 99999));
348        ensure!(result.is_err());
349        Ok(())
350    }
351
352    /// Tests detect partial file returns none for missing.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the test setup or assertion fails.
357    #[test]
358    fn detect_partial_file_returns_none_for_missing() -> Result<()> {
359        let dir = TempDir::new()?;
360        let path = dir.path().join("nonexistent.flac");
361        ensure!(detect_partial_file(&path).is_none());
362        Ok(())
363    }
364
365    /// Tests detect partial file returns size for existing.
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the test setup or assertion fails.
370    #[test]
371    fn detect_partial_file_returns_size_for_existing() -> Result<()> {
372        let dir = TempDir::new()?;
373        let path = dir.path().join("partial.flac");
374        write(&path, b"hello world")?;
375        ensure!(detect_partial_file(&path) == Some(11));
376        Ok(())
377    }
378
379    /// Tests detect partial file returns none for empty.
380    ///
381    /// # Errors
382    ///
383    /// Returns an error if the test setup or assertion fails.
384    #[test]
385    fn detect_partial_file_returns_none_for_empty() -> Result<()> {
386        let dir = TempDir::new()?;
387        let path = dir.path().join("empty.flac");
388        write(&path, b"")?;
389        ensure!(detect_partial_file(&path).is_none());
390        Ok(())
391    }
392}