1use crate::config::ConformConfig;
4use crate::error::{ConformError, ConformResult};
5use crate::exporters::report::{AmbiguousMatch, MatchReport};
6use crate::exporters::{project::ProjectExporter, sequence::SequenceExporter, Exporter};
7use crate::importers::{edl::EdlImporter, TimelineImporter};
8use crate::matching::strategies::MatchStrategy;
9use crate::media::{catalog::MediaCatalog, scanner::MediaScanner, ScanProgress};
10use crate::qc::{checker::QualityChecker, validator::Validator};
11use crate::timeline::{Timeline, TimelineClip, Track, TrackKind};
12use crate::types::{ClipMatch, ClipReference, OutputFormat};
13use parking_lot::RwLock;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use tracing::{debug, info, warn};
17use uuid::Uuid;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SessionStatus {
22 Created,
24 Scanning,
26 Matching,
28 Conforming,
30 Exporting,
32 Completed,
34 Failed,
36}
37
38pub struct ConformSession {
40 id: Uuid,
42 name: String,
44 timeline_path: PathBuf,
46 source_paths: Vec<PathBuf>,
48 output_path: PathBuf,
50 config: ConformConfig,
52 catalog: MediaCatalog,
54 clips: Vec<ClipReference>,
56 matches: Arc<RwLock<Vec<ClipMatch>>>,
58 missing: Arc<RwLock<Vec<ClipReference>>>,
60 ambiguous: Arc<RwLock<Vec<AmbiguousMatch>>>,
62 status: Arc<RwLock<SessionStatus>>,
64}
65
66impl ConformSession {
67 pub fn new<P: AsRef<Path>>(
73 name: String,
74 timeline_path: P,
75 source_paths: Vec<PathBuf>,
76 output_path: PathBuf,
77 config: ConformConfig,
78 ) -> ConformResult<Self> {
79 let id = Uuid::new_v4();
80 let catalog = MediaCatalog::in_memory()?;
81
82 Ok(Self {
83 id,
84 name,
85 timeline_path: timeline_path.as_ref().to_path_buf(),
86 source_paths,
87 output_path,
88 config,
89 catalog,
90 clips: Vec::new(),
91 matches: Arc::new(RwLock::new(Vec::new())),
92 missing: Arc::new(RwLock::new(Vec::new())),
93 ambiguous: Arc::new(RwLock::new(Vec::new())),
94 status: Arc::new(RwLock::new(SessionStatus::Created)),
95 })
96 }
97
98 pub fn with_database<P: AsRef<Path>>(
104 name: String,
105 timeline_path: P,
106 source_paths: Vec<PathBuf>,
107 output_path: PathBuf,
108 config: ConformConfig,
109 db_path: P,
110 ) -> ConformResult<Self> {
111 let id = Uuid::new_v4();
112 let catalog = MediaCatalog::new(db_path)?;
113
114 Ok(Self {
115 id,
116 name,
117 timeline_path: timeline_path.as_ref().to_path_buf(),
118 source_paths,
119 output_path,
120 config,
121 catalog,
122 clips: Vec::new(),
123 matches: Arc::new(RwLock::new(Vec::new())),
124 missing: Arc::new(RwLock::new(Vec::new())),
125 ambiguous: Arc::new(RwLock::new(Vec::new())),
126 status: Arc::new(RwLock::new(SessionStatus::Created)),
127 })
128 }
129
130 #[must_use]
132 pub fn id(&self) -> Uuid {
133 self.id
134 }
135
136 #[must_use]
138 pub fn name(&self) -> &str {
139 &self.name
140 }
141
142 #[must_use]
144 pub fn status(&self) -> SessionStatus {
145 *self.status.read()
146 }
147
148 fn set_status(&self, status: SessionStatus) {
150 *self.status.write() = status;
151 }
152
153 pub async fn load_timeline(&mut self) -> ConformResult<()> {
159 info!("Loading timeline from {}", self.timeline_path.display());
160
161 let clips = if self.timeline_path.extension().and_then(|e| e.to_str()) == Some("edl") {
163 let importer = EdlImporter::new();
164 importer.import(&self.timeline_path)?
165 } else {
166 return Err(ConformError::UnsupportedFormat(
167 "Only EDL files are currently supported".to_string(),
168 ));
169 };
170
171 info!("Loaded {} clips from timeline", clips.len());
172 self.clips = clips;
173 Ok(())
174 }
175
176 pub async fn scan_sources(&mut self) -> ConformResult<ScanProgress> {
182 self.set_status(SessionStatus::Scanning);
183 info!("Scanning {} source paths", self.source_paths.len());
184
185 let mut scanner = MediaScanner::new();
186 scanner.generate_checksums(self.config.verify_checksums);
187
188 let progress = Arc::new(ScanProgress::new());
189 let mut all_media = Vec::new();
190
191 for source_path in &self.source_paths {
192 info!("Scanning {}", source_path.display());
193 let media = scanner.scan(source_path, Some(progress.clone()))?;
194 all_media.extend(media);
195 }
196
197 info!("Found {} media files", all_media.len());
198 self.catalog.add_many(all_media)?;
199
200 Ok((*progress).clone())
201 }
202
203 pub async fn match_clips(&mut self) -> ConformResult<MatchReport> {
209 self.set_status(SessionStatus::Matching);
210 info!("Matching {} clips", self.clips.len());
211
212 let strategy = MatchStrategy::new(self.config.clone());
213 let all_media = self.catalog.get_all()?;
214
215 let mut matched = Vec::new();
216 let mut missing = Vec::new();
217 let mut ambiguous = Vec::new();
218
219 for clip in &self.clips {
220 let clip_matches = strategy.match_clip(clip, &all_media);
221
222 if clip_matches.is_empty() {
223 warn!("No match found for clip: {}", clip.id);
224 missing.push(clip.clone());
225 } else if clip_matches.len() == 1 {
226 debug!(
227 "Found match for clip {}: score {:.2}",
228 clip.id, clip_matches[0].score
229 );
230 matched.push(clip_matches[0].clone());
231 } else {
232 if strategy.is_ambiguous(&clip_matches, 0.05) {
234 warn!(
235 "Ambiguous matches for clip {}: {} candidates",
236 clip.id,
237 clip_matches.len()
238 );
239 ambiguous.push(AmbiguousMatch {
240 clip: clip.clone(),
241 candidates: clip_matches,
242 });
243 } else {
244 debug!(
246 "Using best match for clip {}: score {:.2}",
247 clip.id, clip_matches[0].score
248 );
249 matched.push(clip_matches[0].clone());
250 }
251 }
252 }
253
254 info!(
255 "Matching complete: {} matched, {} missing, {} ambiguous",
256 matched.len(),
257 missing.len(),
258 ambiguous.len()
259 );
260
261 *self.matches.write() = matched.clone();
262 *self.missing.write() = missing.clone();
263 *self.ambiguous.write() = ambiguous.clone();
264
265 Ok(MatchReport::new(matched, missing, ambiguous))
266 }
267
268 pub fn validate(&self) -> ConformResult<()> {
274 info!("Validating matches");
275
276 let validator = Validator::new(self.config.clone());
277 let matches = self.matches.read();
278 let report = validator.validate_all(&matches);
279
280 if !report.is_valid {
281 if self.config.strict_validation {
282 return Err(ConformError::Validation(format!(
283 "Validation failed with {} errors",
284 report.errors.len()
285 )));
286 }
287 warn!(
288 "Validation found {} errors and {} warnings",
289 report.errors.len(),
290 report.warnings.len()
291 );
292 }
293
294 let checker = QualityChecker::new();
295 checker.check_format_consistency(&matches)?;
296 checker.check_timecode_continuity(&matches)?;
297
298 info!("Validation complete");
299 Ok(())
300 }
301
302 pub async fn conform(&mut self) -> ConformResult<Timeline> {
308 self.set_status(SessionStatus::Conforming);
309 info!("Conforming sequence");
310
311 let matches = self.matches.read();
312 if matches.is_empty() {
313 return Err(ConformError::Other("No matches to conform".to_string()));
314 }
315
316 let fps = matches[0].clip.fps;
318 let mut timeline = Timeline::new(self.name.clone(), fps);
319
320 let mut video_track = Track::new("V1".to_string(), TrackKind::Video);
322 for clip_match in matches.iter() {
323 if clip_match.clip.track.has_video() {
324 let clip = TimelineClip::from_match(clip_match);
325 video_track.add_clip(clip);
326 }
327 }
328 video_track.sort_clips();
329 timeline.add_video_track(video_track);
330
331 let mut audio_track = Track::new("A1".to_string(), TrackKind::Audio);
333 for clip_match in matches.iter() {
334 if clip_match.clip.track.has_audio() {
335 let clip = TimelineClip::from_match(clip_match);
336 audio_track.add_clip(clip);
337 }
338 }
339 audio_track.sort_clips();
340 timeline.add_audio_track(audio_track);
341
342 info!(
343 "Timeline created with {} video tracks and {} audio tracks",
344 timeline.video_tracks.len(),
345 timeline.audio_tracks.len()
346 );
347
348 self.set_status(SessionStatus::Completed);
349 Ok(timeline)
350 }
351
352 pub async fn export(&self, format: OutputFormat) -> ConformResult<()> {
358 self.set_status(SessionStatus::Exporting);
359 info!("Exporting to {:?}", format);
360
361 let matches = self.matches.read();
362
363 match format {
364 OutputFormat::Mp4
365 | OutputFormat::Matroska
366 | OutputFormat::FrameSequenceDpx
367 | OutputFormat::FrameSequenceTiff
368 | OutputFormat::FrameSequencePng => {
369 let exporter = SequenceExporter::new(matches.clone());
370 exporter.export(&self.output_path, format)?;
371 }
372 OutputFormat::Edl
373 | OutputFormat::FcpXml
374 | OutputFormat::PremiereXml
375 | OutputFormat::Aaf => {
376 let exporter = ProjectExporter::new(matches.clone());
377 exporter.export(&self.output_path, format)?;
378 }
379 }
380
381 info!("Export complete");
382 Ok(())
383 }
384
385 #[must_use]
387 pub fn get_match_report(&self) -> MatchReport {
388 MatchReport::new(
389 self.matches.read().clone(),
390 self.missing.read().clone(),
391 self.ambiguous.read().clone(),
392 )
393 }
394
395 pub async fn run(&mut self) -> ConformResult<MatchReport> {
401 info!("Starting conform session: {}", self.name);
402
403 self.load_timeline().await?;
405
406 self.scan_sources().await?;
408
409 let report = self.match_clips().await?;
411
412 self.validate()?;
414
415 self.conform().await?;
417
418 info!("Conform session complete");
419 Ok(report)
420 }
421}
422
423pub struct WatchFolderSession {
431 pub poll_dir: PathBuf,
433 pub poll_interval_secs: u64,
435 known_paths: parking_lot::Mutex<std::collections::HashSet<PathBuf>>,
437}
438
439impl WatchFolderSession {
440 #[must_use]
449 pub fn new(poll_dir: PathBuf, poll_interval_secs: u64) -> Self {
450 let initial = collect_source_paths(&poll_dir);
451 Self {
452 poll_dir,
453 poll_interval_secs,
454 known_paths: parking_lot::Mutex::new(initial),
455 }
456 }
457
458 #[must_use]
464 pub fn check_for_new_sources(&self) -> Vec<PathBuf> {
465 let current = collect_source_paths(&self.poll_dir);
466
467 let mut known = self.known_paths.lock();
468 let new_paths: Vec<PathBuf> = current
469 .iter()
470 .filter(|p| !known.contains(*p))
471 .cloned()
472 .collect();
473
474 *known = current;
476 new_paths
477 }
478}
479
480fn collect_source_paths(dir: &Path) -> std::collections::HashSet<PathBuf> {
482 std::fs::read_dir(dir)
483 .into_iter()
484 .flatten()
485 .filter_map(|entry| {
486 let entry = entry.ok()?;
487 let ft = entry.file_type().ok()?;
488 if ft.is_file() {
489 Some(entry.path())
490 } else {
491 None
492 }
493 })
494 .collect()
495}
496
497use crate::types::TrackType;
498
499impl TrackType {
500 #[must_use]
501 pub(crate) const fn has_video(&self) -> bool {
502 matches!(self, Self::Video | Self::AudioVideo)
503 }
504
505 #[must_use]
506 pub(crate) const fn has_audio(&self) -> bool {
507 matches!(self, Self::Audio | Self::AudioVideo)
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_session_creation() {
517 let session = ConformSession::new(
518 "Test Session".to_string(),
519 PathBuf::from("/test/timeline.edl"),
520 vec![PathBuf::from("/test/media")],
521 PathBuf::from("/test/output"),
522 ConformConfig::default(),
523 )
524 .expect("test expectation failed");
525
526 assert_eq!(session.name(), "Test Session");
527 assert_eq!(session.status(), SessionStatus::Created);
528 }
529
530 #[test]
531 fn test_status_changes() {
532 let session = ConformSession::new(
533 "Test".to_string(),
534 PathBuf::from("/test/timeline.edl"),
535 vec![],
536 PathBuf::from("/test/output"),
537 ConformConfig::default(),
538 )
539 .expect("test expectation failed");
540
541 assert_eq!(session.status(), SessionStatus::Created);
542 session.set_status(SessionStatus::Scanning);
543 assert_eq!(session.status(), SessionStatus::Scanning);
544 }
545
546 #[test]
549 fn test_watch_folder_no_new_files_initially() {
550 let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
551 std::fs::write(temp_dir.path().join("pre_existing.mov"), b"x")
553 .expect("write should succeed");
554
555 let session = WatchFolderSession::new(temp_dir.path().to_path_buf(), 5);
556 let new = session.check_for_new_sources();
558 assert!(
559 new.is_empty(),
560 "pre-existing files must not be reported as new, got {new:?}"
561 );
562 }
563
564 #[test]
565 fn test_watch_folder_detects_new_file() {
566 let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
567
568 let session = WatchFolderSession::new(temp_dir.path().to_path_buf(), 5);
569
570 assert!(session.check_for_new_sources().is_empty());
572
573 let new_file = temp_dir.path().join("shot_001.mov");
575 std::fs::write(&new_file, b"data").expect("write should succeed");
576
577 let new = session.check_for_new_sources();
578 assert_eq!(new.len(), 1, "new file should be detected");
579 assert_eq!(new[0], new_file);
580 }
581
582 #[test]
583 fn test_watch_folder_does_not_re_report_known_files() {
584 let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
585 let session = WatchFolderSession::new(temp_dir.path().to_path_buf(), 5);
586
587 std::fs::write(temp_dir.path().join("clip.mov"), b"data").expect("write should succeed");
589 let first = session.check_for_new_sources();
590 assert_eq!(first.len(), 1);
591
592 let second = session.check_for_new_sources();
594 assert!(
595 second.is_empty(),
596 "already-reported file must not be reported again"
597 );
598 }
599
600 #[test]
601 fn test_watch_folder_detects_multiple_new_files() {
602 let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
603 let session = WatchFolderSession::new(temp_dir.path().to_path_buf(), 1);
604
605 assert!(session.check_for_new_sources().is_empty());
607
608 for i in 0..3u8 {
610 std::fs::write(temp_dir.path().join(format!("clip_{i}.mov")), b"data")
611 .expect("write should succeed");
612 }
613
614 let new = session.check_for_new_sources();
615 assert_eq!(new.len(), 3, "all three new files should be detected");
616 }
617}