Skip to main content

oximedia_proxy/
proxy_status.rs

1#![allow(dead_code)]
2
3//! Proxy generation status tracking and lifecycle management.
4//!
5//! This module provides a status tracker for proxy generation jobs,
6//! supporting state transitions, progress reporting, error tracking,
7//! and batch status aggregation.
8
9use std::collections::HashMap;
10use std::time::{Duration, Instant};
11
12/// Possible states of a proxy generation job.
13#[derive(Debug, Clone, PartialEq, Eq, Hash)]
14pub enum ProxyState {
15    /// Job has been created but not yet started.
16    Queued,
17    /// Job is currently being processed.
18    InProgress,
19    /// Job completed successfully.
20    Completed,
21    /// Job failed with an error.
22    Failed,
23    /// Job was cancelled before completion.
24    Cancelled,
25    /// Job is paused (e.g., waiting for resources).
26    Paused,
27    /// Job is being retried after a previous failure.
28    Retrying,
29}
30
31impl std::fmt::Display for ProxyState {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        let label = match self {
34            Self::Queued => "Queued",
35            Self::InProgress => "In Progress",
36            Self::Completed => "Completed",
37            Self::Failed => "Failed",
38            Self::Cancelled => "Cancelled",
39            Self::Paused => "Paused",
40            Self::Retrying => "Retrying",
41        };
42        write!(f, "{label}")
43    }
44}
45
46/// Status information for a single proxy generation job.
47#[derive(Debug, Clone)]
48pub struct ProxyJobStatus {
49    /// Unique job identifier.
50    pub job_id: String,
51    /// Source media file path.
52    pub source_path: String,
53    /// Output proxy file path.
54    pub output_path: String,
55    /// Current state of the job.
56    pub state: ProxyState,
57    /// Progress percentage (0.0 to 100.0).
58    pub progress_percent: f64,
59    /// Number of frames processed so far.
60    pub frames_processed: u64,
61    /// Total frames expected.
62    pub total_frames: u64,
63    /// Error message (if state is Failed).
64    pub error_message: Option<String>,
65    /// Number of retry attempts so far.
66    pub retry_count: u32,
67    /// Maximum number of retries allowed.
68    pub max_retries: u32,
69}
70
71impl ProxyJobStatus {
72    /// Create a new job status in the Queued state.
73    pub fn new(job_id: &str, source: &str, output: &str) -> Self {
74        Self {
75            job_id: job_id.to_string(),
76            source_path: source.to_string(),
77            output_path: output.to_string(),
78            state: ProxyState::Queued,
79            progress_percent: 0.0,
80            frames_processed: 0,
81            total_frames: 0,
82            error_message: None,
83            retry_count: 0,
84            max_retries: 3,
85        }
86    }
87
88    /// Set the total frame count.
89    pub fn with_total_frames(mut self, total: u64) -> Self {
90        self.total_frames = total;
91        self
92    }
93
94    /// Set the maximum number of retries.
95    pub fn with_max_retries(mut self, max: u32) -> Self {
96        self.max_retries = max;
97        self
98    }
99
100    /// Check if the job is in a terminal state (Completed, Failed, or Cancelled).
101    pub fn is_terminal(&self) -> bool {
102        matches!(
103            self.state,
104            ProxyState::Completed | ProxyState::Failed | ProxyState::Cancelled
105        )
106    }
107
108    /// Check if the job can be retried.
109    pub fn can_retry(&self) -> bool {
110        self.state == ProxyState::Failed && self.retry_count < self.max_retries
111    }
112
113    /// Update the progress based on frames processed.
114    #[allow(clippy::cast_precision_loss)]
115    pub fn update_progress(&mut self, frames: u64) {
116        self.frames_processed = frames;
117        if self.total_frames > 0 {
118            self.progress_percent = (frames as f64 / self.total_frames as f64) * 100.0;
119            if self.progress_percent > 100.0 {
120                self.progress_percent = 100.0;
121            }
122        }
123    }
124}
125
126/// Tracker that manages multiple proxy job statuses.
127#[derive(Debug)]
128pub struct ProxyStatusTracker {
129    /// Map of job_id to status.
130    jobs: HashMap<String, ProxyJobStatus>,
131    /// Timestamp of tracker creation.
132    created_at: Instant,
133}
134
135impl ProxyStatusTracker {
136    /// Create a new empty tracker.
137    pub fn new() -> Self {
138        Self {
139            jobs: HashMap::new(),
140            created_at: Instant::now(),
141        }
142    }
143
144    /// Register a new job.
145    pub fn add_job(&mut self, status: ProxyJobStatus) {
146        self.jobs.insert(status.job_id.clone(), status);
147    }
148
149    /// Get the status of a specific job.
150    pub fn get_job(&self, job_id: &str) -> Option<&ProxyJobStatus> {
151        self.jobs.get(job_id)
152    }
153
154    /// Transition a job to a new state.
155    pub fn transition(&mut self, job_id: &str, new_state: ProxyState) -> bool {
156        if let Some(job) = self.jobs.get_mut(job_id) {
157            if job.is_terminal() && new_state != ProxyState::Retrying {
158                return false;
159            }
160            if new_state == ProxyState::Retrying {
161                job.retry_count += 1;
162            }
163            if new_state == ProxyState::Completed {
164                job.progress_percent = 100.0;
165            }
166            job.state = new_state;
167            true
168        } else {
169            false
170        }
171    }
172
173    /// Record a failure with an error message.
174    pub fn fail_job(&mut self, job_id: &str, error: &str) -> bool {
175        if let Some(job) = self.jobs.get_mut(job_id) {
176            job.state = ProxyState::Failed;
177            job.error_message = Some(error.to_string());
178            true
179        } else {
180            false
181        }
182    }
183
184    /// Update frame progress on a job.
185    pub fn update_progress(&mut self, job_id: &str, frames: u64) -> bool {
186        if let Some(job) = self.jobs.get_mut(job_id) {
187            job.update_progress(frames);
188            true
189        } else {
190            false
191        }
192    }
193
194    /// Return the number of tracked jobs.
195    pub fn job_count(&self) -> usize {
196        self.jobs.len()
197    }
198
199    /// Return a count of jobs by state.
200    pub fn count_by_state(&self) -> HashMap<ProxyState, usize> {
201        let mut counts: HashMap<ProxyState, usize> = HashMap::new();
202        for job in self.jobs.values() {
203            *counts.entry(job.state.clone()).or_insert(0) += 1;
204        }
205        counts
206    }
207
208    /// Return the overall progress across all non-terminal jobs.
209    #[allow(clippy::cast_precision_loss)]
210    pub fn overall_progress(&self) -> f64 {
211        let active: Vec<&ProxyJobStatus> =
212            self.jobs.values().filter(|j| !j.is_terminal()).collect();
213        if active.is_empty() {
214            return 100.0;
215        }
216        let sum: f64 = active.iter().map(|j| j.progress_percent).sum();
217        sum / active.len() as f64
218    }
219
220    /// Return all jobs that are in the Failed state and can be retried.
221    pub fn retryable_jobs(&self) -> Vec<&ProxyJobStatus> {
222        self.jobs.values().filter(|j| j.can_retry()).collect()
223    }
224
225    /// Elapsed time since the tracker was created.
226    pub fn elapsed(&self) -> Duration {
227        self.created_at.elapsed()
228    }
229
230    /// Remove all jobs that are in a terminal state.
231    pub fn clear_terminal(&mut self) -> usize {
232        let before = self.jobs.len();
233        self.jobs.retain(|_, j| !j.is_terminal());
234        before - self.jobs.len()
235    }
236}
237
238impl Default for ProxyStatusTracker {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn make_job(id: &str) -> ProxyJobStatus {
249        ProxyJobStatus::new(id, "/src/clip.mov", "/proxy/clip.mp4")
250            .with_total_frames(1000)
251            .with_max_retries(2)
252    }
253
254    #[test]
255    fn test_new_job_is_queued() {
256        let job = make_job("j1");
257        assert_eq!(job.state, ProxyState::Queued);
258        assert!((job.progress_percent - 0.0).abs() < f64::EPSILON);
259    }
260
261    #[test]
262    fn test_is_terminal() {
263        let mut job = make_job("j1");
264        assert!(!job.is_terminal());
265        job.state = ProxyState::Completed;
266        assert!(job.is_terminal());
267        job.state = ProxyState::Failed;
268        assert!(job.is_terminal());
269        job.state = ProxyState::Cancelled;
270        assert!(job.is_terminal());
271    }
272
273    #[test]
274    fn test_can_retry() {
275        let mut job = make_job("j1");
276        job.state = ProxyState::Failed;
277        job.retry_count = 0;
278        assert!(job.can_retry());
279        job.retry_count = 2;
280        assert!(!job.can_retry());
281    }
282
283    #[test]
284    fn test_update_progress() {
285        let mut job = make_job("j1");
286        job.update_progress(500);
287        assert!((job.progress_percent - 50.0).abs() < f64::EPSILON);
288        assert_eq!(job.frames_processed, 500);
289    }
290
291    #[test]
292    fn test_progress_caps_at_100() {
293        let mut job = make_job("j1");
294        job.update_progress(2000);
295        assert!((job.progress_percent - 100.0).abs() < f64::EPSILON);
296    }
297
298    #[test]
299    fn test_tracker_add_and_get() {
300        let mut tracker = ProxyStatusTracker::new();
301        tracker.add_job(make_job("j1"));
302        assert_eq!(tracker.job_count(), 1);
303        assert!(tracker.get_job("j1").is_some());
304        assert!(tracker.get_job("j999").is_none());
305    }
306
307    #[test]
308    fn test_transition() {
309        let mut tracker = ProxyStatusTracker::new();
310        tracker.add_job(make_job("j1"));
311        assert!(tracker.transition("j1", ProxyState::InProgress));
312        assert_eq!(
313            tracker.get_job("j1").expect("should succeed in test").state,
314            ProxyState::InProgress
315        );
316    }
317
318    #[test]
319    fn test_transition_terminal_blocked() {
320        let mut tracker = ProxyStatusTracker::new();
321        tracker.add_job(make_job("j1"));
322        tracker.transition("j1", ProxyState::Completed);
323        // Cannot go back to InProgress from Completed
324        assert!(!tracker.transition("j1", ProxyState::InProgress));
325    }
326
327    #[test]
328    fn test_transition_retry_allowed() {
329        let mut tracker = ProxyStatusTracker::new();
330        tracker.add_job(make_job("j1"));
331        tracker.transition("j1", ProxyState::Failed);
332        // Retrying is allowed from Failed
333        assert!(tracker.transition("j1", ProxyState::Retrying));
334        assert_eq!(
335            tracker
336                .get_job("j1")
337                .expect("should succeed in test")
338                .retry_count,
339            1
340        );
341    }
342
343    #[test]
344    fn test_fail_job() {
345        let mut tracker = ProxyStatusTracker::new();
346        tracker.add_job(make_job("j1"));
347        assert!(tracker.fail_job("j1", "codec not found"));
348        let job = tracker.get_job("j1").expect("should succeed in test");
349        assert_eq!(job.state, ProxyState::Failed);
350        assert_eq!(job.error_message.as_deref(), Some("codec not found"));
351    }
352
353    #[test]
354    fn test_fail_nonexistent_job() {
355        let mut tracker = ProxyStatusTracker::new();
356        assert!(!tracker.fail_job("nonexistent", "err"));
357    }
358
359    #[test]
360    fn test_count_by_state() {
361        let mut tracker = ProxyStatusTracker::new();
362        tracker.add_job(make_job("j1"));
363        tracker.add_job(make_job("j2"));
364        tracker.add_job(make_job("j3"));
365        tracker.transition("j2", ProxyState::InProgress);
366        tracker.transition("j3", ProxyState::Completed);
367
368        let counts = tracker.count_by_state();
369        assert_eq!(*counts.get(&ProxyState::Queued).unwrap_or(&0), 1);
370        assert_eq!(*counts.get(&ProxyState::InProgress).unwrap_or(&0), 1);
371        assert_eq!(*counts.get(&ProxyState::Completed).unwrap_or(&0), 1);
372    }
373
374    #[test]
375    fn test_overall_progress() {
376        let mut tracker = ProxyStatusTracker::new();
377        tracker.add_job(make_job("j1"));
378        tracker.add_job(make_job("j2"));
379        tracker.update_progress("j1", 500); // 50%
380        tracker.update_progress("j2", 250); // 25%
381        let overall = tracker.overall_progress();
382        assert!((overall - 37.5).abs() < f64::EPSILON);
383    }
384
385    #[test]
386    fn test_overall_progress_all_done() {
387        let mut tracker = ProxyStatusTracker::new();
388        tracker.add_job(make_job("j1"));
389        tracker.transition("j1", ProxyState::Completed);
390        assert!((tracker.overall_progress() - 100.0).abs() < f64::EPSILON);
391    }
392
393    #[test]
394    fn test_retryable_jobs() {
395        let mut tracker = ProxyStatusTracker::new();
396        tracker.add_job(make_job("j1"));
397        tracker.add_job(make_job("j2"));
398        tracker.fail_job("j1", "err");
399        let retryable = tracker.retryable_jobs();
400        assert_eq!(retryable.len(), 1);
401        assert_eq!(retryable[0].job_id, "j1");
402    }
403
404    #[test]
405    fn test_clear_terminal() {
406        let mut tracker = ProxyStatusTracker::new();
407        tracker.add_job(make_job("j1"));
408        tracker.add_job(make_job("j2"));
409        tracker.add_job(make_job("j3"));
410        tracker.transition("j1", ProxyState::Completed);
411        tracker.fail_job("j2", "err");
412        let cleared = tracker.clear_terminal();
413        assert_eq!(cleared, 2);
414        assert_eq!(tracker.job_count(), 1);
415    }
416
417    #[test]
418    fn test_proxy_state_display() {
419        assert_eq!(format!("{}", ProxyState::Queued), "Queued");
420        assert_eq!(format!("{}", ProxyState::InProgress), "In Progress");
421        assert_eq!(format!("{}", ProxyState::Completed), "Completed");
422        assert_eq!(format!("{}", ProxyState::Failed), "Failed");
423    }
424
425    #[test]
426    fn test_default_tracker() {
427        let tracker = ProxyStatusTracker::default();
428        assert_eq!(tracker.job_count(), 0);
429    }
430}