Skip to main content

qobuz_api/api/content/
persistence.rs

1//! Download file I/O helpers: partial file detection, stream-to-disk writing, cover art fetch.
2
3use 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
28/// Maximum number of retry attempts on download network errors.
29pub const MAX_DOWNLOAD_RETRIES: u32 = 3;
30
31/// Base delay in milliseconds for download retry exponential backoff.
32pub const DOWNLOAD_RETRY_BASE_DELAY_MS: u64 = 2000;
33
34/// Detects whether a partial file exists on disk with non-zero size.
35///
36/// # Arguments
37///
38/// * `path` - File path to check
39///
40/// # Returns
41///
42/// `Some(size)` if the file exists and has content, `None` otherwise.
43#[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
52/// Writes a streaming HTTP response to a file, optionally appending.
53///
54/// # Arguments
55///
56/// * `response` - HTTP response containing the audio stream
57/// * `path` - Destination file path
58/// * `append` - If `true`, append to existing file; otherwise create/overwrite
59/// * `cancel` - Optional cancellation flag checked between chunks
60///
61/// # Returns
62///
63/// `Ok(())` on success.
64///
65/// # Errors
66///
67/// Returns a `QobuzApiError` on file I/O or stream read failures, or `Canceled` when the
68/// cancellation flag is set.
69pub 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
91/// Saves a streaming response to disk with formatted filename.
92///
93/// # Arguments
94///
95/// * `response` - HTTP response containing the audio stream
96/// * `track_id` - Track identifier (used in filename)
97/// * `output_dir` - Directory to save the file
98/// * `format_id` - Quality format ID (determines file extension)
99/// * `append` - If `true`, append to existing file (resume); otherwise create new
100/// * `cancel` - Optional cancellation flag checked during streaming
101///
102/// # Returns
103///
104/// The path to the saved file.
105///
106/// # Errors
107///
108/// Returns a `QobuzApiError` if directory creation, file I/O, or streaming fails.
109pub 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
125/// Performs a single download attempt, optionally resuming from a partial file.
126///
127/// # Arguments
128///
129/// * `service` - Authenticated API service
130/// * `track_id` - Track identifier
131/// * `format_id` - Quality format ID
132/// * `path` - Destination file path
133/// * `cancel` - Optional cancellation flag checked during streaming
134///
135/// # Returns
136///
137/// `Ok(true)` if the download resumed from a partial file, `Ok(false)` if fresh.
138///
139/// # Errors
140///
141/// Returns a `QobuzApiError` if the track file URL cannot be retrieved, the download stream
142/// fails, or writing to disk fails.
143pub 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/// Checks whether an error is a transient network failure worth retrying.
177///
178/// # Arguments
179///
180/// * `err` - The error to inspect
181///
182/// # Returns
183///
184/// `true` if the error is a connect timeout, body, or decode failure.
185#[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    /// Fetches a mock streaming response with its temp dir and runtime.
224    ///
225    /// # Returns
226    ///
227    /// Temp dir, runtime, and streaming response for file-write tests.
228    ///
229    /// # Errors
230    ///
231    /// Returns an error if the mock setup fails.
232    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    /// Tests retry constants have sensible values.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error if the assertion fails.
250    #[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    /// Tests non-network errors are not retryable.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if the assertion fails.
265    #[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    /// Tests file URL fetching fails without authentication.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the mock setup fails.
287    #[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    /// Tests stream writing saves bytes to disk.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the mock setup fails.
307    #[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    /// Tests track saving creates an extensioned file.
317    ///
318    /// # Errors
319    ///
320    /// Returns an error if the mock setup fails.
321    #[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    /// Tests cover fetching returns none without a URL.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error if the mock setup fails.
341    #[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    /// Tests download attempt fails without a file URL.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if the mock setup fails.
357    #[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}