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    if !status.is_success() {
131        return Err(DownloadError::Status(status));
132    }
133    stream_to_file(resp, dest, on_progress)
134}
135
136/// Stream a response body into `dest` via a `.part` sibling, renaming only once
137/// the transfer completes. A read error or a body shorter than the advertised
138/// Content-Length removes the temp file and errors.
139fn stream_to_file(
140    mut resp: reqwest::blocking::Response,
141    dest: &Path,
142    on_progress: &impl Fn(u64, u64),
143) -> Result<u64, DownloadError> {
144    let total = resp.content_length().unwrap_or(0);
145
146    if let Some(parent) = dest.parent() {
147        std::fs::create_dir_all(parent)?;
148    }
149
150    let tmp = part_path(dest);
151    let mut file = std::fs::File::create(&tmp)?;
152    let mut downloaded: u64 = 0;
153    let mut buf = [0u8; 64 * 1024];
154
155    let result = loop {
156        match resp.read(&mut buf) {
157            Ok(0) => break Ok(()),
158            Ok(n) => {
159                if let Err(e) = file.write_all(&buf[..n]) {
160                    break Err(DownloadError::Io(e));
161                }
162                downloaded += n as u64;
163                on_progress(downloaded, total);
164            }
165            Err(e) => break Err(DownloadError::Io(e)),
166        }
167    };
168
169    let flushed = file.flush();
170    drop(file);
171
172    let outcome = result
173        .and_then(|()| flushed.map_err(DownloadError::Io))
174        .and_then(|()| {
175            if total > 0 && downloaded != total {
176                Err(DownloadError::Incomplete {
177                    got: downloaded,
178                    expected: total,
179                })
180            } else {
181                Ok(())
182            }
183        });
184
185    if let Err(e) = outcome {
186        let _ = std::fs::remove_file(&tmp);
187        return Err(e);
188    }
189
190    std::fs::rename(&tmp, dest)?;
191    Ok(downloaded)
192}
193
194/// The in-progress sibling of `dest`. Appends `.part` rather than replacing the
195/// extension, so `Song.flac` and `Song.mp3` never collide on one temp file.
196pub fn part_path(dest: &Path) -> std::path::PathBuf {
197    let mut name = dest.file_name().unwrap_or_default().to_os_string();
198    name.push(".part");
199    dest.with_file_name(name)
200}
201
202/// Strip a `.part` suffix, yielding the final path a download will land at.
203/// Returns `path` unchanged when it isn't a temp file.
204pub fn strip_part_suffix(path: &Path) -> std::path::PathBuf {
205    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
206        return path.to_path_buf();
207    };
208    match name.strip_suffix(".part") {
209        Some(stripped) => path.with_file_name(stripped),
210        None => path.to_path_buf(),
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::io::BufRead;
218    use std::net::{TcpListener, TcpStream};
219    use std::sync::Arc;
220    use std::sync::atomic::{AtomicUsize, Ordering};
221
222    /// How a stub server answers one request.
223    #[derive(Clone)]
224    enum Reply {
225        /// Content-Length header, then that many bytes.
226        Complete(Vec<u8>),
227        /// Content-Length claims `claimed` bytes but only `body` is sent, then close.
228        Truncated {
229            claimed: usize,
230            body: Vec<u8>,
231        },
232        /// Chunked with no Content-Length, cut off mid-stream — what Navidrome
233        /// does for transcoded streams when the connection drops.
234        ChunkedTruncated(Vec<u8>),
235        ServerError,
236    }
237
238    /// Single-threaded stub HTTP server. Serves `replies` in order, repeating
239    /// the last one forever. Shuts down when the returned handle is dropped.
240    struct StubServer {
241        addr: std::net::SocketAddr,
242        hits: Arc<AtomicUsize>,
243        shutdown: Arc<std::sync::atomic::AtomicBool>,
244    }
245
246    impl StubServer {
247        fn start(replies: Vec<Reply>) -> Self {
248            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
249            listener.set_nonblocking(true).unwrap();
250            let addr = listener.local_addr().unwrap();
251            let hits = Arc::new(AtomicUsize::new(0));
252            let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
253
254            let hits_bg = hits.clone();
255            let shutdown_bg = shutdown.clone();
256            std::thread::spawn(move || {
257                while !shutdown_bg.load(Ordering::Relaxed) {
258                    match listener.accept() {
259                        Ok((stream, _)) => {
260                            // BSD sockets inherit O_NONBLOCK from the listener.
261                            let _ = stream.set_nonblocking(false);
262                            let n = hits_bg.fetch_add(1, Ordering::SeqCst);
263                            let reply = replies[n.min(replies.len() - 1)].clone();
264                            serve_one(stream, reply);
265                        }
266                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
267                            std::thread::sleep(Duration::from_millis(5));
268                        }
269                        Err(_) => break,
270                    }
271                }
272            });
273
274            Self {
275                addr,
276                hits,
277                shutdown,
278            }
279        }
280
281        fn url(&self) -> String {
282            format!("http://{}/file", self.addr)
283        }
284
285        fn hits(&self) -> usize {
286            self.hits.load(Ordering::SeqCst)
287        }
288    }
289
290    impl Drop for StubServer {
291        fn drop(&mut self) {
292            self.shutdown.store(true, Ordering::Relaxed);
293        }
294    }
295
296    fn serve_one(mut stream: TcpStream, reply: Reply) {
297        // Drain the request headers so the client isn't left writing into a
298        // closed socket before it can read the response.
299        let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
300        let mut line = String::new();
301        while reader.read_line(&mut line).unwrap_or(0) > 0 {
302            if line == "\r\n" || line == "\n" {
303                break;
304            }
305            line.clear();
306        }
307
308        match reply {
309            Reply::Complete(body) => {
310                let _ = write!(
311                    stream,
312                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
313                    body.len()
314                );
315                let _ = stream.write_all(&body);
316            }
317            Reply::Truncated { claimed, body } => {
318                let _ = write!(
319                    stream,
320                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n",
321                    claimed
322                );
323                let _ = stream.write_all(&body);
324            }
325            Reply::ChunkedTruncated(body) => {
326                let _ = write!(
327                    stream,
328                    "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
329                );
330                let _ = write!(stream, "{:x}\r\n", body.len());
331                let _ = stream.write_all(&body);
332                let _ = stream.write_all(b"\r\n");
333                // No terminating zero-length chunk — the stream just stops.
334            }
335            Reply::ServerError => {
336                let _ = write!(stream, "HTTP/1.1 500 Internal Server Error\r\n\r\n");
337            }
338        }
339        let _ = stream.flush();
340        let _ = stream.shutdown(std::net::Shutdown::Both);
341    }
342
343    fn tmp_dest(dir: &tempfile::TempDir) -> std::path::PathBuf {
344        dir.path().join("nested").join("track.flac")
345    }
346
347    #[test]
348    fn complete_download_lands_at_dest() {
349        let body = vec![7u8; 200_000];
350        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
351        let dir = tempfile::tempdir().unwrap();
352        let dest = tmp_dest(&dir);
353        let client = download_client().unwrap();
354
355        let written =
356            download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {}).unwrap();
357
358        assert_eq!(written, body.len() as u64);
359        assert_eq!(std::fs::read(&dest).unwrap(), body);
360        assert!(!part_path(&dest).exists(), "temp file should be cleaned up");
361    }
362
363    #[test]
364    fn truncated_body_errors_and_leaves_no_file() {
365        let server = StubServer::start(vec![Reply::Truncated {
366            claimed: 100_000,
367            body: vec![1u8; 4_096],
368        }]);
369        let dir = tempfile::tempdir().unwrap();
370        let dest = tmp_dest(&dir);
371        let client = download_client().unwrap();
372
373        let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
374            .expect_err("a short body must not succeed");
375
376        assert!(
377            matches!(err, DownloadError::Incomplete { .. } | DownloadError::Io(_)),
378            "unexpected error: {err}"
379        );
380        assert!(!dest.exists(), "dest must not hold a truncated file");
381        assert!(!part_path(&dest).exists(), "temp file must be removed");
382    }
383
384    #[test]
385    fn missing_content_length_truncation_errors_rather_than_completing() {
386        // No Content-Length at all — the only signal is the stream ending
387        // mid-message, which must not be read as a finished download.
388        let server = StubServer::start(vec![Reply::ChunkedTruncated(vec![9u8; 8_192])]);
389        let dir = tempfile::tempdir().unwrap();
390        let dest = tmp_dest(&dir);
391        let client = download_client().unwrap();
392
393        let err = download_with_retries(&dest, 1, || Ok(client.get(server.url())), |_, _| {})
394            .expect_err("a cut-off chunked body must not succeed");
395
396        assert!(matches!(err, DownloadError::Io(_)), "unexpected: {err}");
397        assert!(!dest.exists(), "dest must not hold a truncated file");
398        assert!(!part_path(&dest).exists(), "temp file must be removed");
399    }
400
401    #[test]
402    fn retries_transient_failure_then_succeeds() {
403        let body = vec![3u8; 50_000];
404        let server = StubServer::start(vec![
405            Reply::ServerError,
406            Reply::Truncated {
407                claimed: 50_000,
408                body: vec![3u8; 10],
409            },
410            Reply::Complete(body.clone()),
411        ]);
412        let dir = tempfile::tempdir().unwrap();
413        let dest = tmp_dest(&dir);
414        let client = download_client().unwrap();
415
416        let written =
417            download_with_retries(&dest, 3, || Ok(client.get(server.url())), |_, _| {}).unwrap();
418
419        assert_eq!(written, body.len() as u64);
420        assert_eq!(server.hits(), 3, "should have used all three attempts");
421        assert_eq!(std::fs::read(&dest).unwrap(), body);
422    }
423
424    #[test]
425    fn progress_reports_total_when_content_length_present() {
426        let body = vec![0u8; 300_000];
427        let server = StubServer::start(vec![Reply::Complete(body.clone())]);
428        let dir = tempfile::tempdir().unwrap();
429        let dest = tmp_dest(&dir);
430        let client = download_client().unwrap();
431
432        let seen = std::sync::Mutex::new(Vec::new());
433        download_with_retries(
434            &dest,
435            1,
436            || Ok(client.get(server.url())),
437            |d, t| {
438                seen.lock().unwrap().push((d, t));
439            },
440        )
441        .unwrap();
442
443        let seen = seen.into_inner().unwrap();
444        assert!(!seen.is_empty(), "progress should be reported");
445        assert!(seen.iter().all(|(_, t)| *t == body.len() as u64));
446        assert_eq!(seen.last().unwrap().0, body.len() as u64);
447    }
448
449    #[test]
450    fn part_path_appends_rather_than_replacing_extension() {
451        let flac = part_path(Path::new("/tmp/Song.flac"));
452        let mp3 = part_path(Path::new("/tmp/Song.mp3"));
453        assert_eq!(flac, Path::new("/tmp/Song.flac.part"));
454        assert_ne!(flac, mp3, "different codecs must not share a temp file");
455    }
456
457    #[test]
458    fn strip_part_suffix_round_trips() {
459        let dest = Path::new("/tmp/a/Song.flac");
460        assert_eq!(strip_part_suffix(&part_path(dest)), dest);
461        assert_eq!(strip_part_suffix(dest), dest);
462    }
463}