Skip to main content

turbo_cdn/
progress.rs

1// Licensed under the MIT License
2// Copyright (c) 2025 Hal <hal.long@outlook.com>
3
4use indicatif::{ProgressBar, ProgressStyle};
5use serde::{Deserialize, Serialize};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8use tokio::sync::RwLock;
9use tracing::debug;
10
11/// Progress tracker for downloads
12#[derive(Debug)]
13pub struct ProgressTracker {
14    inner: Arc<RwLock<ProgressTrackerInner>>,
15}
16
17struct ProgressTrackerInner {
18    progress_bar: Option<ProgressBar>,
19    start_time: Instant,
20    total_size: u64,
21    downloaded_size: u64,
22    chunks: Vec<ChunkProgress>,
23    speed_samples: Vec<SpeedSample>,
24    callback: Option<Box<dyn Fn(ProgressInfo) + Send + Sync>>,
25}
26
27impl std::fmt::Debug for ProgressTrackerInner {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        f.debug_struct("ProgressTrackerInner")
30            .field("progress_bar", &self.progress_bar)
31            .field("start_time", &self.start_time)
32            .field("total_size", &self.total_size)
33            .field("downloaded_size", &self.downloaded_size)
34            .field("chunks", &self.chunks)
35            .field("speed_samples", &self.speed_samples)
36            .field("callback", &"<callback>")
37            .finish()
38    }
39}
40
41/// Progress information
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ProgressInfo {
44    /// Total file size in bytes
45    pub total_size: u64,
46
47    /// Downloaded size in bytes
48    pub downloaded_size: u64,
49
50    /// Download percentage (0.0 to 100.0)
51    pub percentage: f64,
52
53    /// Download speed in bytes per second
54    pub speed: f64,
55
56    /// Estimated time remaining
57    pub eta: Option<Duration>,
58
59    /// Elapsed time since download started
60    pub elapsed: Duration,
61
62    /// Number of active chunks
63    pub active_chunks: usize,
64
65    /// Whether the download is complete
66    pub complete: bool,
67}
68
69/// Progress information for a single chunk
70#[derive(Debug, Clone)]
71pub struct ChunkProgress {
72    pub chunk_id: usize,
73    pub start_byte: u64,
74    pub end_byte: u64,
75    pub downloaded: u64,
76    pub active: bool,
77}
78
79/// Speed sample for calculating average speed
80#[derive(Debug, Clone)]
81struct SpeedSample {
82    timestamp: Instant,
83    bytes_downloaded: u64,
84}
85
86/// Progress callback type
87pub type ProgressCallback = Box<dyn Fn(ProgressInfo) + Send + Sync>;
88
89impl ProgressTracker {
90    /// Create a new progress tracker
91    pub fn new(total_size: u64) -> Self {
92        let inner = ProgressTrackerInner {
93            progress_bar: None,
94            start_time: Instant::now(),
95            total_size,
96            downloaded_size: 0,
97            chunks: Vec::new(),
98            speed_samples: Vec::new(),
99            callback: None,
100        };
101
102        Self {
103            inner: Arc::new(RwLock::new(inner)),
104        }
105    }
106
107    /// Create a new progress tracker with visual progress bar
108    pub fn with_progress_bar(total_size: u64) -> Self {
109        let progress_bar = ProgressBar::new(total_size);
110        progress_bar.set_style(
111            ProgressStyle::default_bar()
112                .template("{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})")
113                .unwrap()
114                .progress_chars("#>-"),
115        );
116
117        let inner = ProgressTrackerInner {
118            progress_bar: Some(progress_bar),
119            start_time: Instant::now(),
120            total_size,
121            downloaded_size: 0,
122            chunks: Vec::new(),
123            speed_samples: Vec::new(),
124            callback: None,
125        };
126
127        Self {
128            inner: Arc::new(RwLock::new(inner)),
129        }
130    }
131
132    /// Set a progress callback
133    pub async fn set_callback<F>(&self, callback: F)
134    where
135        F: Fn(ProgressInfo) + Send + Sync + 'static,
136    {
137        let mut inner = self.inner.write().await;
138        inner.callback = Some(Box::new(callback));
139    }
140
141    /// Initialize chunks for parallel downloading
142    pub async fn init_chunks(&self, chunk_ranges: Vec<(u64, u64)>) {
143        let mut inner = self.inner.write().await;
144        inner.chunks = chunk_ranges
145            .into_iter()
146            .enumerate()
147            .map(|(id, (start, end))| ChunkProgress {
148                chunk_id: id,
149                start_byte: start,
150                end_byte: end,
151                downloaded: 0,
152                active: false,
153            })
154            .collect();
155    }
156
157    /// Update progress for a specific chunk
158    pub async fn update_chunk(&self, chunk_id: usize, bytes_downloaded: u64) {
159        let mut inner = self.inner.write().await;
160
161        if let Some(chunk) = inner.chunks.get_mut(chunk_id) {
162            let old_downloaded = chunk.downloaded;
163            chunk.downloaded = bytes_downloaded;
164            chunk.active = true;
165
166            // Update total downloaded size
167            let delta = bytes_downloaded.saturating_sub(old_downloaded);
168            inner.downloaded_size += delta;
169
170            // Update progress bar
171            if let Some(ref pb) = inner.progress_bar {
172                pb.set_position(inner.downloaded_size);
173            }
174
175            // Add speed sample
176            let bytes_downloaded = inner.downloaded_size;
177            inner.speed_samples.push(SpeedSample {
178                timestamp: Instant::now(),
179                bytes_downloaded,
180            });
181
182            // Keep only recent samples (last 10 seconds)
183            let cutoff = Instant::now() - Duration::from_secs(10);
184            inner
185                .speed_samples
186                .retain(|sample| sample.timestamp > cutoff);
187
188            // Trigger callback
189            if let Some(ref callback) = inner.callback {
190                let progress_info = Self::calculate_progress_info(&inner);
191                callback(progress_info);
192            }
193        }
194    }
195
196    /// Mark a chunk as complete
197    pub async fn complete_chunk(&self, chunk_id: usize) {
198        let mut inner = self.inner.write().await;
199
200        if let Some(chunk) = inner.chunks.get_mut(chunk_id) {
201            chunk.active = false;
202            debug!(
203                "Chunk {} completed: {}/{} bytes",
204                chunk_id,
205                chunk.downloaded,
206                chunk.end_byte - chunk.start_byte + 1
207            );
208        }
209    }
210
211    /// Update total progress (for single-threaded downloads)
212    pub async fn update(&self, bytes_downloaded: u64) {
213        let mut inner = self.inner.write().await;
214        inner.downloaded_size = bytes_downloaded;
215
216        // Update progress bar
217        if let Some(ref pb) = inner.progress_bar {
218            pb.set_position(bytes_downloaded);
219        }
220
221        // Add speed sample
222        inner.speed_samples.push(SpeedSample {
223            timestamp: Instant::now(),
224            bytes_downloaded,
225        });
226
227        // Keep only recent samples
228        let cutoff = Instant::now() - Duration::from_secs(10);
229        inner
230            .speed_samples
231            .retain(|sample| sample.timestamp > cutoff);
232
233        // Trigger callback
234        if let Some(ref callback) = inner.callback {
235            let progress_info = Self::calculate_progress_info(&inner);
236            callback(progress_info);
237        }
238    }
239
240    /// Mark download as complete
241    pub async fn complete(&self) {
242        let mut inner = self.inner.write().await;
243        inner.downloaded_size = inner.total_size;
244
245        if let Some(ref pb) = inner.progress_bar {
246            pb.finish_with_message("Download completed");
247        }
248
249        // Trigger final callback
250        if let Some(ref callback) = inner.callback {
251            let mut progress_info = Self::calculate_progress_info(&inner);
252            progress_info.complete = true;
253            callback(progress_info);
254        }
255    }
256
257    /// Get current progress information
258    pub async fn get_progress(&self) -> ProgressInfo {
259        let inner = self.inner.read().await;
260        Self::calculate_progress_info(&inner)
261    }
262
263    /// Abort the download and clean up
264    pub async fn abort(&self) {
265        let inner = self.inner.read().await;
266        if let Some(ref pb) = inner.progress_bar {
267            pb.abandon_with_message("Download aborted");
268        }
269    }
270
271    // Private helper methods
272
273    fn calculate_progress_info(inner: &ProgressTrackerInner) -> ProgressInfo {
274        let percentage = if inner.total_size > 0 {
275            (inner.downloaded_size as f64 / inner.total_size as f64) * 100.0
276        } else {
277            0.0
278        };
279
280        let elapsed = inner.start_time.elapsed();
281        let speed = Self::calculate_speed(&inner.speed_samples);
282        let eta = Self::calculate_eta(inner.total_size, inner.downloaded_size, speed);
283        let active_chunks = inner.chunks.iter().filter(|c| c.active).count();
284
285        ProgressInfo {
286            total_size: inner.total_size,
287            downloaded_size: inner.downloaded_size,
288            percentage,
289            speed,
290            eta,
291            elapsed,
292            active_chunks,
293            complete: false,
294        }
295    }
296
297    fn calculate_speed(samples: &[SpeedSample]) -> f64 {
298        if samples.len() < 2 {
299            return 0.0;
300        }
301
302        let first = &samples[0];
303        let last = &samples[samples.len() - 1];
304
305        let time_diff = last.timestamp.duration_since(first.timestamp).as_secs_f64();
306        let bytes_diff = last.bytes_downloaded.saturating_sub(first.bytes_downloaded);
307
308        if time_diff > 0.0 {
309            bytes_diff as f64 / time_diff
310        } else {
311            0.0
312        }
313    }
314
315    fn calculate_eta(total_size: u64, downloaded_size: u64, speed: f64) -> Option<Duration> {
316        if speed > 0.0 && downloaded_size < total_size {
317            let remaining_bytes = total_size - downloaded_size;
318            let eta_seconds = remaining_bytes as f64 / speed;
319            Some(Duration::from_secs_f64(eta_seconds))
320        } else {
321            None
322        }
323    }
324}
325
326impl ProgressInfo {
327    /// Get percentage as a value between 0.0 and 1.0
328    pub fn percentage_normalized(&self) -> f64 {
329        self.percentage / 100.0
330    }
331
332    /// Get speed in MB/s
333    pub fn speed_mbps(&self) -> f64 {
334        self.speed / 1_000_000.0
335    }
336
337    /// Get a human-readable speed string
338    pub fn speed_human(&self) -> String {
339        if self.speed >= 1_000_000_000.0 {
340            format!("{:.2} GB/s", self.speed / 1_000_000_000.0)
341        } else if self.speed >= 1_000_000.0 {
342            format!("{:.2} MB/s", self.speed / 1_000_000.0)
343        } else if self.speed >= 1_000.0 {
344            format!("{:.2} KB/s", self.speed / 1_000.0)
345        } else {
346            format!("{:.0} B/s", self.speed)
347        }
348    }
349
350    /// Get a human-readable ETA string
351    pub fn eta_human(&self) -> String {
352        match self.eta {
353            Some(eta) => {
354                let total_seconds = eta.as_secs();
355                let hours = total_seconds / 3600;
356                let minutes = (total_seconds % 3600) / 60;
357                let seconds = total_seconds % 60;
358
359                if hours > 0 {
360                    format!("{hours}h {minutes}m {seconds}s")
361                } else if minutes > 0 {
362                    format!("{minutes}m {seconds}s")
363                } else {
364                    format!("{seconds}s")
365                }
366            }
367            None => "Unknown".to_string(),
368        }
369    }
370
371    /// Get a human-readable size string
372    pub fn size_human(&self) -> String {
373        Self::format_bytes(self.downloaded_size, self.total_size)
374    }
375
376    fn format_bytes(downloaded: u64, total: u64) -> String {
377        let format_size = |size: u64| -> String {
378            if size >= 1_000_000_000 {
379                format!("{:.2} GB", size as f64 / 1_000_000_000.0)
380            } else if size >= 1_000_000 {
381                format!("{:.2} MB", size as f64 / 1_000_000.0)
382            } else if size >= 1_000 {
383                format!("{:.2} KB", size as f64 / 1_000.0)
384            } else {
385                format!("{size} B")
386            }
387        };
388
389        format!("{} / {}", format_size(downloaded), format_size(total))
390    }
391}
392
393/// Simple progress reporter that prints to console
394pub struct ConsoleProgressReporter;
395
396impl ConsoleProgressReporter {
397    pub fn default_callback() -> ProgressCallback {
398        Box::new(|progress: ProgressInfo| {
399            println!(
400                "Progress: {:.1}% ({}) - {} - ETA: {}",
401                progress.percentage,
402                progress.size_human(),
403                progress.speed_human(),
404                progress.eta_human()
405            );
406        })
407    }
408}
409
410impl Default for ConsoleProgressReporter {
411    fn default() -> Self {
412        let _tracker = Self::default_callback();
413        Self
414    }
415}