1use std::{
4 fs::{create_dir_all, metadata},
5 path::{Path, PathBuf},
6 sync::atomic::{AtomicBool, Ordering::Relaxed},
7};
8
9use {
10 reqwest::Response,
11 tokio::{
12 fs::{File, OpenOptions},
13 io::AsyncWriteExt,
14 },
15 tokio_stream::StreamExt,
16 tracing::warn,
17};
18
19use crate::{
20 api::{
21 content::{albums::Album, stream::get_track_file_url},
22 requests::download_stream,
23 service::QobuzApiService,
24 },
25 errors::QobuzApiError::{self, Canceled, DownloadError, HttpError},
26};
27
28pub const MAX_DOWNLOAD_RETRIES: u32 = 3;
30
31pub const DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 2000;
33
34#[must_use]
44pub fn detect_partial_file(path: &Path) -> Option<u64> {
45 let size = match metadata(path) {
46 Ok(m) => m.len(),
47 Err(_) => return None,
48 };
49 (size > 0).then_some(size)
50}
51
52pub async fn write_response_to_file(
70 response: Response,
71 path: &Path,
72 append: bool,
73 cancel: Option<&AtomicBool>,
74) -> Result<(), QobuzApiError> {
75 let mut file = if append {
76 OpenOptions::new().append(true).open(path).await?
77 } else {
78 File::create(path).await?
79 };
80 let mut stream = response.bytes_stream();
81 while let Some(chunk) = stream.next().await {
82 if cancel.is_some_and(|c| c.load(Relaxed)) {
83 return Err(Canceled);
84 }
85 file.write_all(&chunk?).await?;
86 }
87 file.flush().await?;
88 Ok(())
89}
90
91pub async fn save_track_to_disk(
110 response: Response,
111 track_id: i32,
112 output_dir: &Path,
113 format_id: i32,
114 append: bool,
115 cancel: Option<&AtomicBool>,
116) -> Result<PathBuf, QobuzApiError> {
117 create_dir_all(output_dir)?;
118
119 let ext = Album::extension_for_format(format_id);
120 let path = output_dir.join(format!("{track_id:02}.{ext}"));
121 write_response_to_file(response, &path, append, cancel).await?;
122 Ok(path)
123}
124
125pub async fn attempt_download(
144 service: &QobuzApiService,
145 track_id: i32,
146 format_id: i32,
147 path: &Path,
148 cancel: Option<&AtomicBool>,
149) -> Result<bool, QobuzApiError> {
150 let offset = detect_partial_file(path);
151 let range = offset.map(|s| format!("bytes={s}-"));
152
153 let file_url = get_track_file_url(service, track_id, format_id).await?;
154
155 let url = file_url.url.ok_or_else(|| DownloadError {
156 message: format!("No download URL for track {track_id}"),
157 })?;
158
159 let token = service.require_auth_token()?;
160 let response = download_stream(service.http_client(), &url, token, range.as_deref()).await?;
161
162 let resumed = offset.is_some() && response.status().as_u16() == 206;
163 if offset.is_some() && !resumed {
164 warn!(
165 track_id,
166 path = %path.display(),
167 "Server did not support Range request, re-downloading full file"
168 );
169 }
170
171 write_response_to_file(response, path, resumed, cancel).await?;
172
173 Ok(resumed)
174}
175
176#[must_use]
186pub fn is_retryable_network_error(err: &QobuzApiError) -> bool {
187 let HttpError(reqwest_err) = err else {
188 return false;
189 };
190 reqwest_err.is_connect()
191 || reqwest_err.is_timeout()
192 || reqwest_err.is_body()
193 || reqwest_err.is_decode()
194}
195
196#[cfg(test)]
197mod tests {
198 use std::sync::atomic::AtomicBool;
199
200 use {
201 anyhow::{Result, anyhow, ensure},
202 reqwest::Response,
203 tempfile::TempDir,
204 tokio::runtime::Runtime,
205 };
206
207 use crate::{
208 api::{
209 content::{
210 cover::fetch_track_cover,
211 persistence::{
212 DOWNLOAD_RETRY_BASE_DELAY_MS, MAX_DOWNLOAD_RETRIES, attempt_download,
213 is_retryable_network_error, save_track_to_disk, write_response_to_file,
214 },
215 stream::{get_track_file_url, quality::MP3_320},
216 },
217 fixture::{MockServer, make_service, make_service_without_auth},
218 },
219 errors::QobuzApiError::{AuthenticationError, Canceled, DownloadError},
220 metadata::extractor::ComprehensiveMetadata,
221 };
222
223 fn mock_stream_response() -> Result<(TempDir, Runtime, Response)> {
233 let server = MockServer::start(200, "audio-bytes")?;
234 let service = make_service(&server.base_url())?;
235 let rt = Runtime::new()?;
236 let response = rt.block_on(service.http_client().get_with_auth(
237 &format!("{}/stream", service.base_url()),
238 "token",
239 None,
240 ))?;
241 let dir = TempDir::new()?;
242 Ok((dir, rt, response))
243 }
244
245 #[test]
251 fn retry_constants_are_sensible() -> Result<()> {
252 ensure!(MAX_DOWNLOAD_RETRIES > 0, "retries should be positive");
253 ensure!(
254 DOWNLOAD_RETRY_BASE_DELAY_MS > 0,
255 "base delay should be positive"
256 );
257 Ok(())
258 }
259
260 #[test]
266 fn non_network_error_is_not_retryable() -> Result<()> {
267 let err = Canceled;
268 ensure!(
269 !is_retryable_network_error(&err),
270 "canceled should not retry"
271 );
272 let err = DownloadError {
273 message: "boom".to_string(),
274 };
275 ensure!(
276 !is_retryable_network_error(&err),
277 "download should not retry"
278 );
279 Ok(())
280 }
281
282 #[test]
288 fn file_url_requires_auth() -> Result<()> {
289 let server = MockServer::start(200, "{}")?;
290 let service = make_service_without_auth(&server.base_url())?;
291 let rt = Runtime::new()?;
292 let result = rt.block_on(get_track_file_url(&service, 1, MP3_320));
293 ensure!(result.is_err(), "expected auth error");
294 let err = result.err().ok_or_else(|| anyhow!("expected error"))?;
295 ensure!(
296 matches!(err, AuthenticationError { .. }),
297 "expected AuthenticationError"
298 );
299 Ok(())
300 }
301
302 #[test]
308 fn write_response_to_file_saves_bytes() -> Result<()> {
309 let (dir, rt, response) = mock_stream_response()?;
310 let path = dir.path().join("out.bin");
311 rt.block_on(write_response_to_file(response, &path, false, None))?;
312 ensure!(path.exists(), "output file should exist");
313 Ok(())
314 }
315
316 #[test]
322 fn save_track_to_disk_creates_file() -> Result<()> {
323 let (dir, rt, response) = mock_stream_response()?;
324 let path = rt.block_on(save_track_to_disk(
325 response,
326 7,
327 dir.path(),
328 MP3_320,
329 false,
330 None,
331 ))?;
332 ensure!(path.exists(), "saved file should exist");
333 Ok(())
334 }
335
336 #[test]
342 fn fetch_cover_returns_none_without_url() -> Result<()> {
343 let server = MockServer::start(200, "{}")?;
344 let service = make_service(&server.base_url())?;
345 let meta = ComprehensiveMetadata::default();
346 let rt = Runtime::new()?;
347 let cover = rt.block_on(fetch_track_cover(&service, &meta, "token"));
348 ensure!(cover.is_none(), "expected no cover without URL");
349 Ok(())
350 }
351
352 #[test]
358 fn attempt_download_errors_without_url() -> Result<()> {
359 let body = r#"{"track_id":1}"#;
360 let server = MockServer::start(200, body)?;
361 let service = make_service(&server.base_url())?;
362 let dir = TempDir::new()?;
363 let path = dir.path().join("track.mp3");
364 let rt = Runtime::new()?;
365 let cancel: Option<&AtomicBool> = None;
366 let result = rt.block_on(attempt_download(&service, 1, MP3_320, &path, cancel));
367 ensure!(result.is_err(), "expected error without URL");
368 Ok(())
369 }
370}