1use std::io::{Read, Write};
8use std::path::Path;
9use std::time::Duration;
10
11use thiserror::Error;
12
13const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
15
16const STALL_TIMEOUT: Duration = Duration::from_secs(30);
22
23pub const API_TIMEOUT: Duration = Duration::from_secs(30);
25
26pub const DEFAULT_ATTEMPTS: u32 = 3;
28
29const 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 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
61pub 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
70pub 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
78pub 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 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
150fn 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
208pub 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
216pub 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 #[derive(Clone)]
238 enum Reply {
239 Complete(Vec<u8>),
241 Truncated {
243 claimed: usize,
244 body: Vec<u8>,
245 },
246 ChunkedTruncated(Vec<u8>),
249 ServerError,
250 }
251
252 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 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 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 }
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 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}