1use 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#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
43pub struct AudioInfo {
44 pub bit_depth: Option<i32>,
46 pub sampling_rate: Option<f64>,
48 pub channels: Option<i32>,
50 pub codec: Option<String>,
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
56pub struct Track {
57 pub id: Option<i32>,
59 pub title: Option<String>,
61 pub version: Option<String>,
63 pub isrc: Option<String>,
65 pub track_number: Option<i32>,
67 pub duration: Option<i32>,
69 pub media_number: Option<i32>,
71 pub work: Option<String>,
73 pub album: Option<Box<Album>>,
75 pub performer: Option<Box<Artist>>,
77 pub performers: Option<String>,
79 pub composer: Option<Box<Artist>>,
81 pub audio_info: Option<AudioInfo>,
83 pub copyright: Option<String>,
85 pub streamable: Option<bool>,
87 pub downloadable: Option<bool>,
89 pub hires: Option<bool>,
91 pub maximum_bit_depth: Option<i32>,
93 pub maximum_sampling_rate: Option<f64>,
95 pub maximum_channel_count: Option<i32>,
97 pub release_date_original: Option<String>,
99 pub release_date_stream: Option<String>,
101 pub parental_warning: Option<bool>,
103 pub product_sales_factors: Option<Value>,
105}
106
107pub 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
133pub 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
151pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}