use super::{Collection, CollectionId};
use crate::clip::Clip;
use crate::logging::Rating;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[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>,
#[serde(default)]
pub poll_interval_secs: Option<u64>,
#[serde(default)]
pub cached_clip_ids: Vec<crate::clip::ClipId>,
#[serde(default)]
pub cache_valid: bool,
}
#[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(),
poll_interval_secs: None,
cached_clip_ids: Vec::new(),
cache_valid: false,
}
}
pub fn set_poll_interval(&mut self, interval: Duration) {
self.poll_interval_secs = Some(interval.as_secs().max(1));
}
pub fn clear_poll_interval(&mut self) {
self.poll_interval_secs = None;
}
#[must_use]
pub fn poll_interval(&self) -> Option<Duration> {
self.poll_interval_secs.map(Duration::from_secs)
}
#[must_use]
pub fn needs_refresh(&self) -> bool {
if !self.auto_update {
return false;
}
if !self.cache_valid {
return true;
}
if let Some(interval_secs) = self.poll_interval_secs {
let elapsed = Utc::now()
.signed_duration_since(self.last_updated)
.num_seconds();
return elapsed >= interval_secs as i64;
}
false
}
pub fn invalidate_cache(&mut self) {
self.cache_valid = false;
self.cached_clip_ids.clear();
}
#[must_use]
pub fn cached_clip_ids(&self) -> Option<&[crate::clip::ClipId]> {
if self.cache_valid {
Some(&self.cached_clip_ids)
} else {
None
}
}
#[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();
self.cached_clip_ids.clear();
for clip in clips {
if self.matches(clip) {
self.collection.add_clip(clip.id);
self.cached_clip_ids.push(clip.id);
}
}
self.last_updated = Utc::now();
self.cache_valid = true;
}
pub fn refresh_if_needed(&mut self, clips: &[Clip]) -> bool {
if self.needs_refresh() {
self.update(clips);
true
} else {
false
}
}
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));
}
#[test]
fn test_smart_collection_auto_refresh_needs_refresh_when_cache_invalid() {
let rule = SmartRule::Keyword {
keyword: "interview".to_string(),
};
let mut smart = SmartCollection::new("Interviews", vec![rule], MatchMode::All);
assert!(smart.needs_refresh());
let clips: Vec<Clip> = Vec::new();
smart.update(&clips);
assert!(!smart.needs_refresh());
}
#[test]
fn test_smart_collection_invalidate_cache() {
let rule = SmartRule::Keyword {
keyword: "outdoor".to_string(),
};
let mut smart = SmartCollection::new("Outdoor", vec![rule], MatchMode::All);
let clips: Vec<Clip> = Vec::new();
smart.update(&clips);
assert!(!smart.needs_refresh());
smart.invalidate_cache();
assert!(smart.needs_refresh());
assert!(smart.cached_clip_ids().is_none());
}
#[test]
fn test_smart_collection_poll_interval_accessors() {
let rule = SmartRule::HasMarkers;
let mut smart = SmartCollection::new("Marked", vec![rule], MatchMode::All);
assert!(smart.poll_interval().is_none());
smart.set_poll_interval(Duration::from_secs(60));
assert_eq!(smart.poll_interval(), Some(Duration::from_secs(60)));
smart.clear_poll_interval();
assert!(smart.poll_interval().is_none());
}
#[test]
fn test_smart_collection_cache_populated_after_update() {
let rule = SmartRule::Keyword {
keyword: "interview".to_string(),
};
let mut smart = SmartCollection::new("Interviews", vec![rule], MatchMode::All);
let mut clip = Clip::new(PathBuf::from("/test.mov"));
clip.add_keyword("interview");
smart.update(&[clip.clone()]);
let cached = smart.cached_clip_ids().expect("cache should be valid");
assert_eq!(cached.len(), 1);
assert_eq!(cached[0], clip.id);
}
#[test]
fn test_smart_collection_auto_update_new_clips() {
let rule = SmartRule::Keyword {
keyword: "broll".to_string(),
};
let mut smart = SmartCollection::new("B-Roll", vec![rule], MatchMode::All);
smart.update(&[]);
assert_eq!(smart.collection.count(), 0);
let mut clip = Clip::new(PathBuf::from("/broll.mov"));
clip.add_keyword("broll");
smart.update(&[clip]);
assert_eq!(smart.collection.count(), 1);
}
#[test]
fn test_refresh_if_needed_skips_when_not_needed() {
let rule = SmartRule::Keyword {
keyword: "action".to_string(),
};
let mut smart = SmartCollection::new("Action", vec![rule], MatchMode::All);
let clips: Vec<Clip> = Vec::new();
let refreshed = smart.refresh_if_needed(&clips);
assert!(refreshed);
let refreshed = smart.refresh_if_needed(&clips);
assert!(!refreshed);
}
}