audiobook_forge/core/
progress.rs

1//! Progress tracking for batch processing
2
3use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
4use std::sync::Arc;
5use std::time::Instant;
6
7/// Stage of book processing
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ProcessingStage {
10    /// Scanning directories
11    Scanning,
12    /// Analyzing tracks
13    Analyzing,
14    /// Processing audio
15    Processing,
16    /// Injecting chapters
17    Chapters,
18    /// Injecting metadata
19    Metadata,
20    /// Complete
21    Complete,
22}
23
24impl ProcessingStage {
25    /// Get human-readable name
26    pub fn name(&self) -> &'static str {
27        match self {
28            Self::Scanning => "Scanning",
29            Self::Analyzing => "Analyzing",
30            Self::Processing => "Processing",
31            Self::Chapters => "Chapters",
32            Self::Metadata => "Metadata",
33            Self::Complete => "Complete",
34        }
35    }
36}
37
38/// Progress information for a single book
39#[derive(Debug, Clone)]
40pub struct BookProgress {
41    /// Book name
42    pub name: String,
43    /// Current processing stage
44    pub stage: ProcessingStage,
45    /// Progress percentage (0-100)
46    pub progress: f32,
47    /// Start time
48    pub start_time: Instant,
49    /// Estimated time remaining in seconds (None if unknown)
50    pub eta_seconds: Option<f64>,
51}
52
53impl BookProgress {
54    /// Create a new book progress tracker
55    pub fn new(name: String) -> Self {
56        Self {
57            name,
58            stage: ProcessingStage::Scanning,
59            progress: 0.0,
60            start_time: Instant::now(),
61            eta_seconds: None,
62        }
63    }
64
65    /// Update stage
66    pub fn set_stage(&mut self, stage: ProcessingStage) {
67        self.stage = stage;
68    }
69
70    /// Update progress
71    pub fn set_progress(&mut self, progress: f32) {
72        self.progress = progress.clamp(0.0, 100.0);
73    }
74
75    /// Calculate ETA based on current progress
76    pub fn update_eta(&mut self) {
77        if self.progress > 0.0 {
78            let elapsed = self.start_time.elapsed().as_secs_f64();
79            let total_estimated = elapsed / (self.progress as f64 / 100.0);
80            self.eta_seconds = Some(total_estimated - elapsed);
81        }
82    }
83
84    /// Get elapsed time in seconds
85    pub fn elapsed_seconds(&self) -> f64 {
86        self.start_time.elapsed().as_secs_f64()
87    }
88}
89
90/// Batch progress tracker
91#[derive(Debug, Clone)]
92pub struct BatchProgress {
93    /// Total number of books
94    total_books: usize,
95    /// Number of completed books
96    completed: Arc<AtomicUsize>,
97    /// Number of failed books
98    failed: Arc<AtomicUsize>,
99    /// Total bytes processed
100    bytes_processed: Arc<AtomicU64>,
101    /// Start time
102    start_time: Instant,
103}
104
105impl BatchProgress {
106    /// Create a new batch progress tracker
107    pub fn new(total_books: usize) -> Self {
108        Self {
109            total_books,
110            completed: Arc::new(AtomicUsize::new(0)),
111            failed: Arc::new(AtomicUsize::new(0)),
112            bytes_processed: Arc::new(AtomicU64::new(0)),
113            start_time: Instant::now(),
114        }
115    }
116
117    /// Mark a book as completed
118    pub fn mark_completed(&self) {
119        self.completed.fetch_add(1, Ordering::Relaxed);
120    }
121
122    /// Mark a book as failed
123    pub fn mark_failed(&self) {
124        self.failed.fetch_add(1, Ordering::Relaxed);
125    }
126
127    /// Add processed bytes
128    pub fn add_bytes(&self, bytes: u64) {
129        self.bytes_processed.fetch_add(bytes, Ordering::Relaxed);
130    }
131
132    /// Get number of completed books
133    pub fn completed_count(&self) -> usize {
134        self.completed.load(Ordering::Relaxed)
135    }
136
137    /// Get number of failed books
138    pub fn failed_count(&self) -> usize {
139        self.failed.load(Ordering::Relaxed)
140    }
141
142    /// Get total processed bytes
143    pub fn total_bytes(&self) -> u64 {
144        self.bytes_processed.load(Ordering::Relaxed)
145    }
146
147    /// Get total books
148    pub fn total_books(&self) -> usize {
149        self.total_books
150    }
151
152    /// Get overall progress percentage (0-100)
153    pub fn overall_progress(&self) -> f32 {
154        if self.total_books == 0 {
155            return 0.0;
156        }
157
158        let processed = self.completed_count() + self.failed_count();
159        (processed as f32 / self.total_books as f32) * 100.0
160    }
161
162    /// Calculate ETA for remaining books
163    pub fn eta_seconds(&self) -> Option<f64> {
164        let completed = self.completed_count();
165        if completed == 0 {
166            return None;
167        }
168
169        let elapsed = self.start_time.elapsed().as_secs_f64();
170        let avg_time_per_book = elapsed / completed as f64;
171        let remaining_books = self.total_books - completed - self.failed_count();
172
173        if remaining_books > 0 {
174            Some(avg_time_per_book * remaining_books as f64)
175        } else {
176            Some(0.0)
177        }
178    }
179
180    /// Get elapsed time in seconds
181    pub fn elapsed_seconds(&self) -> f64 {
182        self.start_time.elapsed().as_secs_f64()
183    }
184
185    /// Format ETA as human-readable string
186    pub fn format_eta(&self) -> String {
187        match self.eta_seconds() {
188            Some(seconds) if seconds > 0.0 => {
189                let hours = (seconds / 3600.0) as u64;
190                let minutes = ((seconds % 3600.0) / 60.0) as u64;
191                let secs = (seconds % 60.0) as u64;
192
193                if hours > 0 {
194                    format!("{}h {:02}m {:02}s", hours, minutes, secs)
195                } else if minutes > 0 {
196                    format!("{}m {:02}s", minutes, secs)
197                } else {
198                    format!("{}s", secs)
199                }
200            }
201            _ => "calculating...".to_string(),
202        }
203    }
204
205    /// Format elapsed time as human-readable string
206    pub fn format_elapsed(&self) -> String {
207        let seconds = self.elapsed_seconds();
208        let hours = (seconds / 3600.0) as u64;
209        let minutes = ((seconds % 3600.0) / 60.0) as u64;
210        let secs = (seconds % 60.0) as u64;
211
212        if hours > 0 {
213            format!("{}h {:02}m {:02}s", hours, minutes, secs)
214        } else if minutes > 0 {
215            format!("{}m {:02}s", minutes, secs)
216        } else {
217            format!("{}s", secs)
218        }
219    }
220
221    /// Check if batch is complete
222    pub fn is_complete(&self) -> bool {
223        self.completed_count() + self.failed_count() >= self.total_books
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::thread;
231    use std::time::Duration;
232
233    #[test]
234    fn test_processing_stage_name() {
235        assert_eq!(ProcessingStage::Scanning.name(), "Scanning");
236        assert_eq!(ProcessingStage::Analyzing.name(), "Analyzing");
237        assert_eq!(ProcessingStage::Complete.name(), "Complete");
238    }
239
240    #[test]
241    fn test_book_progress() {
242        let mut progress = BookProgress::new("Test Book".to_string());
243        assert_eq!(progress.name, "Test Book");
244        assert_eq!(progress.stage, ProcessingStage::Scanning);
245        assert_eq!(progress.progress, 0.0);
246
247        progress.set_stage(ProcessingStage::Processing);
248        assert_eq!(progress.stage, ProcessingStage::Processing);
249
250        progress.set_progress(50.0);
251        assert_eq!(progress.progress, 50.0);
252
253        // Test clamping
254        progress.set_progress(150.0);
255        assert_eq!(progress.progress, 100.0);
256    }
257
258    #[test]
259    fn test_batch_progress() {
260        let progress = BatchProgress::new(10);
261        assert_eq!(progress.total_books(), 10);
262        assert_eq!(progress.completed_count(), 0);
263        assert_eq!(progress.failed_count(), 0);
264        assert_eq!(progress.overall_progress(), 0.0);
265
266        progress.mark_completed();
267        assert_eq!(progress.completed_count(), 1);
268        assert_eq!(progress.overall_progress(), 10.0);
269
270        progress.mark_failed();
271        assert_eq!(progress.failed_count(), 1);
272        assert_eq!(progress.overall_progress(), 20.0);
273    }
274
275    #[test]
276    fn test_batch_progress_bytes() {
277        let progress = BatchProgress::new(5);
278        assert_eq!(progress.total_bytes(), 0);
279
280        progress.add_bytes(1024);
281        assert_eq!(progress.total_bytes(), 1024);
282
283        progress.add_bytes(2048);
284        assert_eq!(progress.total_bytes(), 3072);
285    }
286
287    #[test]
288    fn test_batch_progress_eta() {
289        let progress = BatchProgress::new(10);
290
291        // No ETA with 0 completed
292        assert!(progress.eta_seconds().is_none());
293
294        // Sleep a bit to get some elapsed time
295        thread::sleep(Duration::from_millis(100));
296
297        // Mark one complete
298        progress.mark_completed();
299
300        // Should have an ETA now
301        assert!(progress.eta_seconds().is_some());
302    }
303
304    #[test]
305    fn test_batch_progress_is_complete() {
306        let progress = BatchProgress::new(3);
307        assert!(!progress.is_complete());
308
309        progress.mark_completed();
310        progress.mark_completed();
311        assert!(!progress.is_complete());
312
313        progress.mark_completed();
314        assert!(progress.is_complete());
315    }
316
317    #[test]
318    fn test_format_eta() {
319        let progress = BatchProgress::new(1);
320        let eta = progress.format_eta();
321        assert_eq!(eta, "calculating...");
322    }
323
324    #[test]
325    fn test_format_elapsed() {
326        let progress = BatchProgress::new(1);
327        thread::sleep(Duration::from_millis(100));
328        let elapsed = progress.format_elapsed();
329        assert!(elapsed.ends_with('s'));
330    }
331}