use std::{
fs::{create_dir_all, metadata},
path::{Path, PathBuf},
sync::atomic::{AtomicBool, Ordering::Relaxed},
};
use {
reqwest::Response,
tokio::{
fs::{File, OpenOptions},
io::AsyncWriteExt,
},
tokio_stream::StreamExt,
tracing::{debug, warn},
};
use crate::{
api::{
content::tracks::get_track_file_url, requests::download_stream, service::QobuzApiService,
},
errors::QobuzApiError::{self, Canceled, DownloadError, HttpError},
metadata::extractor::ComprehensiveMetadata,
models::album::Album,
};
pub const MAX_DOWNLOAD_RETRIES: u32 = 3;
pub const DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 2000;
#[must_use]
pub fn detect_partial_file(path: &Path) -> Option<u64> {
let size = match metadata(path) {
Ok(m) => m.len(),
Err(_) => return None,
};
(size > 0).then_some(size)
}
pub async fn write_response_to_file(
response: Response,
path: &Path,
append: bool,
cancel: Option<&AtomicBool>,
) -> Result<(), QobuzApiError> {
let mut file = if append {
OpenOptions::new().append(true).open(path).await?
} else {
File::create(path).await?
};
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
if cancel.is_some_and(|c| c.load(Relaxed)) {
return Err(Canceled);
}
file.write_all(&chunk?).await?;
}
file.flush().await?;
Ok(())
}
pub async fn save_track_to_disk(
response: Response,
track_id: i32,
output_dir: &Path,
format_id: i32,
append: bool,
cancel: Option<&AtomicBool>,
) -> Result<PathBuf, QobuzApiError> {
create_dir_all(output_dir)?;
let ext = Album::extension_for_format(format_id);
let path = output_dir.join(format!("{track_id:02}.{ext}"));
write_response_to_file(response, &path, append, cancel).await?;
Ok(path)
}
pub async fn fetch_track_cover(
service: &QobuzApiService,
meta: &ComprehensiveMetadata,
token: &str,
) -> Option<Vec<u8>> {
let url = meta.cover_art_url.as_deref()?;
let resp = match service.http_client().get_with_auth(url, token, None).await {
Ok(r) => r,
Err(e) => {
debug!(error = %e, "Cover art HTTP request failed");
return None;
}
};
match resp.bytes().await {
Err(e) => {
debug!(error = %e, "Failed to read cover art bytes");
None
}
Ok(b) => Some(b.to_vec()),
}
}
pub async fn attempt_download(
service: &QobuzApiService,
track_id: i32,
format_id: i32,
path: &Path,
cancel: Option<&AtomicBool>,
) -> Result<bool, QobuzApiError> {
let offset = detect_partial_file(path);
let range = offset.map(|s| format!("bytes={s}-"));
let file_url = get_track_file_url(service, track_id, format_id).await?;
let url = file_url.url.ok_or_else(|| DownloadError {
message: format!("No download URL for track {track_id}"),
})?;
let token = service.require_auth_token()?;
let response = download_stream(service.http_client(), &url, token, range.as_deref()).await?;
let resumed = offset.is_some() && response.status().as_u16() == 206;
if offset.is_some() && !resumed {
warn!(
track_id,
path = %path.display(),
"Server did not support Range request, re-downloading full file"
);
}
write_response_to_file(response, path, resumed, cancel).await?;
Ok(resumed)
}
#[must_use]
pub fn is_retryable_network_error(err: &QobuzApiError) -> bool {
let HttpError(reqwest_err) = err else {
return false;
};
reqwest_err.is_connect()
|| reqwest_err.is_timeout()
|| reqwest_err.is_body()
|| reqwest_err.is_decode()
}