atlas_file_download/
lib.rs

1#![allow(clippy::arithmetic_side_effects)]
2use {
3    console::Emoji,
4    indicatif::{ProgressBar, ProgressStyle},
5    log::*,
6    std::{
7        fs::{self, File},
8        io::{self, Read},
9        path::Path,
10        time::{Duration, Instant},
11    },
12};
13
14static TRUCK: Emoji = Emoji("🚚 ", "");
15static SPARKLE: Emoji = Emoji("✨ ", "");
16
17/// Creates a new process bar for processing that will take an unknown amount of time
18fn new_spinner_progress_bar() -> ProgressBar {
19    let progress_bar = ProgressBar::new(42);
20    progress_bar.set_style(
21        ProgressStyle::default_spinner()
22            .template("{spinner:.green} {wide_msg}")
23            .expect("ProgresStyle::template direct input to be correct"),
24    );
25    progress_bar.enable_steady_tick(Duration::from_millis(100));
26    progress_bar
27}
28
29/// Structure modeling information about download progress
30#[derive(Debug)]
31pub struct DownloadProgressRecord {
32    // Duration since the beginning of the download
33    pub elapsed_time: Duration,
34    // Duration since the last notification
35    pub last_elapsed_time: Duration,
36    // the bytes/sec speed measured for the last notification period
37    pub last_throughput: f32,
38    // the bytes/sec speed measured from the beginning
39    pub total_throughput: f32,
40    // total bytes of the download
41    pub total_bytes: usize,
42    // bytes downloaded so far
43    pub current_bytes: usize,
44    // percentage downloaded
45    pub percentage_done: f32,
46    // Estimated remaining time (in seconds) to finish the download if it keeps at the last download speed
47    pub estimated_remaining_time: f32,
48    // The times of the progress is being notified, it starts from 1 and increments by 1 each time
49    pub notification_count: u64,
50}
51
52type DownloadProgressCallback<'a> = Box<dyn FnMut(&DownloadProgressRecord) -> bool + 'a>;
53pub type DownloadProgressCallbackOption<'a> = Option<DownloadProgressCallback<'a>>;
54
55/// This callback allows the caller to get notified of the download progress modelled by DownloadProgressRecord
56/// Return "true" to continue the download
57/// Return "false" to abort the download
58pub fn download_file<'a, 'b>(
59    url: &str,
60    destination_file: &Path,
61    use_progress_bar: bool,
62    progress_notify_callback: &'a mut DownloadProgressCallbackOption<'b>,
63) -> Result<(), String> {
64    if destination_file.is_file() {
65        return Err(format!("{destination_file:?} already exists"));
66    }
67    let download_start = Instant::now();
68
69    fs::create_dir_all(destination_file.parent().expect("parent"))
70        .map_err(|err| err.to_string())?;
71
72    let mut temp_destination_file = destination_file.to_path_buf();
73    temp_destination_file.set_file_name(format!(
74        "tmp-{}",
75        destination_file
76            .file_name()
77            .expect("file_name")
78            .to_str()
79            .expect("to_str")
80    ));
81
82    let progress_bar = new_spinner_progress_bar();
83    if use_progress_bar {
84        progress_bar.set_message(format!("{TRUCK}Downloading {url}..."));
85    }
86
87    let response = reqwest::blocking::Client::new()
88        .get(url)
89        .send()
90        .and_then(|response| response.error_for_status())
91        .map_err(|err| {
92            progress_bar.finish_and_clear();
93            err.to_string()
94        })?;
95
96    let download_size = {
97        response
98            .headers()
99            .get(reqwest::header::CONTENT_LENGTH)
100            .and_then(|content_length| content_length.to_str().ok())
101            .and_then(|content_length| content_length.parse().ok())
102            .unwrap_or(0)
103    };
104
105    if use_progress_bar {
106        progress_bar.set_length(download_size);
107        progress_bar.set_style(
108            ProgressStyle::default_bar()
109                .template(
110                    "{spinner:.green}{msg_wide}[{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})",
111                )
112                .expect("ProgresStyle::template direct input to be correct")
113                .progress_chars("=> "),
114        );
115        progress_bar.set_message(format!("{TRUCK}Downloading~ {url}"));
116    } else {
117        info!("Downloading {} bytes from {}", download_size, url);
118    }
119
120    struct DownloadProgress<'e, 'f, R> {
121        progress_bar: ProgressBar,
122        response: R,
123        last_print: Instant,
124        current_bytes: usize,
125        last_print_bytes: usize,
126        download_size: f32,
127        use_progress_bar: bool,
128        start_time: Instant,
129        callback: &'f mut DownloadProgressCallbackOption<'e>,
130        notification_count: u64,
131    }
132
133    impl<R: Read> Read for DownloadProgress<'_, '_, R> {
134        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
135            let n = self.response.read(buf)?;
136
137            self.current_bytes += n;
138            let total_bytes_f32 = self.current_bytes as f32;
139            let diff_bytes_f32 = (self.current_bytes - self.last_print_bytes) as f32;
140            let last_throughput = diff_bytes_f32 / self.last_print.elapsed().as_secs_f32();
141            let estimated_remaining_time = if last_throughput > 0_f32 {
142                (self.download_size - self.current_bytes as f32) / last_throughput
143            } else {
144                f32::MAX
145            };
146
147            let mut progress_record = DownloadProgressRecord {
148                elapsed_time: self.start_time.elapsed(),
149                last_elapsed_time: self.last_print.elapsed(),
150                last_throughput,
151                total_throughput: self.current_bytes as f32
152                    / self.start_time.elapsed().as_secs_f32(),
153                total_bytes: self.download_size as usize,
154                current_bytes: self.current_bytes,
155                percentage_done: 100f32 * (total_bytes_f32 / self.download_size),
156                estimated_remaining_time,
157                notification_count: self.notification_count,
158            };
159            let mut to_update_progress = false;
160            if progress_record.last_elapsed_time.as_secs() > 5 {
161                self.last_print = Instant::now();
162                self.last_print_bytes = self.current_bytes;
163                to_update_progress = true;
164                self.notification_count += 1;
165                progress_record.notification_count = self.notification_count
166            }
167
168            if self.use_progress_bar {
169                self.progress_bar.inc(n as u64);
170            } else if to_update_progress {
171                info!(
172                    "downloaded {} bytes {:.1}% {:.1} bytes/s",
173                    self.current_bytes,
174                    progress_record.percentage_done,
175                    progress_record.last_throughput,
176                );
177            }
178
179            if let Some(callback) = self.callback {
180                if to_update_progress && !callback(&progress_record) {
181                    info!("Download is aborted by the caller");
182                    return Err(io::Error::other("Download is aborted by the caller"));
183                }
184            }
185
186            Ok(n)
187        }
188    }
189
190    let mut source = DownloadProgress::<'b, 'a> {
191        progress_bar,
192        response,
193        last_print: Instant::now(),
194        current_bytes: 0,
195        last_print_bytes: 0,
196        download_size: (download_size as f32).max(1f32),
197        use_progress_bar,
198        start_time: Instant::now(),
199        callback: progress_notify_callback,
200        notification_count: 0,
201    };
202
203    File::create(&temp_destination_file)
204        .and_then(|mut file| std::io::copy(&mut source, &mut file))
205        .map_err(|err| format!("Unable to write {temp_destination_file:?}: {err:?}"))?;
206
207    source.progress_bar.finish_and_clear();
208    info!(
209        "  {}{}",
210        SPARKLE,
211        format!(
212            "Downloaded {} ({} bytes) in {:?}",
213            url,
214            download_size,
215            Instant::now().duration_since(download_start),
216        )
217    );
218
219    std::fs::rename(temp_destination_file, destination_file)
220        .map_err(|err| format!("Unable to rename: {err:?}"))?;
221
222    Ok(())
223}