use crate::config::ConformConfig;
use crate::types::{ClipMatch, ClipReference, MatchMethod, MediaFile};
use dashmap::DashMap;
use rayon::prelude::*;
use std::sync::Arc;
use super::content::duration_match;
use super::filename::{exact_filename_match, fuzzy_filename_match, relink_proxy_match};
use super::timecode::timecode_match;
pub struct MatchStrategy {
config: Arc<ConformConfig>,
}
impl MatchStrategy {
#[must_use]
pub fn new(config: ConformConfig) -> Self {
Self {
config: Arc::new(config),
}
}
#[must_use]
pub fn match_clip(&self, clip: &ClipReference, all_media: &[MediaFile]) -> Vec<ClipMatch> {
let mut all_matches = Vec::new();
let mut matches = exact_filename_match(clip, all_media);
all_matches.append(&mut matches);
if self.config.fuzzy_matching {
let mut matches = fuzzy_filename_match(clip, all_media, &self.config);
all_matches.append(&mut matches);
}
if self.config.auto_relink {
let mut matches = relink_proxy_match(clip, all_media, &self.config);
all_matches.append(&mut matches);
}
if self.config.timecode_matching {
let mut matches = timecode_match(clip, all_media, &self.config);
all_matches.append(&mut matches);
}
if self.config.duration_matching {
let mut matches = duration_match(clip, all_media, &self.config);
all_matches.append(&mut matches);
}
all_matches.retain(|m| m.score >= self.config.match_threshold);
all_matches.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
self.deduplicate_matches(all_matches)
}
#[must_use]
pub fn match_clips(
&self,
clips: &[ClipReference],
all_media: &[MediaFile],
) -> Vec<Vec<ClipMatch>> {
clips
.par_iter()
.map(|clip| self.match_clip(clip, all_media))
.collect()
}
fn deduplicate_matches(&self, matches: Vec<ClipMatch>) -> Vec<ClipMatch> {
let best_matches: DashMap<uuid::Uuid, ClipMatch> = DashMap::new();
for m in matches {
let media_id = m.media.id;
best_matches
.entry(media_id)
.and_modify(|existing| {
if m.score > existing.score {
*existing = m.clone();
}
})
.or_insert(m);
}
let mut result: Vec<ClipMatch> = best_matches.into_iter().map(|(_, v)| v).collect();
result.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
result
}
#[must_use]
pub fn get_best_match(
&self,
clip: &ClipReference,
all_media: &[MediaFile],
) -> Option<ClipMatch> {
let matches = self.match_clip(clip, all_media);
matches.into_iter().next()
}
#[must_use]
pub fn is_ambiguous(&self, matches: &[ClipMatch], tolerance: f64) -> bool {
if matches.len() <= 1 {
return false;
}
if let Some(best) = matches.first() {
matches
.iter()
.skip(1)
.any(|m| (best.score - m.score).abs() < tolerance)
} else {
false
}
}
#[must_use]
pub fn combine_matches(&self, matches: Vec<ClipMatch>) -> Option<ClipMatch> {
if matches.is_empty() {
return None;
}
if matches.len() == 1 {
return matches.into_iter().next();
}
let first_media_id = matches[0].media.id;
if matches.iter().all(|m| m.media.id == first_media_id) {
let combined_score: f64 =
matches.iter().map(|m| m.score).sum::<f64>() / matches.len() as f64;
let methods: Vec<String> = matches.iter().map(|m| m.method.to_string()).collect();
let details = format!("Combined match from: {}", methods.join(", "));
Some(ClipMatch {
clip: matches[0].clip.clone(),
media: matches[0].media.clone(),
score: combined_score,
method: MatchMethod::Combined,
details,
})
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{FrameRate, Timecode, TrackType};
use std::path::PathBuf;
fn create_test_clip(id: &str, source_file: &str) -> ClipReference {
ClipReference {
id: id.to_string(),
source_file: Some(source_file.to_string()),
source_in: Timecode::new(1, 0, 0, 0),
source_out: Timecode::new(1, 0, 10, 0),
record_in: Timecode::new(1, 0, 0, 0),
record_out: Timecode::new(1, 0, 10, 0),
track: TrackType::Video,
fps: FrameRate::Fps25,
metadata: std::collections::HashMap::new(),
}
}
#[test]
fn test_match_strategy_exact() {
let config = ConformConfig::default();
let strategy = MatchStrategy::new(config);
let clip = create_test_clip("clip1", "test.mov");
let media = MediaFile::new(PathBuf::from("/path/test.mov"));
let matches = strategy.match_clip(&clip, &[media]);
assert!(!matches.is_empty());
}
#[test]
fn test_match_clips_parallel() {
let config = ConformConfig::default();
let strategy = MatchStrategy::new(config);
let clips = vec![
create_test_clip("clip1", "test1.mov"),
create_test_clip("clip2", "test2.mov"),
];
let media = vec![
MediaFile::new(PathBuf::from("/path/test1.mov")),
MediaFile::new(PathBuf::from("/path/test2.mov")),
];
let results = strategy.match_clips(&clips, &media);
assert_eq!(results.len(), 2);
}
#[test]
fn test_get_best_match() {
let config = ConformConfig::default();
let strategy = MatchStrategy::new(config);
let clip = create_test_clip("clip1", "test.mov");
let media = MediaFile::new(PathBuf::from("/path/test.mov"));
let best = strategy.get_best_match(&clip, &[media]);
assert!(best.is_some());
}
#[test]
fn test_is_ambiguous() {
let config = ConformConfig::default();
let strategy = MatchStrategy::new(config);
let clip = create_test_clip("clip1", "test.mov");
let media1 = MediaFile::new(PathBuf::from("/path/test1.mov"));
let media2 = MediaFile::new(PathBuf::from("/path/test2.mov"));
let matches = vec![
ClipMatch {
clip: clip.clone(),
media: media1,
score: 0.9,
method: MatchMethod::ExactFilename,
details: String::new(),
},
ClipMatch {
clip,
media: media2,
score: 0.89,
method: MatchMethod::FuzzyFilename,
details: String::new(),
},
];
assert!(strategy.is_ambiguous(&matches, 0.05));
assert!(!strategy.is_ambiguous(&matches, 0.005));
}
}