Skip to main content

qobuz_api/api/content/
bundle.rs

1//! Album download operations with concurrent track fetching and metadata embedding.
2
3use std::{
4    fs::{create_dir_all, metadata},
5    path::{Path, PathBuf},
6    sync::{Arc, atomic::AtomicBool},
7};
8
9use {
10    tokio::{spawn, sync::Semaphore},
11    tracing::{error, info, warn},
12};
13
14use crate::{
15    api::{
16        content::{
17            albums::{Album, get_album},
18            check_cancel, fetch_with_cancel,
19            persistence::save_track_to_disk,
20            stream::get_track_file_url_raw,
21            tracks::get_track,
22        },
23        requests::{RequestAuth, download_stream},
24        service::QobuzApiService,
25    },
26    errors::QobuzApiError::{self, DownloadError},
27    metadata::{
28        config::{MetadataConfig, MetadataField::CoverArt},
29        embedder::embed_metadata_batch,
30        extractor::{best_cover_url, extract_comprehensive_metadata},
31    },
32    sanitize::sanitize_filename,
33};
34
35/// Prepares the output directory for an album download.
36///
37/// Extracts artist name and album title from the album, sanitizes them, and creates
38/// the `{output_dir}/{artist}/{album_title}/` directory structure.
39///
40/// # Arguments
41///
42/// * `album` - Album containing artist name, title, and track IDs
43/// * `output_dir` - Base output directory
44///
45/// # Returns
46///
47/// A tuple of `(track_ids, dir_path)` where:
48/// - `track_ids`: List of track IDs from the album
49/// - `dir_path`: The created output directory path
50///
51/// # Errors
52///
53/// Returns `QobuzApiError` if the directory cannot be created.
54fn prepare_album_directory(
55    album: &Album,
56    output_dir: &Path,
57) -> Result<(Vec<i32>, PathBuf), QobuzApiError> {
58    let artist_name = album
59        .artist
60        .as_ref()
61        .and_then(|a| a.name.as_deref())
62        .unwrap_or("Unknown Artist");
63
64    let album_title = album.title.as_deref().unwrap_or("Unknown Album");
65
66    let dir = output_dir
67        .join(sanitize_filename(artist_name))
68        .join(sanitize_filename(album_title));
69
70    create_dir_all(&dir)?;
71
72    let track_ids = album.track_ids.clone().unwrap_or_default();
73
74    Ok((track_ids, dir))
75}
76
77/// Downloads all tracks in an album concurrently, returning their file paths.
78///
79/// Spawns a tokio task per track, bounded by a semaphore for concurrency control.
80///
81/// # Arguments
82///
83/// * `service` - Authenticated API service for signing requests
84/// * `track_ids` - Slice of track IDs to download
85/// * `format_id` - Quality format ID (5=MP3, 6=FLAC 16-bit, 7=FLAC 24-bit/96kHz, 27=FLAC
86///   24-bit/192kHz)
87/// * `dir` - Output directory for downloaded files
88/// * `concurrency` - Maximum number of concurrent downloads
89/// * `cancel` - Optional cancellation flag
90///
91/// # Returns
92///
93/// A vector of paths to the downloaded track files.
94///
95/// # Errors
96///
97/// Returns `QobuzApiError` if cancellation is requested, track download fails,
98/// or metadata embedding fails.
99async fn download_album_tracks(
100    service: &QobuzApiService,
101    track_ids: &[i32],
102    format_id: i32,
103    dir: &Path,
104    concurrency: Option<usize>,
105    cancel: Option<Arc<AtomicBool>>,
106) -> Result<Vec<PathBuf>, QobuzApiError> {
107    let max_concurrent = concurrency.unwrap_or(4);
108    let semaphore = Arc::new(Semaphore::new(max_concurrent));
109    let mut handles = Vec::new();
110
111    for &track_id in track_ids {
112        check_cancel(cancel.as_deref())?;
113
114        let permit = Arc::clone(&semaphore)
115            .acquire_owned()
116            .await
117            .map_err(|e| DownloadError {
118                message: format!("Semaphore error: {e}"),
119            })?;
120
121        let dir_clone = dir.to_path_buf();
122        let service_client = QobuzApiService::http_client_ref(&service.app_id)?;
123        let app_id = service.app_id.clone();
124        let app_secret = service.app_secret.clone();
125        let token = service.require_auth_token()?.to_string();
126        let base_url = service.base_url().to_string();
127        let cancel_clone = cancel.clone();
128
129        let handle = spawn(async move {
130            let permit = permit;
131
132            let auth = RequestAuth {
133                app_id: &app_id,
134                app_secret: &app_secret,
135                user_auth_token: &token,
136            };
137
138            let file_url =
139                get_track_file_url_raw(&*service_client, &base_url, &auth, track_id, format_id)
140                    .await?;
141
142            let url = file_url.url.ok_or_else(|| DownloadError {
143                message: format!("No download URL for track {track_id}"),
144            })?;
145
146            let (offset, range) = detect_resume_offset(&dir_clone, track_id, format_id);
147
148            let response =
149                download_stream(&*service_client, &url, &token, range.as_deref()).await?;
150
151            let resumed = offset.is_some() && response.status().as_u16() == 206;
152            warn_if_not_resumed(offset.is_some(), resumed, track_id);
153
154            let path = save_track_to_disk(
155                response,
156                track_id,
157                &dir_clone,
158                format_id,
159                resumed,
160                cancel_clone.as_deref(),
161            )
162            .await?;
163
164            info!(
165                track_id,
166                format_id,
167                path = %path.display(),
168                resumed,
169                "Track downloaded"
170            );
171
172            drop(permit);
173            Ok::<PathBuf, QobuzApiError>(path)
174        });
175
176        handles.push(handle);
177    }
178
179    let mut results = Vec::new();
180    for handle in handles {
181        check_cancel(cancel.as_deref())?;
182
183        match handle.await {
184            Ok(Ok(path)) => results.push(path),
185            Ok(Err(e)) => {
186                error!(error = %e, "Track download failed");
187                return Err(e);
188            }
189            Err(e) => {
190                return Err(DownloadError {
191                    message: format!("Task join error: {e}"),
192                });
193            }
194        }
195    }
196
197    Ok(results)
198}
199
200/// Embeds metadata into downloaded album tracks.
201///
202/// Downloads cover art, fetches track metadata, and embeds it into each file.
203///
204/// # Arguments
205///
206/// * `service` - Authenticated API service for fetching track metadata
207/// * `album` - Album used as fallback metadata source
208/// * `track_ids` - Slice of track IDs matching the results order
209/// * `results` - Downloaded file paths, one per track ID
210/// * `config` - Metadata configuration controlling which fields to embed
211///
212/// # Returns
213///
214/// `Ok(())` on success.
215///
216/// # Errors
217///
218/// Returns `QobuzApiError` if track fetching or metadata embedding fails.
219async fn embed_album_metadata(
220    service: &QobuzApiService,
221    album: &Album,
222    track_ids: &[i32],
223    results: &[PathBuf],
224    config: &MetadataConfig,
225) -> Result<(), QobuzApiError> {
226    let cover_data = download_cover_data(service, album, config).await?;
227
228    let mut file_metas = Vec::new();
229    for (&track_id, path) in track_ids.iter().zip(results.iter()) {
230        let track = get_track(service, track_id).await?;
231        let mut meta = extract_comprehensive_metadata(&track, Some(album), None);
232        meta.cover_art_data.clone_from(&cover_data);
233        file_metas.push((path.clone(), meta));
234    }
235
236    for result in embed_metadata_batch(&file_metas, config) {
237        result?;
238    }
239
240    Ok(())
241}
242
243/// Downloads all tracks in an album concurrently.
244///
245/// Creates `{output_dir}/{artist}/{album_title}/` directory structure.
246/// Uses `tokio::sync::Semaphore` for bounded concurrency.
247///
248/// # Arguments
249///
250/// * `service` - Authenticated API service
251/// * `album_id` - Album identifier
252/// * `format_id` - Quality format ID (5=MP3, 6=FLAC 16-bit, 7=FLAC 24-bit/96kHz, 27=FLAC
253///   24-bit/192kHz)
254/// * `output_dir` - Base output directory for downloaded files
255/// * `config` - Optional metadata configuration for tagging
256/// * `concurrency` - Maximum number of concurrent downloads
257///
258/// # Returns
259///
260/// A vector of paths to the downloaded track files.
261///
262/// # Errors
263///
264/// Returns a `QobuzApiError` if album retrieval, track download, or I/O fails.
265pub async fn download_album(
266    service: &QobuzApiService,
267    album_id: &str,
268    format_id: i32,
269    output_dir: &Path,
270    config: Option<&MetadataConfig>,
271    concurrency: Option<usize>,
272    cancel: Option<Arc<AtomicBool>>,
273) -> Result<Vec<PathBuf>, QobuzApiError> {
274    let album = fetch_with_cancel(cancel.as_deref(), || {
275        get_album(service, album_id, Some("track_ids"))
276    })
277    .await?;
278
279    let (track_ids, dir) = prepare_album_directory(&album, output_dir)?;
280
281    let results =
282        download_album_tracks(service, &track_ids, format_id, &dir, concurrency, cancel).await?;
283
284    info!(album_id, count = results.len(), "Album download complete");
285
286    if let Some(cfg) = config {
287        embed_album_metadata(service, &album, &track_ids, &results, cfg).await?;
288    }
289
290    Ok(results)
291}
292
293/// Detects a partial file on disk and returns the byte offset and Range header value for resumable
294/// downloads.
295///
296/// Checks if a partial file exists for the given track and format, and if so, returns the
297/// byte offset (file size) and a formatted Range header string for the HTTP request.
298///
299/// # Arguments
300///
301/// * `dir` - Directory containing the track file
302/// * `track_id` - Track identifier
303/// * `format_id` - Quality format ID (e.g., 5=MP3, 6=FLAC 16-bit, 7=FLAC 24-bit/96kHz)
304///
305/// # Returns
306///
307/// A tuple of `(offset, range)` where:
308/// - `offset`: The byte position to resume from (file size), or `None` if no partial file exists
309/// - `range`: The `Range` HTTP header value (e.g., `"bytes=1234567-"`), or `None`
310fn detect_resume_offset(
311    dir: &Path,
312    track_id: i32,
313    format_id: i32,
314) -> (Option<u64>, Option<String>) {
315    let ext = Album::extension_for_format(format_id);
316    let existing_path = dir.join(format!("{track_id:02}.{ext}"));
317    let offset = existing_path
318        .exists()
319        .then(|| match metadata(&existing_path) {
320            Ok(m) if m.len() > 0 => Some(m.len()),
321            _ => None,
322        })
323        .flatten();
324    let range = offset.map(|s| format!("bytes={s}-"));
325    (offset, range)
326}
327
328/// Warns if a Range request was made but the server responded with 200 instead of 206.
329///
330/// This indicates the server does not support resumable downloads, so the full file
331/// will be re-downloaded from byte 0.
332///
333/// # Arguments
334///
335/// * `had_offset` - Whether we attempted to resume (found a partial file)
336/// * `resumed` - Whether the server responded with 206 Partial Content
337/// * `track_id` - Track identifier for logging
338fn warn_if_not_resumed(had_offset: bool, resumed: bool, track_id: i32) {
339    if had_offset && !resumed {
340        warn!(
341            track_id,
342            "Server did not support Range request, re-downloading full file"
343        );
344    }
345}
346
347/// Downloads cover art data for an album if cover art metadata is enabled.
348///
349/// # Arguments
350///
351/// * `service` - Authenticated API service
352/// * `album` - Album containing cover art URL
353/// * `config` - Metadata configuration controlling field embedding
354///
355/// # Returns
356///
357/// Cover art image bytes, or `None` if cover art is disabled or unavailable.
358///
359/// # Errors
360///
361/// Returns `QobuzApiError` if authentication is missing.
362async fn download_cover_data(
363    service: &QobuzApiService,
364    album: &Album,
365    config: &MetadataConfig,
366) -> Result<Option<Vec<u8>>, QobuzApiError> {
367    if !config.is_enabled(CoverArt) {
368        return Ok(None);
369    }
370    let Some(url) = album.image.as_ref().and_then(best_cover_url) else {
371        return Ok(None);
372    };
373    let token = service.require_auth_token()?;
374    let resp = match service.http_client().get_with_auth(&url, token, None).await {
375        Ok(r) => r,
376        Err(e) => {
377            warn!(error = %e, "Cover art download failed");
378            return Ok(None);
379        }
380    };
381    match resp.bytes().await {
382        Err(e) => {
383            warn!(error = %e, "Failed to read cover art bytes");
384            Ok(None)
385        }
386        Ok(b) => Ok(Some(b.to_vec())),
387    }
388}