Skip to main content

oximedia_conform/
session.rs

1//! Conform session management.
2
3use 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/// Conform session status.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum SessionStatus {
22    /// Session created but not started.
23    Created,
24    /// Scanning source media.
25    Scanning,
26    /// Matching clips.
27    Matching,
28    /// Conforming.
29    Conforming,
30    /// Exporting.
31    Exporting,
32    /// Completed successfully.
33    Completed,
34    /// Failed.
35    Failed,
36}
37
38/// A conform session that manages the entire conforming workflow.
39pub struct ConformSession {
40    /// Unique session ID.
41    id: Uuid,
42    /// Session name.
43    name: String,
44    /// EDL or timeline path.
45    timeline_path: PathBuf,
46    /// Source media paths to scan.
47    source_paths: Vec<PathBuf>,
48    /// Output path for conformed sequence.
49    output_path: PathBuf,
50    /// Configuration.
51    config: ConformConfig,
52    /// Media catalog.
53    catalog: MediaCatalog,
54    /// Clips to match.
55    clips: Vec<ClipReference>,
56    /// Match results.
57    matches: Arc<RwLock<Vec<ClipMatch>>>,
58    /// Missing clips.
59    missing: Arc<RwLock<Vec<ClipReference>>>,
60    /// Ambiguous matches.
61    ambiguous: Arc<RwLock<Vec<AmbiguousMatch>>>,
62    /// Session status.
63    status: Arc<RwLock<SessionStatus>>,
64}
65
66impl ConformSession {
67    /// Create a new conform session.
68    ///
69    /// # Errors
70    ///
71    /// Returns an error if the session cannot be created.
72    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    /// Create a new conform session with a database.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the session cannot be created.
103    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    /// Get the session ID.
131    #[must_use]
132    pub fn id(&self) -> Uuid {
133        self.id
134    }
135
136    /// Get the session name.
137    #[must_use]
138    pub fn name(&self) -> &str {
139        &self.name
140    }
141
142    /// Get the current status.
143    #[must_use]
144    pub fn status(&self) -> SessionStatus {
145        *self.status.read()
146    }
147
148    /// Set the status.
149    fn set_status(&self, status: SessionStatus) {
150        *self.status.write() = status;
151    }
152
153    /// Load clips from the timeline file.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the timeline cannot be loaded.
158    pub async fn load_timeline(&mut self) -> ConformResult<()> {
159        info!("Loading timeline from {}", self.timeline_path.display());
160
161        // Detect format and import
162        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    /// Scan source media directories.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if scanning fails.
181    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    /// Match clips to media files.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if matching fails.
208    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                // Multiple matches - check if ambiguous
233                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                    // Use best match
245                    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    /// Validate matches.
269    ///
270    /// # Errors
271    ///
272    /// Returns an error if validation fails.
273    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    /// Perform conforming.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if conforming fails.
307    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        // Build timeline
317        let fps = matches[0].clip.fps;
318        let mut timeline = Timeline::new(self.name.clone(), fps);
319
320        // Create video track
321        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        // Create audio track
332        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    /// Export the conformed sequence.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if export fails.
357    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    /// Generate a match report.
386    #[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    /// Run the complete conform workflow.
396    ///
397    /// # Errors
398    ///
399    /// Returns an error if any step fails.
400    pub async fn run(&mut self) -> ConformResult<MatchReport> {
401        info!("Starting conform session: {}", self.name);
402
403        // Load timeline
404        self.load_timeline().await?;
405
406        // Scan sources
407        self.scan_sources().await?;
408
409        // Match clips
410        let report = self.match_clips().await?;
411
412        // Validate
413        self.validate()?;
414
415        // Conform
416        self.conform().await?;
417
418        info!("Conform session complete");
419        Ok(report)
420    }
421}
422
423// ── Watch folder mode ─────────────────────────────────────────────────────────
424
425/// A lightweight watch-folder session that polls a directory at a fixed
426/// interval and reports newly detected source files.
427///
428/// This is intentionally synchronous so it can be driven from a background
429/// thread or a polling loop without requiring an async runtime.
430pub struct WatchFolderSession {
431    /// Directory to poll for new source files.
432    pub poll_dir: PathBuf,
433    /// Polling interval in seconds.
434    pub poll_interval_secs: u64,
435    /// Known paths from the previous poll — used to detect new arrivals.
436    known_paths: parking_lot::Mutex<std::collections::HashSet<PathBuf>>,
437}
438
439impl WatchFolderSession {
440    /// Create a new `WatchFolderSession` for `poll_dir` with the given
441    /// polling interval.
442    ///
443    /// The set of "known" paths is initialised to the files present in
444    /// `poll_dir` at construction time so that pre-existing files are not
445    /// reported as new on the first call to [`check_for_new_sources`].
446    ///
447    /// [`check_for_new_sources`]: WatchFolderSession::check_for_new_sources
448    #[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    /// Poll `poll_dir` once and return paths of **newly detected** source
459    /// files (files present now that were not present on the previous call).
460    ///
461    /// After this call the internal set of known paths is updated to include
462    /// all currently visible files.
463    #[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        // Update known set with everything we see now.
475        *known = current;
476        new_paths
477    }
478}
479
480/// Collect all regular-file paths inside `dir` (non-recursive, depth=1).
481fn 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    // ── WatchFolderSession tests ──────────────────────────────────────────────
547
548    #[test]
549    fn test_watch_folder_no_new_files_initially() {
550        let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
551        // Place a file before constructing the session
552        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        // On first check, nothing should be new (pre-existing files are known)
557        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        // No files yet — first check returns nothing
571        assert!(session.check_for_new_sources().is_empty());
572
573        // Add a new file
574        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        // Introduce a file and consume the notification
588        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        // Second call must not re-report the same file
593        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        // Baseline check
606        assert!(session.check_for_new_sources().is_empty());
607
608        // Add three files
609        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}