1use crate::{ProxyError, Result};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15const SIDECAR_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub enum ChecksumAlgorithm {
21 Md5,
23 Sha256,
25 Crc32,
27 XxHash64,
29}
30
31impl ChecksumAlgorithm {
32 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Checksum {
47 pub algorithm: ChecksumAlgorithm,
49 pub value: String,
51 pub file_size: u64,
53}
54
55impl Checksum {
56 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SidecarTimecode {
70 pub start: String,
72 pub duration_frames: u64,
74 pub frame_rate: String,
76 pub drop_frame: bool,
78}
79
80impl SidecarTimecode {
81 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ProcessingRecord {
101 pub operation: String,
103 pub timestamp: String,
105 pub tool: String,
107 pub tool_version: String,
109 pub params: HashMap<String, String>,
111}
112
113impl ProcessingRecord {
114 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct SidecarData {
136 pub version: u32,
138 pub media_path: PathBuf,
140 pub checksum: Option<Checksum>,
142 pub timecode: Option<SidecarTimecode>,
144 pub history: Vec<ProcessingRecord>,
146 pub proxies: HashMap<String, PathBuf>,
148 pub metadata: HashMap<String, String>,
150 pub integrity_verified: bool,
152 pub notes: String,
154}
155
156impl SidecarData {
157 #[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 pub fn set_checksum(&mut self, checksum: Checksum) {
175 self.integrity_verified = false;
176 self.checksum = Some(checksum);
177 }
178
179 pub fn set_timecode(&mut self, tc: SidecarTimecode) {
181 self.timecode = Some(tc);
182 }
183
184 pub fn add_proxy(&mut self, spec_name: impl Into<String>, path: PathBuf) {
186 self.proxies.insert(spec_name.into(), path);
187 }
188
189 pub fn add_history(&mut self, record: ProcessingRecord) {
191 self.history.push(record);
192 }
193
194 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 #[must_use]
201 pub fn get_metadata(&self, key: &str) -> Option<&str> {
202 self.metadata.get(key).map(String::as_str)
203 }
204
205 #[must_use]
207 pub fn proxy_count(&self) -> usize {
208 self.proxies.len()
209 }
210}
211
212pub struct SideCar;
214
215impl SideCar {
216 #[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 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 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 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 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 #[must_use]
287 pub fn exists(media: &Path) -> bool {
288 Self::path_for(media).exists()
289 }
290
291 #[must_use]
296 pub fn mock_checksum(data: &[u8]) -> String {
297 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); }
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 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); }
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}