Skip to main content

koan_core/remote/
download.rs

1//! Streaming file downloads: temp file, progress reporting, atomic rename, retries.
2//!
3//! Every remote byte koan writes to disk goes through here. `dest` only ever
4//! appears once the transfer completed, so a partially-written file can never
5//! be mistaken for a cached track.
6
7use std::io::{Read, Write};
8use std::path::Path;
9use std::time::Duration;
10
11use thiserror::Error;
12
13/// Longest the TCP connect + TLS handshake may take.
14const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15
16/// Longest a single body read may block before the transfer counts as stalled.
17///
18/// `reqwest`'s blocking client re-applies its request timeout to each `Read`
19/// of a streamed response, so this bounds *stalls*, not total transfer time —
20/// a large file on a slow link keeps going as long as bytes keep arriving.
21const STALL_TIMEOUT: Duration = Duration::from_secs(30);
22
23/// Total deadline for JSON API calls, whose bodies are small and read in one go.
24pub const API_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Attempts a download gets before giving up.
27pub const DEFAULT_ATTEMPTS: u32 = 3;
28
29/// Base backoff between attempts; doubles each retry.
30const BACKOFF_BASE: Duration = Duration::from_millis(500);
31
32#[derive(Debug, Error)]
33pub enum DownloadError {
34    #[error("http error: {0}")]
35    Http(#[from] reqwest::Error),
36    #[error("io error: {0}")]
37    Io(#[from] std::io::Error),
38    #[error("incomplete download: got {got} of {expected} bytes")]
39    Incomplete { got: u64, expected: u64 },
40    #[error("server returned {0}")]
41    Status(reqwest::StatusCode),
42    #[error("request could not be built: {0}")]
43    Request(String),
44}
45
46impl DownloadError {
47    /// Whether another attempt could plausibly succeed: transport-level
48    /// failures, truncated bodies, and server-side/rate-limit statuses.
49    pub fn is_retryable(&self) -> bool {
50        match self {
51            DownloadError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
52            DownloadError::Io(_) | DownloadError::Incomplete { .. } => true,
53            DownloadError::Status(s) => {
54                s.is_server_error() || *s == reqwest::StatusCode::TOO_MANY_REQUESTS
55            }
56            DownloadError::Request(_) => false,
57        }
58    }
59}
60
61/// HTTP client for streaming large bodies — bounded connect, bounded stalls,
62/// no total deadline on the transfer.
63pub fn download_client() -> reqwest::Result<reqwest::blocking::Client> {
64    reqwest::blocking::Client::builder()
65        .connect_timeout(CONNECT_TIMEOUT)
66        .timeout(STALL_TIMEOUT)
67        .build()
68}
69
70/// HTTP client for small JSON API calls, where a total request deadline is correct.
71pub fn api_client() -> reqwest::Result<reqwest::blocking::Client> {
72    reqwest::blocking::Client::builder()
73        .connect_timeout(CONNECT_TIMEOUT)
74        .timeout(API_TIMEOUT)
75        .build()
76}
77
78/// Download to `dest`, retrying transient failures with exponential backoff.
79///
80/// `request` is invoked once per attempt so per-request state (Subsonic auth
81/// salts, for one) is regenerated rather than replayed; a request that cannot
82/// be built is fatal, not retried. `on_progress` receives
83/// `(bytes_this_attempt, total)` where `total` is 0 if the server sent no
84/// Content-Length; it restarts from zero when an attempt is retried.
85///
86/// Returns the number of bytes written. `dest` is left untouched on failure.
87pub fn download_with_retries(
88    dest: &Path,
89    attempts: u32,
90    request: impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
91    on_progress: impl Fn(u64, u64),
92) -> Result<u64, DownloadError> {
93    let attempts = attempts.max(1);
94    let mut last_err = None;
95
96    for attempt in 0..attempts {
97        if attempt > 0 {
98            let backoff = BACKOFF_BASE * 2u32.pow(attempt - 1);
99            log::warn!(
100                "download of {} failed ({}), retrying in {:?} ({}/{})",
101                dest.display(),
102                last_err
103                    .as_ref()
104                    .map(|e: &DownloadError| e.to_string())
105                    .unwrap_or_default(),
106                backoff,
107                attempt + 1,
108                attempts
109            );
110            std::thread::sleep(backoff);
111        }
112
113        match attempt_download(dest, &request, &on_progress) {
114            Ok(bytes) => return Ok(bytes),
115            Err(e) if e.is_retryable() => last_err = Some(e),
116            Err(e) => return Err(e),
117        }
118    }
119
120    Err(last_err.expect("loop runs at least once and only continues on error"))
121}
122
123fn attempt_download(
124    dest: &Path,
125    request: &impl Fn() -> Result<reqwest::blocking::RequestBuilder, DownloadError>,
126    on_progress: &impl Fn(u64, u64),
127) -> Result<u64, DownloadError> {
128    let resp = request()?.send()?;
129    let status = resp.status();
130    // Subsonic reports failure with HTTP 200 and a JSON or XML error body, so a
131    // success status proves nothing on a binary endpoint. Without this, an error
132    // response gets written to disk and cached as if it were audio — it then
133    // reports Ready and fails to decode forever.
134    if resp
135        .headers()
136        .get(reqwest::header::CONTENT_TYPE)
137        .and_then(|v| v.to_str().ok())
138        .is_some_and(|ct| ct.contains("json") || ct.contains("xml"))
139    {
140        return Err(DownloadError::Request(
141            "server returned an error document where audio was expected".into(),
142        ));
143    }
144    if !status.is_success() {
145        return Err(DownloadError::Status(status));
146    }
147    stream_to_file(resp, dest, on_progress)
148}
149
150/// Stream a response body into `dest` via a `.part` sibling, renaming only once
151/// the transfer completes. A read error or a body shorter than the advertised
152/// Content-Length removes the temp file and errors.
153fn stream_to_file(
154    mut resp: reqwest::blocking::Response,
155    dest: &Path,
156    on_progress: &impl Fn(u64, u64),
157) -> Result<u64, DownloadError> {
158    let total = resp.content_length().unwrap_or(0);
159
160    if let Some(parent) = dest.parent() {
161        std::fs::create_dir_all(parent)?;
162    }
163
164    let tmp = part_path(dest);
165    let mut file = std::fs::File::create(&tmp)?;
166    let mut downloaded: u64 = 0;
167    let mut buf = [0u8; 64 * 1024];
168
169    let result = loop {
170        match resp.read(&mut buf) {
171            Ok(0) => break Ok(()),
172            Ok(n) => {
173                if let Err(e) = file.write_all(&buf[..n]) {
174                    break Err(DownloadError::Io(e));
175                }
176                downloaded += n as u64;
177                on_progress(downloaded, total);
178            }
179            Err(e) => break Err(DownloadError::Io(e)),
180        }
181    };
182
183    let flushed = file.flush();
184    drop(file);
185
186    let outcome = result
187        .and_then(|()| flushed.map_err(DownloadError::Io))
188        .and_then(|()| {
189            if total > 0 && downloaded != total {
190                Err(DownloadError::Incomplete {
191                    got: downloaded,
192                    expected: total,
193                })
194            } else {
195                Ok(())
196            }
197        });
198
199    if let Err(e) = outcome {
200        let _ = std::fs::remove_file(&tmp);
201        return Err(e);
202    }
203
204    std::fs::rename(&tmp, dest)?;
205    Ok(downloaded)
206}
207
208/// The in-progress sibling of `dest`. Appends `.part` rather than replacing the
209/// extension, so `Song.flac` and `Song.mp3` never collide on one temp file.
210pub fn part_path(dest: &Path) -> std::path::PathBuf {
211    let mut name = dest.file_name().unwrap_or_default().to_os_string();
212    name.push(".part");
213    dest.with_file_name(name)
214}
215
216/// Strip a `.part` suffix, yielding the final path a download will land at.
217/// Returns `path` unchanged when it isn't a temp file.
218pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
219    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
220        return path.to_path_buf();
221    };
222    match name.strip_suffix(".part") {
223        Some(stripped) => path.with_file_name(stripped),
224        None => path.to_path_buf(),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::io::BufRead;
232    use std::net::{TcpListener, TcpStream};
233    use std::sync::Arc;
234    use std::sync::atomic::{AtomicUsize, Ordering};
235
236    /// How a stub server answers one request.
237    #[derive(Clone)]
238    enum Reply {
239        /// Content-Length header, then that many bytes.
240        Complete(Vec<u8>),
241        /// Content-Length claims `claimed` bytes but only `body` is sent, then close.
242        Truncated {
243            claimed: usize,
244            body: Vec<u8>,
245        },
246        /// Chunked with no Content-Length, cut off mid-stream — what Navidrome
247        /// does for transcoded streams when the connection drops.
248        ChunkedTruncated(Vec<u8>),
249        ServerError,
250    }
251
252    /// Single-threaded stub HTTP server. Serves `replies` in order, repeating
253    /// the last one forever. Shuts down when the returned handle is dropped.
254    struct StubServer {
255        addr: std::net::SocketAddr,
256        hits: Arc<AtomicUsize>,
257        shutdown: Arc<std::sync::atomic::AtomicBool>,
258    }
259
260    impl StubServer {
261        fn start(replies: Vec<Reply>) -> Self {
262            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
263            listener.set_nonblocking(true).unwrap();
264            let addr = listener.local_addr().unwrap();
265            let hits = Arc::new(AtomicUsize::new(0));
266            let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
267
268            let hits_bg = hits.clone();
269            let shutdown_bg = shutdown.clone();
270            std::thread::spawn(move || {
271                while !shutdown_bg.load(Ordering::Relaxed) {
272                    match listener.accept() {
273                        Ok((stream, _)) => {
274                            // BSD sockets inherit O_NONBLOCK from the listener.
275                            let _ = stream.set_nonblocking(false);
276                            let n = hits_bg.fetch_add(1, Ordering::SeqCst);
277                            let reply = replies[n.min(replies.len() - 1)].clone();
278                            serve_one(stream, reply);
279                        }
280                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
281                            std::thread::sleep(Duration::from_millis(5));
282                        }
283                        Err(_) => break,
284                    }
285                }
286            });
287
288            Self {
289                addr,
290                hits,
291                shutdown,
292            }
293        }
294
295        fn url(&self) -> String {
296            format!("http://{}/file", self.addr)
297        }
298
299        fn hits(&self) -> usize {
300            self.hits.load(Ordering::SeqCst)
301        }
302    }
303
304    impl Drop for StubServer {
305        fn drop(&mut self) {
306            self.shutdown.store(true, Ordering::Relaxed);
307        }
308    }
309
310    fn serve_one(mut stream: TcpStream, reply: Reply) {
311        // Drain the request headers so the client isn't left writing into a
312        // closed socket before it can read the response.
313        let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
314        let mut line = String::new();
315        while reader.read_line(&mut line).unwrap_or(0) > 0 {
316            if line == "\r\n" || line == "\n" {
317                break;
318            }
319            line.clear();
320        }
321
322        match reply {
323            Reply::Complete(body) => {
324                let _ = write!(
325                    stream,
326                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
327                    body.len()
328                );
329                let _ = stream.write_all(&body);
330            }
331            Reply::Truncated { claimed, body } => {
332                let _ = write!(
333                    stream,
334                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
335                    claimed
336                );
337                let _ = stream.write_all(&body);
338            }
339            Reply::ChunkedTruncated(body) => {
340                let _ = write!(
341                    stream,
342                    "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
343                );
344                let _ = write!(stream, "{:x}\r\n", body.len());
345                let _ = stream.write_all(&body);
346                let _ = stream.write_all(b"\r\n");
347                // No terminating zero-length chunk — the stream just stops.
348            }
349            Reply::ServerError => {
350                let _ = write!(stream, "HTTP/1.1 500 Internal Server Error\r\n\r\n");
351            }
352        }
353        let _ = stream.flush();
354        let _ = stream.shutdown(std::net::Shutdown::Both);
355    }
356
357    fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
358        dir.path().join("nested").join("track.flac")
359    }
360
361    #[test]
362    fn complete_download_lands_at_dest() {
363        let body = vec![7u8; 200_000];
364        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
365        let dir = tempfile::tempdir().unwrap();
366        let dest = tmp_dest(&dir);
367        let client = download_client().unwrap();
368
369        let written =
370            download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {}).unwrap();
371
372        assert_eq!(written, body.len() as u64);
373        assert_eq!(std::fs::read(&dest).unwrap(), body);
374        assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
375    }
376
377    #[test]
378    fn truncated_body_errors_and_leaves_no_file() {
379        let server = StubServer::start(vec![Reply::Truncated {
380            claimed: 100_000,
381            body: vec![1u8; 4_096],
382        }]);
383        let dir = tempfile::tempdir().unwrap();
384        let dest = tmp_dest(&dir);
385        let client = download_client().unwrap();
386
387        let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
388            .expect_err("a short body must not succeed");
389
390        assert!(
391            matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
392            "unexpected error: {err}"
393        );
394        assert!(!dest.exists(), "dest must not hold a truncated file");
395        assert!(!part_path(&dest).exists(), "temp file must be removed");
396    }
397
398    #[test]
399    fn missing_content_length_truncation_errors_rather_than_completing() {
400        // No Content-Length at all — the only signal is the stream ending
401        // mid-message, which must not be read as a finished download.
402        let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
403        let dir = tempfile::tempdir().unwrap();
404        let dest = tmp_dest(&dir);
405        let client = download_client().unwrap();
406
407        let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
408            .expect_err("a cut-off chunked body must not succeed");
409
410        assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
411        assert!(!dest.exists(), "dest must not hold a truncated file");
412        assert!(!part_path(&dest).exists(), "temp file must be removed");
413    }
414
415    #[test]
416    fn retries_transient_failure_then_succeeds() {
417        let body = vec![3u8; 50_000];
418        let server = StubServer::start(vec![
419            Reply::ServerError,
420            Reply::Truncated {
421                claimed: 50_000,
422                body: vec![3u8; 10],
423            },
424            Reply::Complete(body.clone()),
425        ]);
426        let dir = tempfile::tempdir().unwrap();
427        let dest = tmp_dest(&dir);
428        let client = download_client().unwrap();
429
430        let written =
431            download_with_retries(&dest, 3, || Ok(client.get(server.url())), |_, _| {}).unwrap();
432
433        assert_eq!(written, body.len() as u64);
434        assert_eq!(server.hits(), 3, "should have used all three attempts");
435        assert_eq!(std::fs::read(&dest).unwrap(), body);
436    }
437
438    #[test]
439    fn progress_reports_total_when_content_length_present() {
440        let body = vec![0u8; 300_000];
441        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
442        let dir = tempfile::tempdir().unwrap();
443        let dest = tmp_dest(&dir);
444        let client = download_client().unwrap();
445
446        let seen = std::sync::Mutex::new(Vec::new());
447        download_with_retries(
448            &dest,
449            1,
450            || Ok(client.get(server.url())),
451            |d, t| {
452                seen.lock().unwrap().push((d, t));
453            },
454        )
455        .unwrap();
456
457        let seen = seen.into_inner().unwrap();
458        assert!(!seen.is_empty(), "progress should be reported");
459        assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
460        assert_eq!(seen.last().unwrap().0, body.len() as u64);
461    }
462
463    #[test]
464    fn part_path_appends_rather_than_replacing_extension() {
465        let flac = part_path(Path::new("/tmp/Song.flac"));
466        let mp3 = part_path(Path::new("/tmp/Song.mp3"));
467        assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
468        assert_ne!(flac, mp3, "different codecs must not share a temp file");
469    }
470
471    #[test]
472    fn strip_part_suffix_round_trips() {
473        let dest = Path::new("/tmp/a/Song.flac");
474        assert_eq!(strip_part_suffix(&part_path(dest)), dest);
475        assert_eq!(strip_part_suffix(dest), dest);
476    }
477}