1use 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
35fn 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
77async 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
200async 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
243pub 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
293fn 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
328fn 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
347async 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}