use super::{Collection, CollectionId};
use crate::clip::Clip;
use crate::logging::Rating;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SmartCollection {
pub collection: Collection,
pub rules: Vec<SmartRule>,
pub match_mode: MatchMode,
pub auto_update: bool,
pub last_updated: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatchMode {
All,
Any,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SmartRule {
Keyword {
keyword: String,
},
Rating {
operator: Comparison,
value: Rating,
},
IsFavorite {
is_favorite: bool,
},
IsRejected {
is_rejected: bool,
},
FileName {
pattern: String,
},
Duration {
operator: Comparison,
frames: i64,
},
CreatedDate {
operator: Comparison,
date: DateTime<Utc>,
},
ModifiedDate {
operator: Comparison,
date: DateTime<Utc>,
},
HasMarkers,
HasNotes,
CustomMetadata {
key: String,
value: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Comparison {
Equal,
NotEqual,
GreaterThan,
GreaterThanOrEqual,
LessThan,
LessThanOrEqual,
}
impl SmartCollection {
#[must_use]
pub fn new(name: impl Into<String>, rules: Vec<SmartRule>, match_mode: MatchMode) -> Self {
Self {
collection: Collection::new(name),
rules,
match_mode,
auto_update: true,
last_updated: Utc::now(),
}
}
#[must_use]
pub const fn id(&self) -> CollectionId {
self.collection.id
}
#[must_use]
pub fn matches(&self, clip: &Clip) -> bool {
if self.rules.is_empty() {
return false;
}
match self.match_mode {
MatchMode::All => self.rules.iter().all(|rule| rule.matches(clip)),
MatchMode::Any => self.rules.iter().any(|rule| rule.matches(clip)),
}
}
pub fn update(&mut self, clips: &[Clip]) {
self.collection.clear();
for clip in clips {
if self.matches(clip) {
self.collection.add_clip(clip.id);
}
}
self.last_updated = Utc::now();
}
pub fn add_rule(&mut self, rule: SmartRule) {
self.rules.push(rule);
}
pub fn remove_rule(&mut self, index: usize) -> Option<SmartRule> {
if index < self.rules.len() {
Some(self.rules.remove(index))
} else {
None
}
}
pub fn set_match_mode(&mut self, mode: MatchMode) {
self.match_mode = mode;
}
}
impl SmartRule {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn matches(&self, clip: &Clip) -> bool {
match self {
Self::Keyword { keyword } => clip.keywords.contains(keyword),
Self::Rating { operator, value } => match operator {
Comparison::Equal => clip.rating == *value,
Comparison::NotEqual => clip.rating != *value,
Comparison::GreaterThan => clip.rating > *value,
Comparison::GreaterThanOrEqual => clip.rating >= *value,
Comparison::LessThan => clip.rating < *value,
Comparison::LessThanOrEqual => clip.rating <= *value,
},
Self::IsFavorite { is_favorite } => clip.is_favorite == *is_favorite,
Self::IsRejected { is_rejected } => clip.is_rejected == *is_rejected,
Self::FileName { pattern } => clip
.file_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.contains(pattern)),
Self::Duration { operator, frames } => {
if let Some(duration) = clip.effective_duration() {
match operator {
Comparison::Equal => duration == *frames,
Comparison::NotEqual => duration != *frames,
Comparison::GreaterThan => duration > *frames,
Comparison::GreaterThanOrEqual => duration >= *frames,
Comparison::LessThan => duration < *frames,
Comparison::LessThanOrEqual => duration <= *frames,
}
} else {
false
}
}
Self::CreatedDate { operator, date } => match operator {
Comparison::Equal => clip.created_at == *date,
Comparison::NotEqual => clip.created_at != *date,
Comparison::GreaterThan => clip.created_at > *date,
Comparison::GreaterThanOrEqual => clip.created_at >= *date,
Comparison::LessThan => clip.created_at < *date,
Comparison::LessThanOrEqual => clip.created_at <= *date,
},
Self::ModifiedDate { operator, date } => match operator {
Comparison::Equal => clip.modified_at == *date,
Comparison::NotEqual => clip.modified_at != *date,
Comparison::GreaterThan => clip.modified_at > *date,
Comparison::GreaterThanOrEqual => clip.modified_at >= *date,
Comparison::LessThan => clip.modified_at < *date,
Comparison::LessThanOrEqual => clip.modified_at <= *date,
},
Self::HasMarkers => !clip.markers.is_empty(),
Self::HasNotes => false,
Self::CustomMetadata { key, value } => clip
.custom_metadata
.as_ref()
.and_then(|json| {
serde_json::from_str::<serde_json::Value>(json)
.ok()
.and_then(|v| v.get(key).and_then(|val| val.as_str().map(String::from)))
})
.is_some_and(|v| &v == value),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_smart_collection_keyword() {
let rule = SmartRule::Keyword {
keyword: "interview".to_string(),
};
let rules = vec![rule];
let smart = SmartCollection::new("Interviews", rules, MatchMode::All);
let mut clip = Clip::new(PathBuf::from("/test.mov"));
clip.add_keyword("interview");
assert!(smart.matches(&clip));
}
#[test]
fn test_smart_collection_rating() {
let rule = SmartRule::Rating {
operator: Comparison::GreaterThanOrEqual,
value: Rating::FourStars,
};
let smart = SmartCollection::new("High Rated", vec![rule], MatchMode::All);
let mut clip = Clip::new(PathBuf::from("/test.mov"));
clip.set_rating(Rating::FiveStars);
assert!(smart.matches(&clip));
}
#[test]
fn test_smart_collection_match_modes() {
let rules = vec![
SmartRule::IsFavorite { is_favorite: true },
SmartRule::Rating {
operator: Comparison::GreaterThanOrEqual,
value: Rating::FourStars,
},
];
let smart_all = SmartCollection::new("Test All", rules.clone(), MatchMode::All);
let smart_any = SmartCollection::new("Test Any", rules, MatchMode::Any);
let mut clip = Clip::new(PathBuf::from("/test.mov"));
clip.set_favorite(true);
assert!(smart_any.matches(&clip));
assert!(!smart_all.matches(&clip));
clip.set_rating(Rating::FourStars);
assert!(smart_all.matches(&clip));
}
}