use std::{
fs::{create_dir_all, metadata},
path::{Path, PathBuf},
result::Result,
sync::{Arc, atomic::AtomicBool},
};
use {
tokio::{spawn, sync::Semaphore},
tracing::{debug, error, info, warn},
};
use crate::{
api::{
content::{
albums::get_album,
check_cancel,
download_io::save_track_to_disk,
fetch_with_cancel,
tracks::{get_track, get_track_file_url_raw},
},
requests::{RequestAuth, download_stream},
service::QobuzApiService,
},
errors::QobuzApiError::{self, DownloadError},
metadata::{
config::{MetadataConfig, MetadataField::CoverArt},
embedder::embed_metadata_batch,
extractor::{best_cover_url, extract_comprehensive_metadata},
},
models::album::Album,
sanitize::sanitize_filename,
};
fn prepare_album_directory(
album: &Album,
output_dir: &Path,
) -> Result<(Vec<i32>, PathBuf), QobuzApiError> {
let artist_name = album
.artist
.as_ref()
.and_then(|a| a.name.as_deref())
.unwrap_or("Unknown Artist");
let album_title = album.title.as_deref().unwrap_or("Unknown Album");
let dir = output_dir
.join(sanitize_filename(artist_name))
.join(sanitize_filename(album_title));
create_dir_all(&dir)?;
let track_ids = album.track_ids.clone().unwrap_or_default();
Ok((track_ids, dir))
}
async fn download_album_tracks(
service: &QobuzApiService,
track_ids: &[i32],
format_id: i32,
dir: &Path,
concurrency: Option<usize>,
cancel: Option<Arc<AtomicBool>>,
) -> Result<Vec<PathBuf>, QobuzApiError> {
let max_concurrent = concurrency.unwrap_or(4);
let semaphore = Arc::new(Semaphore::new(max_concurrent));
let mut handles = Vec::new();
for &track_id in track_ids {
check_cancel(cancel.as_deref())?;
let permit = Arc::clone(&semaphore)
.acquire_owned()
.await
.map_err(|e| DownloadError {
message: format!("Semaphore error: {e}"),
})?;
let dir_clone = dir.to_path_buf();
let service_client = QobuzApiService::http_client_ref(&service.app_id)?;
let app_id = service.app_id.clone();
let app_secret = service.app_secret.clone();
let token = service.require_auth_token()?.to_string();
let base_url = service.base_url().to_string();
let cancel_clone = cancel.clone();
let handle = spawn(async move {
let permit = permit;
let auth = RequestAuth {
app_id: &app_id,
app_secret: &app_secret,
user_auth_token: &token,
};
let file_url =
get_track_file_url_raw(&*service_client, &base_url, &auth, track_id, format_id)
.await?;
let url = file_url.url.ok_or_else(|| DownloadError {
message: format!("No download URL for track {track_id}"),
})?;
let (offset, range) = detect_resume_offset(&dir_clone, track_id, format_id);
let response =
download_stream(&*service_client, &url, &token, range.as_deref()).await?;
let resumed = offset.is_some() && response.status().as_u16() == 206;
warn_if_not_resumed(offset.is_some(), resumed, track_id);
let path = save_track_to_disk(
response,
track_id,
&dir_clone,
format_id,
resumed,
cancel_clone.as_deref(),
)
.await?;
info!(
track_id,
format_id,
path = %path.display(),
resumed,
"Track downloaded"
);
drop(permit);
Ok::<PathBuf, QobuzApiError>(path)
});
handles.push(handle);
}
let mut results = Vec::new();
for handle in handles {
check_cancel(cancel.as_deref())?;
match handle.await {
Ok(Ok(path)) => results.push(path),
Ok(Err(e)) => {
error!(error = %e, "Track download failed");
return Err(e);
}
Err(e) => {
return Err(DownloadError {
message: format!("Task join error: {e}"),
});
}
}
}
Ok(results)
}
async fn embed_album_metadata(
service: &QobuzApiService,
album: &Album,
track_ids: &[i32],
results: &[PathBuf],
config: &MetadataConfig,
) -> Result<(), QobuzApiError> {
let cover_data = download_cover_data(service, album, config).await?;
let mut file_metas = Vec::new();
for (&track_id, path) in track_ids.iter().zip(results.iter()) {
let track = get_track(service, track_id).await?;
let mut meta = extract_comprehensive_metadata(&track, Some(album), None);
meta.cover_art_data.clone_from(&cover_data);
file_metas.push((path.clone(), meta));
}
for result in embed_metadata_batch(&file_metas, config) {
result?;
}
Ok(())
}
pub async fn download_album(
service: &QobuzApiService,
album_id: &str,
format_id: i32,
output_dir: &Path,
config: Option<&MetadataConfig>,
concurrency: Option<usize>,
cancel: Option<Arc<AtomicBool>>,
) -> Result<Vec<PathBuf>, QobuzApiError> {
let album = fetch_with_cancel(cancel.as_deref(), || {
get_album(service, album_id, Some("track_ids"))
})
.await?;
let (track_ids, dir) = prepare_album_directory(&album, output_dir)?;
let results =
download_album_tracks(service, &track_ids, format_id, &dir, concurrency, cancel).await?;
info!(album_id, count = results.len(), "Album download complete");
if let Some(cfg) = config {
embed_album_metadata(service, &album, &track_ids, &results, cfg).await?;
}
Ok(results)
}
fn detect_resume_offset(
dir: &Path,
track_id: i32,
format_id: i32,
) -> (Option<u64>, Option<String>) {
let ext = Album::extension_for_format(format_id);
let existing_path = dir.join(format!("{track_id:02}.{ext}"));
let offset = existing_path
.exists()
.then(|| match metadata(&existing_path) {
Ok(m) if m.len() > 0 => Some(m.len()),
_ => None,
})
.flatten();
let range = offset.map(|s| format!("bytes={s}-"));
(offset, range)
}
fn warn_if_not_resumed(had_offset: bool, resumed: bool, track_id: i32) {
if had_offset && !resumed {
warn!(
track_id,
"Server did not support Range request, re-downloading full file"
);
}
}
async fn download_cover_data(
service: &QobuzApiService,
album: &Album,
config: &MetadataConfig,
) -> Result<Option<Vec<u8>>, QobuzApiError> {
if !config.is_enabled(CoverArt) {
return Ok(None);
}
let Some(url) = album.image.as_ref().and_then(best_cover_url) else {
return Ok(None);
};
let token = service.require_auth_token()?;
let resp = match service.http_client().get_with_auth(&url, token, None).await {
Ok(r) => r,
Err(e) => {
debug!(error = %e, "Cover art download failed");
return Ok(None);
}
};
match resp.bytes().await {
Err(e) => {
debug!(error = %e, "Failed to read cover art bytes");
Ok(None)
}
Ok(b) => Ok(Some(b.to_vec())),
}
}