Skip to main content

youtube_uploader/
progress.rs

1use std::io::IsTerminal as _;
2
3use crate::{UploadError, UploadResult};
4
5/// Trait for receiving upload progress updates.
6///
7/// Implement this to show custom progress indicators during uploads.
8///
9/// # Examples
10///
11/// ```
12/// use youtube_uploader::{ProgressListener, UploadError, UploadResult};
13///
14/// struct MyProgress;
15///
16/// impl ProgressListener for MyProgress {
17///     fn on_progress(&self, uploaded: u64, total: u64) {
18///         eprintln!("  {:.1}% ({}/{})", uploaded as f64 / total as f64 * 100.0, uploaded, total);
19///     }
20///     fn on_complete(&self, result: &UploadResult) {
21///         eprintln!("Done: {}", result.url);
22///     }
23///     fn on_error(&self, error: &UploadError) {
24///         eprintln!("Error: {error}");
25///     }
26/// }
27/// ```
28pub trait ProgressListener: Send + Sync {
29    /// Called periodically with bytes uploaded and total file size.
30    fn on_progress(&self, uploaded: u64, total: u64);
31
32    /// Called when upload completes successfully.
33    fn on_complete(&self, result: &UploadResult);
34
35    /// Called when upload fails.
36    fn on_error(&self, error: &UploadError);
37}
38
39/// A no-op progress listener for when you don't care about progress.
40///
41/// Useful for background/batch uploads where no output is desired.
42///
43/// ```
44/// use youtube_uploader::NoopProgressListener;
45/// use youtube_uploader::ProgressListener;
46///
47/// let listener = NoopProgressListener;
48/// listener.on_progress(50, 100); // does nothing
49/// ```
50pub struct NoopProgressListener;
51
52impl ProgressListener for NoopProgressListener {
53    fn on_progress(&self, _uploaded: u64, _total: u64) {}
54    fn on_complete(&self, _result: &UploadResult) {}
55    fn on_error(&self, _error: &UploadError) {}
56}
57
58/// A progress listener that prints to stderr.
59/// Uses carriage return (`\r`) when attached to a TTY for inline progress,
60/// falls back to full-line output when output is piped/redirected.
61/// Shows upload speed and ETA when progress is reported multiple times.
62pub struct StderrProgressListener {
63    start: std::time::Instant,
64}
65
66impl Default for StderrProgressListener {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl StderrProgressListener {
73    /// Create a new stderr progress listener.
74    pub fn new() -> Self {
75        Self {
76            start: std::time::Instant::now(),
77        }
78    }
79}
80
81impl ProgressListener for StderrProgressListener {
82    fn on_progress(&self, uploaded: u64, total: u64) {
83        if total > 0 {
84            let pct = (uploaded as f64 / total as f64) * 100.0;
85            let elapsed = self.start.elapsed().as_secs_f64();
86
87            // Calculate speed and ETA
88            let speed_str = if elapsed > 0.0 && uploaded > 0 {
89                let speed = uploaded as f64 / elapsed;
90                format_speed(speed)
91            } else {
92                "--".to_string()
93            };
94
95            let eta_str = if uploaded > 0 && uploaded < total && elapsed > 0.0 {
96                let speed = uploaded as f64 / elapsed;
97                let remaining_bytes = total - uploaded;
98                let eta_secs = remaining_bytes as f64 / speed;
99                format_duration(eta_secs)
100            } else {
101                "--".to_string()
102            };
103
104            if std::io::stderr().is_terminal() {
105                eprint!(
106                    "\r  {:>6.2}% {}/s  ETA {} ({}/{})",
107                    pct, speed_str, eta_str, uploaded, total
108                );
109            } else {
110                eprintln!(
111                    "  {:>6.2}% {}/s  ETA {} ({}/{})",
112                    pct, speed_str, eta_str, uploaded, total
113                );
114            }
115        }
116    }
117
118    fn on_complete(&self, result: &UploadResult) {
119        let elapsed = self.start.elapsed();
120        if std::io::stderr().is_terminal() {
121            eprintln!(
122                "\n  {} uploaded to {}: {}",
123                format_duration(elapsed.as_secs_f64()),
124                result.workspace,
125                result.url
126            );
127        } else {
128            eprintln!(
129                "[complete] {}: {} ({})",
130                result.workspace,
131                result.url,
132                format_duration(elapsed.as_secs_f64())
133            );
134        }
135    }
136
137    fn on_error(&self, error: &UploadError) {
138        eprintln!("  Upload failed: {}", error);
139    }
140}
141
142/// Format bytes/second as a human-readable string.
143fn format_speed(bytes_per_sec: f64) -> String {
144    if bytes_per_sec >= 1_000_000_000.0 {
145        format!("{:.1} GB", bytes_per_sec / 1_000_000_000.0)
146    } else if bytes_per_sec >= 1_000_000.0 {
147        format!("{:.1} MB", bytes_per_sec / 1_000_000.0)
148    } else if bytes_per_sec >= 1_000.0 {
149        format!("{:.0} KB", bytes_per_sec / 1_000.0)
150    } else {
151        format!("{:.0} B", bytes_per_sec)
152    }
153}
154
155/// Format seconds as a human-readable duration.
156fn format_duration(secs: f64) -> String {
157    if secs.is_nan() || secs.is_infinite() || secs < 0.0 {
158        return "--".to_string();
159    }
160    let total_secs = secs as u64;
161    if total_secs < 60 {
162        format!("{}s", total_secs)
163    } else if total_secs < 3600 {
164        format!("{}m {}s", total_secs / 60, total_secs % 60)
165    } else {
166        format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
167    }
168}