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::warn,
};
use crate::{
api::{
content::{records::Album, stream::get_track_file_url},
requests::download_stream,
service_state::QobuzApiService,
},
errors::QobuzApiError::{self, Canceled, DownloadError, HttpError},
};
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 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()
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicBool;
use {
anyhow::{Result, anyhow, ensure},
reqwest::Response,
tempfile::TempDir,
tokio::runtime::Runtime,
};
use crate::{
api::{
content::{
cover::fetch_track_cover,
persistence::{
DOWNLOAD_RETRY_BASE_DELAY_MS, MAX_DOWNLOAD_RETRIES, attempt_download,
is_retryable_network_error, save_track_to_disk, write_response_to_file,
},
quality::MP3_320,
stream::get_track_file_url,
},
dev_server::{MockServer, make_service, make_service_without_auth},
},
errors::QobuzApiError::{AuthenticationError, Canceled, DownloadError},
metadata::extractor::ComprehensiveMetadata,
};
fn mock_stream_response() -> Result<(TempDir, Runtime, Response)> {
let server = MockServer::start(200, "audio-bytes")?;
let service = make_service(&server.base_url())?;
let rt = Runtime::new()?;
let response = rt.block_on(service.http_client().get_with_auth(
&format!("{}/stream", service.base_url()),
"token",
None,
))?;
let dir = TempDir::new()?;
Ok((dir, rt, response))
}
#[test]
fn retry_constants_are_sensible() -> Result<()> {
ensure!(MAX_DOWNLOAD_RETRIES > 0, "retries should be positive");
ensure!(
DOWNLOAD_RETRY_BASE_DELAY_MS > 0,
"base delay should be positive"
);
Ok(())
}
#[test]
fn non_network_error_is_not_retryable() -> Result<()> {
let err = Canceled;
ensure!(
!is_retryable_network_error(&err),
"canceled should not retry"
);
let err = DownloadError {
message: "boom".to_string(),
};
ensure!(
!is_retryable_network_error(&err),
"download should not retry"
);
Ok(())
}
#[test]
fn file_url_requires_auth() -> Result<()> {
let server = MockServer::start(200, "{}")?;
let service = make_service_without_auth(&server.base_url())?;
let rt = Runtime::new()?;
let result = rt.block_on(get_track_file_url(&service, 1, MP3_320));
ensure!(result.is_err(), "expected auth error");
let err = result.err().ok_or_else(|| anyhow!("expected error"))?;
ensure!(
matches!(err, AuthenticationError { .. }),
"expected AuthenticationError"
);
Ok(())
}
#[test]
fn write_response_to_file_saves_bytes() -> Result<()> {
let (dir, rt, response) = mock_stream_response()?;
let path = dir.path().join("out.bin");
rt.block_on(write_response_to_file(response, &path, false, None))?;
ensure!(path.exists(), "output file should exist");
Ok(())
}
#[test]
fn save_track_to_disk_creates_file() -> Result<()> {
let (dir, rt, response) = mock_stream_response()?;
let path = rt.block_on(save_track_to_disk(
response,
7,
dir.path(),
MP3_320,
false,
None,
))?;
ensure!(path.exists(), "saved file should exist");
Ok(())
}
#[test]
fn fetch_cover_returns_none_without_url() -> Result<()> {
let server = MockServer::start(200, "{}")?;
let service = make_service(&server.base_url())?;
let meta = ComprehensiveMetadata::default();
let rt = Runtime::new()?;
let cover = rt.block_on(fetch_track_cover(&service, &meta, "token"));
ensure!(cover.is_none(), "expected no cover without URL");
Ok(())
}
#[test]
fn attempt_download_errors_without_url() -> Result<()> {
let body = r#"{"track_id":1}"#;
let server = MockServer::start(200, body)?;
let service = make_service(&server.base_url())?;
let dir = TempDir::new()?;
let path = dir.path().join("track.mp3");
let rt = Runtime::new()?;
let cancel: Option<&AtomicBool> = None;
let result = rt.block_on(attempt_download(&service, 1, MP3_320, &path, cancel));
ensure!(result.is_err(), "expected error without URL");
Ok(())
}
}