Skip to main content

oximedia_clips/
proxy_meta.rs

1//! Proxy generation metadata for clip management.
2//!
3//! Provides [`ProxySpec`], [`ProxyMetadata`], and [`ProxyManagerSpec`] for
4//! tracking proxy media files associated with original clips.  The
5//! [`ProxyManagerSpec`] type is intentionally named to avoid collision with the
6//! existing `proxy::ProxyManager` which manages legacy `ProxyLink` records.
7
8use std::collections::HashMap;
9
10/// Technical specification of a proxy media file.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ProxySpec {
13    /// Proxy video width in pixels.
14    pub width: u32,
15    /// Proxy video height in pixels.
16    pub height: u32,
17    /// Codec identifier, e.g. `"h264"` or `"prores_proxy"`.
18    pub codec: String,
19    /// Target bitrate in kbps.
20    pub bitrate: u32,
21}
22
23impl ProxySpec {
24    /// Create a new proxy specification.
25    #[must_use]
26    pub fn new(width: u32, height: u32, codec: impl Into<String>, bitrate: u32) -> Self {
27        Self {
28            width,
29            height,
30            codec: codec.into(),
31            bitrate,
32        }
33    }
34}
35
36/// Metadata record linking an original clip to its proxy file.
37#[derive(Debug, Clone)]
38pub struct ProxyMetadata {
39    /// Absolute path to the original (full-resolution) clip.
40    pub original_path: String,
41    /// Absolute path to the proxy clip.
42    pub proxy_path: String,
43    /// Technical specification of the proxy.
44    pub spec: ProxySpec,
45    /// Unix timestamp (seconds since epoch) when the proxy was created.
46    pub created_at: u64,
47    /// Hex-encoded checksum of the proxy file (e.g. SHA-256).
48    pub checksum: String,
49}
50
51impl ProxyMetadata {
52    /// Create a new proxy metadata record.
53    #[must_use]
54    pub fn new(
55        original_path: impl Into<String>,
56        proxy_path: impl Into<String>,
57        spec: ProxySpec,
58        created_at: u64,
59        checksum: impl Into<String>,
60    ) -> Self {
61        Self {
62            original_path: original_path.into(),
63            proxy_path: proxy_path.into(),
64            spec,
65            created_at,
66            checksum: checksum.into(),
67        }
68    }
69}
70
71/// Errors produced by [`ProxyManagerSpec`] operations.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ProxyValidationError {
74    /// The proxy's actual dimensions differ from those in the spec.
75    DimensionMismatch {
76        /// Dimensions recorded in the spec (width, height).
77        expected: (u32, u32),
78        /// Dimensions observed at validation time (width, height).
79        actual: (u32, u32),
80    },
81    /// The proxy file's checksum does not match the recorded value.
82    ChecksumMismatch,
83    /// A required file path was not found on disk.
84    PathNotFound(String),
85    /// No proxy is registered for the given original path.
86    NotRegistered(String),
87    /// A provided path string is empty.
88    EmptyPath,
89}
90
91impl std::fmt::Display for ProxyValidationError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::DimensionMismatch { expected, actual } => write!(
95                f,
96                "dimension mismatch: expected {}x{}, got {}x{}",
97                expected.0, expected.1, actual.0, actual.1
98            ),
99            Self::ChecksumMismatch => write!(f, "proxy checksum mismatch"),
100            Self::PathNotFound(p) => write!(f, "path not found: {p}"),
101            Self::NotRegistered(p) => write!(f, "no proxy registered for: {p}"),
102            Self::EmptyPath => write!(f, "path must not be empty"),
103        }
104    }
105}
106
107impl std::error::Error for ProxyValidationError {}
108
109/// Manages proxy metadata records indexed by original clip path.
110///
111/// This type coexists with the legacy `proxy::ProxyManager` which manages
112/// [`crate::proxy::ProxyLink`] records.  [`ProxyManagerSpec`] is the newer,
113/// spec-driven variant.
114#[derive(Debug, Clone, Default)]
115pub struct ProxyManagerSpec {
116    records: HashMap<String, ProxyMetadata>,
117}
118
119impl ProxyManagerSpec {
120    /// Create a new, empty proxy manager.
121    #[must_use]
122    pub fn new() -> Self {
123        Self {
124            records: HashMap::new(),
125        }
126    }
127
128    /// Register a proxy metadata record.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`ProxyValidationError::EmptyPath`] if either path is empty.
133    pub fn register(&mut self, meta: ProxyMetadata) -> Result<(), ProxyValidationError> {
134        if meta.original_path.is_empty() || meta.proxy_path.is_empty() {
135            return Err(ProxyValidationError::EmptyPath);
136        }
137        self.records.insert(meta.original_path.clone(), meta);
138        Ok(())
139    }
140
141    /// Look up the proxy record for `original_path`.
142    #[must_use]
143    pub fn find_proxy(&self, original_path: &str) -> Option<&ProxyMetadata> {
144        self.records.get(original_path)
145    }
146
147    /// Check whether the proxy dimensions match the recorded spec.
148    ///
149    /// Returns `Ok(true)` when valid, `Ok(false)` when dimensions mismatch,
150    /// and `Err(ProxyValidationError::NotRegistered)` when no record exists.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`ProxyValidationError::NotRegistered`] if no proxy is
155    /// registered for `original_path`.
156    pub fn is_proxy_valid(
157        &self,
158        original_path: &str,
159        actual_width: u32,
160        actual_height: u32,
161    ) -> Result<bool, ProxyValidationError> {
162        let meta = self
163            .records
164            .get(original_path)
165            .ok_or_else(|| ProxyValidationError::NotRegistered(original_path.to_string()))?;
166
167        if meta.spec.width == actual_width && meta.spec.height == actual_height {
168            Ok(true)
169        } else {
170            Ok(false)
171        }
172    }
173
174    /// Remove and return the proxy record for `original_path`.
175    pub fn remove(&mut self, original_path: &str) -> Option<ProxyMetadata> {
176        self.records.remove(original_path)
177    }
178
179    /// Return references to all registered proxy records.
180    #[must_use]
181    pub fn list_all(&self) -> Vec<&ProxyMetadata> {
182        self.records.values().collect()
183    }
184
185    /// Number of registered proxy records.
186    #[must_use]
187    pub fn count(&self) -> usize {
188        self.records.len()
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn make_spec(w: u32, h: u32) -> ProxySpec {
201        ProxySpec::new(w, h, "h264", 2000)
202    }
203
204    fn make_meta(orig: &str, proxy: &str, w: u32, h: u32) -> ProxyMetadata {
205        ProxyMetadata::new(orig, proxy, make_spec(w, h), 1_700_000_000, "deadbeef")
206    }
207
208    #[test]
209    fn test_register_and_find() {
210        let mut mgr = ProxyManagerSpec::new();
211        let meta = make_meta("/orig/clip.mov", "/proxy/clip_proxy.mp4", 1280, 720);
212        mgr.register(meta).expect("register should succeed");
213
214        let found = mgr.find_proxy("/orig/clip.mov").expect("should find proxy");
215        assert_eq!(found.proxy_path, "/proxy/clip_proxy.mp4");
216    }
217
218    #[test]
219    fn test_find_missing_returns_none() {
220        let mgr = ProxyManagerSpec::new();
221        assert!(mgr.find_proxy("/nonexistent.mov").is_none());
222    }
223
224    #[test]
225    fn test_register_empty_original_path_errors() {
226        let mut mgr = ProxyManagerSpec::new();
227        let meta = make_meta("", "/proxy/clip.mp4", 1280, 720);
228        let err = mgr.register(meta).unwrap_err();
229        assert_eq!(err, ProxyValidationError::EmptyPath);
230    }
231
232    #[test]
233    fn test_register_empty_proxy_path_errors() {
234        let mut mgr = ProxyManagerSpec::new();
235        let meta = make_meta("/orig/clip.mov", "", 1280, 720);
236        let err = mgr.register(meta).unwrap_err();
237        assert_eq!(err, ProxyValidationError::EmptyPath);
238    }
239
240    #[test]
241    fn test_is_proxy_valid_dimensions_match() {
242        let mut mgr = ProxyManagerSpec::new();
243        mgr.register(make_meta("/orig/a.mov", "/proxy/a.mp4", 1280, 720))
244            .expect("register");
245        assert_eq!(mgr.is_proxy_valid("/orig/a.mov", 1280, 720), Ok(true));
246    }
247
248    #[test]
249    fn test_is_proxy_valid_dimension_mismatch() {
250        let mut mgr = ProxyManagerSpec::new();
251        mgr.register(make_meta("/orig/a.mov", "/proxy/a.mp4", 1280, 720))
252            .expect("register");
253        assert_eq!(mgr.is_proxy_valid("/orig/a.mov", 640, 360), Ok(false));
254    }
255
256    #[test]
257    fn test_is_proxy_valid_not_registered() {
258        let mgr = ProxyManagerSpec::new();
259        let err = mgr.is_proxy_valid("/unknown.mov", 1280, 720).unwrap_err();
260        assert!(matches!(err, ProxyValidationError::NotRegistered(_)));
261    }
262
263    #[test]
264    fn test_remove_existing() {
265        let mut mgr = ProxyManagerSpec::new();
266        mgr.register(make_meta("/orig/b.mov", "/proxy/b.mp4", 1920, 1080))
267            .expect("register");
268        let removed = mgr.remove("/orig/b.mov");
269        assert!(removed.is_some());
270        assert!(mgr.find_proxy("/orig/b.mov").is_none());
271    }
272
273    #[test]
274    fn test_remove_missing_returns_none() {
275        let mut mgr = ProxyManagerSpec::new();
276        assert!(mgr.remove("/nonexistent.mov").is_none());
277    }
278
279    #[test]
280    fn test_count() {
281        let mut mgr = ProxyManagerSpec::new();
282        assert_eq!(mgr.count(), 0);
283        mgr.register(make_meta("/orig/c.mov", "/proxy/c.mp4", 1280, 720))
284            .expect("register");
285        assert_eq!(mgr.count(), 1);
286        mgr.register(make_meta("/orig/d.mov", "/proxy/d.mp4", 1920, 1080))
287            .expect("register");
288        assert_eq!(mgr.count(), 2);
289    }
290
291    #[test]
292    fn test_list_all() {
293        let mut mgr = ProxyManagerSpec::new();
294        mgr.register(make_meta("/orig/e.mov", "/proxy/e.mp4", 1280, 720))
295            .expect("register");
296        mgr.register(make_meta("/orig/f.mov", "/proxy/f.mp4", 1920, 1080))
297            .expect("register");
298        let list = mgr.list_all();
299        assert_eq!(list.len(), 2);
300    }
301
302    #[test]
303    fn test_register_overwrites_duplicate() {
304        let mut mgr = ProxyManagerSpec::new();
305        mgr.register(make_meta("/orig/g.mov", "/proxy/g_v1.mp4", 1280, 720))
306            .expect("register v1");
307        mgr.register(make_meta("/orig/g.mov", "/proxy/g_v2.mp4", 1920, 1080))
308            .expect("register v2");
309        assert_eq!(mgr.count(), 1);
310        let found = mgr.find_proxy("/orig/g.mov").expect("should find");
311        assert_eq!(found.proxy_path, "/proxy/g_v2.mp4");
312        assert_eq!(found.spec.width, 1920);
313    }
314
315    #[test]
316    fn test_proxy_spec_fields() {
317        let spec = ProxySpec::new(3840, 2160, "prores_proxy", 8000);
318        assert_eq!(spec.width, 3840);
319        assert_eq!(spec.height, 2160);
320        assert_eq!(spec.codec, "prores_proxy");
321        assert_eq!(spec.bitrate, 8000);
322    }
323
324    #[test]
325    fn test_proxy_metadata_fields() {
326        let meta = ProxyMetadata::new(
327            "/orig/h.mxf",
328            "/proxy/h_proxy.mp4",
329            ProxySpec::new(960, 540, "h264", 1500),
330            1_234_567_890,
331            "abc123",
332        );
333        assert_eq!(meta.original_path, "/orig/h.mxf");
334        assert_eq!(meta.proxy_path, "/proxy/h_proxy.mp4");
335        assert_eq!(meta.created_at, 1_234_567_890);
336        assert_eq!(meta.checksum, "abc123");
337    }
338
339    #[test]
340    fn test_validation_error_display_dimension_mismatch() {
341        let err = ProxyValidationError::DimensionMismatch {
342            expected: (1280, 720),
343            actual: (640, 360),
344        };
345        let msg = err.to_string();
346        assert!(msg.contains("1280x720"));
347        assert!(msg.contains("640x360"));
348    }
349
350    #[test]
351    fn test_validation_error_display_not_registered() {
352        let err = ProxyValidationError::NotRegistered("/missing.mov".to_string());
353        assert!(err.to_string().contains("/missing.mov"));
354    }
355}