Skip to main content

oximedia_proxy/
sidecar.rs

1//! Side-car metadata file management.
2//!
3//! A "side-car" is a companion file that lives alongside a media file and stores
4//! supplemental metadata: proxy registry info, processing history, editorial notes,
5//! checksum, timecode, color metadata, etc.
6//!
7//! Side-car files use the `.oxsc` extension (OxiMedia Side-Car) and are serialized
8//! as JSON for human-readability and easy debugging.
9
10use crate::{ProxyError, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15/// Side-car format version for future compatibility.
16const SIDECAR_VERSION: u32 = 1;
17
18/// Checksum algorithm used for integrity verification.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum ChecksumAlgorithm {
21    /// MD5 (fast, not cryptographically secure).
22    Md5,
23    /// SHA-256 (secure, recommended for archival).
24    Sha256,
25    /// CRC32 (fastest, lowest security).
26    Crc32,
27    /// xxHash64 (very fast, good distribution).
28    XxHash64,
29}
30
31impl ChecksumAlgorithm {
32    /// Get algorithm name string.
33    #[must_use]
34    pub const fn name(&self) -> &'static str {
35        match self {
36            Self::Md5 => "md5",
37            Self::Sha256 => "sha256",
38            Self::Crc32 => "crc32",
39            Self::XxHash64 => "xxhash64",
40        }
41    }
42}
43
44/// File integrity checksum.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Checksum {
47    /// Algorithm used.
48    pub algorithm: ChecksumAlgorithm,
49    /// Hex-encoded checksum value.
50    pub value: String,
51    /// Size of file at time of checksum computation.
52    pub file_size: u64,
53}
54
55impl Checksum {
56    /// Create a new checksum record.
57    #[must_use]
58    pub fn new(algorithm: ChecksumAlgorithm, value: impl Into<String>, file_size: u64) -> Self {
59        Self {
60            algorithm,
61            value: value.into(),
62            file_size,
63        }
64    }
65}
66
67/// Timecode metadata stored in a side-car.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SidecarTimecode {
70    /// Start timecode string (e.g. "01:00:00:00").
71    pub start: String,
72    /// Duration in frames.
73    pub duration_frames: u64,
74    /// Frame rate as fraction string (e.g. "24/1").
75    pub frame_rate: String,
76    /// Whether drop-frame timecode is used.
77    pub drop_frame: bool,
78}
79
80impl SidecarTimecode {
81    /// Create a new timecode entry.
82    #[must_use]
83    pub fn new(
84        start: impl Into<String>,
85        duration_frames: u64,
86        frame_rate: impl Into<String>,
87        drop_frame: bool,
88    ) -> Self {
89        Self {
90            start: start.into(),
91            duration_frames,
92            frame_rate: frame_rate.into(),
93            drop_frame,
94        }
95    }
96}
97
98/// Processing history entry.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProcessingRecord {
101    /// Name of the operation (e.g. "proxy_generate", "color_grade").
102    pub operation: String,
103    /// ISO8601 timestamp.
104    pub timestamp: String,
105    /// Tool/software that performed the operation.
106    pub tool: String,
107    /// Tool version string.
108    pub tool_version: String,
109    /// Additional parameters or notes.
110    pub params: HashMap<String, String>,
111}
112
113impl ProcessingRecord {
114    /// Create a new processing record.
115    #[must_use]
116    pub fn new(operation: impl Into<String>, tool: impl Into<String>) -> Self {
117        Self {
118            operation: operation.into(),
119            timestamp: String::new(),
120            tool: tool.into(),
121            tool_version: String::new(),
122            params: HashMap::new(),
123        }
124    }
125
126    /// Add a parameter.
127    pub fn with_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
128        self.params.insert(key.into(), value.into());
129        self
130    }
131}
132
133/// The main side-car data structure.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SidecarData {
136    /// Side-car format version.
137    pub version: u32,
138    /// Path to the associated media file (relative or absolute).
139    pub media_path: PathBuf,
140    /// File integrity checksum.
141    pub checksum: Option<Checksum>,
142    /// Timecode information.
143    pub timecode: Option<SidecarTimecode>,
144    /// Processing history (most recent last).
145    pub history: Vec<ProcessingRecord>,
146    /// Proxy paths (spec name → proxy path).
147    pub proxies: HashMap<String, PathBuf>,
148    /// Arbitrary user/application metadata.
149    pub metadata: HashMap<String, String>,
150    /// Whether this file has been verified against its checksum.
151    pub integrity_verified: bool,
152    /// Optional editorial notes.
153    pub notes: String,
154}
155
156impl SidecarData {
157    /// Create a new side-car for the given media path.
158    #[must_use]
159    pub fn new(media_path: PathBuf) -> Self {
160        Self {
161            version: SIDECAR_VERSION,
162            media_path,
163            checksum: None,
164            timecode: None,
165            history: Vec::new(),
166            proxies: HashMap::new(),
167            metadata: HashMap::new(),
168            integrity_verified: false,
169            notes: String::new(),
170        }
171    }
172
173    /// Set the checksum.
174    pub fn set_checksum(&mut self, checksum: Checksum) {
175        self.integrity_verified = false;
176        self.checksum = Some(checksum);
177    }
178
179    /// Set timecode info.
180    pub fn set_timecode(&mut self, tc: SidecarTimecode) {
181        self.timecode = Some(tc);
182    }
183
184    /// Register a proxy path under a spec name.
185    pub fn add_proxy(&mut self, spec_name: impl Into<String>, path: PathBuf) {
186        self.proxies.insert(spec_name.into(), path);
187    }
188
189    /// Add a processing record to history.
190    pub fn add_history(&mut self, record: ProcessingRecord) {
191        self.history.push(record);
192    }
193
194    /// Set a metadata key-value pair.
195    pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
196        self.metadata.insert(key.into(), value.into());
197    }
198
199    /// Get a metadata value by key.
200    #[must_use]
201    pub fn get_metadata(&self, key: &str) -> Option<&str> {
202        self.metadata.get(key).map(String::as_str)
203    }
204
205    /// Number of proxies registered.
206    #[must_use]
207    pub fn proxy_count(&self) -> usize {
208        self.proxies.len()
209    }
210}
211
212/// Manages reading and writing side-car files.
213pub struct SideCar;
214
215impl SideCar {
216    /// Get the side-car path for a media file.
217    ///
218    /// The side-car is placed alongside the media file with `.oxsc` appended.
219    #[must_use]
220    pub fn path_for(media: &Path) -> PathBuf {
221        let mut p = media.to_path_buf();
222        let name = p
223            .file_name()
224            .unwrap_or_default()
225            .to_string_lossy()
226            .into_owned();
227        p.set_file_name(format!("{name}.oxsc"));
228        p
229    }
230
231    /// Load a side-car file from its media path.
232    ///
233    /// # Errors
234    ///
235    /// Returns an error if the side-car does not exist or cannot be parsed.
236    pub fn load(media: &Path) -> Result<SidecarData> {
237        let sc_path = Self::path_for(media);
238        if !sc_path.exists() {
239            return Err(ProxyError::FileNotFound(sc_path.display().to_string()));
240        }
241        let content = std::fs::read_to_string(&sc_path).map_err(ProxyError::IoError)?;
242        serde_json::from_str(&content)
243            .map_err(|e| ProxyError::MetadataError(format!("Side-car parse error: {e}")))
244    }
245
246    /// Load a side-car or create a default one if none exists.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error only if an existing side-car file can't be read.
251    pub fn load_or_create(media: &Path) -> Result<SidecarData> {
252        let sc_path = Self::path_for(media);
253        if sc_path.exists() {
254            Self::load(media)
255        } else {
256            Ok(SidecarData::new(media.to_path_buf()))
257        }
258    }
259
260    /// Save a side-car file alongside the media path.
261    ///
262    /// # Errors
263    ///
264    /// Returns an error if serialization or file write fails.
265    pub fn save(media: &Path, data: &SidecarData) -> Result<()> {
266        let sc_path = Self::path_for(media);
267        let json = serde_json::to_string_pretty(data)
268            .map_err(|e| ProxyError::MetadataError(e.to_string()))?;
269        std::fs::write(&sc_path, json).map_err(ProxyError::IoError)
270    }
271
272    /// Delete the side-car file for a media path (if it exists).
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the file exists but cannot be deleted.
277    pub fn delete(media: &Path) -> Result<()> {
278        let sc_path = Self::path_for(media);
279        if sc_path.exists() {
280            std::fs::remove_file(sc_path).map_err(ProxyError::IoError)?;
281        }
282        Ok(())
283    }
284
285    /// Check whether a side-car exists for a given media path.
286    #[must_use]
287    pub fn exists(media: &Path) -> bool {
288        Self::path_for(media).exists()
289    }
290
291    /// Compute a simple (fake/mock) checksum string for testing without crypto deps.
292    ///
293    /// This is a placeholder that produces a deterministic hash for the file size.
294    /// Real implementations would use sha2 / md5 crates.
295    #[must_use]
296    pub fn mock_checksum(data: &[u8]) -> String {
297        // Simple FNV-1a 64-bit hash as a stand-in
298        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
299        for &byte in data {
300            hash ^= byte as u64;
301            hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
302        }
303        format!("{hash:016x}")
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_checksum_algorithm_names() {
313        assert_eq!(ChecksumAlgorithm::Md5.name(), "md5");
314        assert_eq!(ChecksumAlgorithm::Sha256.name(), "sha256");
315        assert_eq!(ChecksumAlgorithm::Crc32.name(), "crc32");
316        assert_eq!(ChecksumAlgorithm::XxHash64.name(), "xxhash64");
317    }
318
319    #[test]
320    fn test_checksum_new() {
321        let c = Checksum::new(ChecksumAlgorithm::Sha256, "abc123", 4096);
322        assert_eq!(c.algorithm, ChecksumAlgorithm::Sha256);
323        assert_eq!(c.value, "abc123");
324        assert_eq!(c.file_size, 4096);
325    }
326
327    #[test]
328    fn test_sidecar_timecode_new() {
329        let tc = SidecarTimecode::new("01:00:00:00", 86400, "24/1", false);
330        assert_eq!(tc.start, "01:00:00:00");
331        assert_eq!(tc.duration_frames, 86400);
332        assert!(!tc.drop_frame);
333    }
334
335    #[test]
336    fn test_processing_record_new() {
337        let r = ProcessingRecord::new("proxy_generate", "OxiMedia");
338        assert_eq!(r.operation, "proxy_generate");
339        assert_eq!(r.tool, "OxiMedia");
340    }
341
342    #[test]
343    fn test_processing_record_with_param() {
344        let r = ProcessingRecord::new("encode", "ffmpeg")
345            .with_param("codec", "h264")
346            .with_param("bitrate", "2000000");
347        assert_eq!(
348            r.params.get("codec").expect("should succeed in test"),
349            "h264"
350        );
351        assert_eq!(
352            r.params.get("bitrate").expect("should succeed in test"),
353            "2000000"
354        );
355    }
356
357    #[test]
358    fn test_sidecar_data_new() {
359        let data = SidecarData::new(PathBuf::from("/media/clip.mov"));
360        assert_eq!(data.version, 1);
361        assert!(data.history.is_empty());
362        assert!(data.proxies.is_empty());
363        assert!(!data.integrity_verified);
364    }
365
366    #[test]
367    fn test_sidecar_data_set_metadata() {
368        let mut data = SidecarData::new(PathBuf::from("/media/clip.mov"));
369        data.set_metadata("camera", "ARRI ALEXA");
370        assert_eq!(data.get_metadata("camera"), Some("ARRI ALEXA"));
371        assert_eq!(data.get_metadata("missing"), None);
372    }
373
374    #[test]
375    fn test_sidecar_data_add_proxy() {
376        let mut data = SidecarData::new(PathBuf::from("/media/clip.mov"));
377        data.add_proxy("Quarter H.264", PathBuf::from("/proxy/clip.mp4"));
378        assert_eq!(data.proxy_count(), 1);
379        assert!(data.proxies.contains_key("Quarter H.264"));
380    }
381
382    #[test]
383    fn test_sidecar_data_add_history() {
384        let mut data = SidecarData::new(PathBuf::from("/media/clip.mov"));
385        data.add_history(ProcessingRecord::new("ingest", "OxiMedia"));
386        data.add_history(ProcessingRecord::new("proxy_generate", "OxiMedia"));
387        assert_eq!(data.history.len(), 2);
388        assert_eq!(data.history[1].operation, "proxy_generate");
389    }
390
391    #[test]
392    fn test_sidecar_data_checksum() {
393        let mut data = SidecarData::new(PathBuf::from("/media/clip.mov"));
394        data.set_checksum(Checksum::new(ChecksumAlgorithm::Sha256, "deadbeef", 1024));
395        assert!(data.checksum.is_some());
396        assert!(!data.integrity_verified); // Reset on set
397    }
398
399    #[test]
400    fn test_sidecar_path_for() {
401        let media = Path::new("/media/project/clip001.mov");
402        let sc_path = SideCar::path_for(media);
403        assert_eq!(sc_path, PathBuf::from("/media/project/clip001.mov.oxsc"));
404    }
405
406    #[test]
407    fn test_sidecar_path_for_no_extension() {
408        let media = Path::new("/media/clip");
409        let sc_path = SideCar::path_for(media);
410        assert_eq!(sc_path, PathBuf::from("/media/clip.oxsc"));
411    }
412
413    #[test]
414    fn test_sidecar_exists_false() {
415        let media = Path::new("/nonexistent/clip.mov");
416        assert!(!SideCar::exists(media));
417    }
418
419    #[test]
420    fn test_sidecar_load_not_found() {
421        let media = Path::new("/nonexistent/clip.mov");
422        let result = SideCar::load(media);
423        assert!(result.is_err());
424        matches!(result, Err(crate::ProxyError::FileNotFound(_)));
425    }
426
427    #[test]
428    fn test_sidecar_save_and_load() {
429        let dir = std::env::temp_dir();
430        let media = dir.join("test_sidecar_media.mov");
431        // Clean up first
432        let _ = SideCar::delete(&media);
433
434        let mut data = SidecarData::new(media.clone());
435        data.set_metadata("test", "value123");
436        data.add_proxy("Quarter", PathBuf::from("/proxy/q.mp4"));
437        data.add_history(ProcessingRecord::new("test_op", "OxiMedia v1.0"));
438
439        SideCar::save(&media, &data).expect("should succeed in test");
440        assert!(SideCar::exists(&media));
441
442        let loaded = SideCar::load(&media).expect("should succeed in test");
443        assert_eq!(loaded.get_metadata("test"), Some("value123"));
444        assert_eq!(loaded.proxy_count(), 1);
445        assert_eq!(loaded.history.len(), 1);
446
447        SideCar::delete(&media).expect("should succeed in test");
448        assert!(!SideCar::exists(&media));
449    }
450
451    #[test]
452    fn test_sidecar_load_or_create_new() {
453        let media = Path::new("/nonexistent/fresh.mov");
454        let data = SideCar::load_or_create(media).expect("should succeed in test");
455        assert_eq!(data.media_path, PathBuf::from("/nonexistent/fresh.mov"));
456        assert!(data.history.is_empty());
457    }
458
459    #[test]
460    fn test_mock_checksum_deterministic() {
461        let data = b"hello world";
462        let h1 = SideCar::mock_checksum(data);
463        let h2 = SideCar::mock_checksum(data);
464        assert_eq!(h1, h2);
465        assert_eq!(h1.len(), 16); // 8-byte hex = 16 chars
466    }
467
468    #[test]
469    fn test_mock_checksum_different_inputs() {
470        let h1 = SideCar::mock_checksum(b"abc");
471        let h2 = SideCar::mock_checksum(b"def");
472        assert_ne!(h1, h2);
473    }
474}