1use super::{Collection, CollectionId};
4use crate::clip::Clip;
5use crate::logging::Rating;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SmartCollection {
13 pub collection: Collection,
15
16 pub rules: Vec<SmartRule>,
18
19 pub match_mode: MatchMode,
21
22 pub auto_update: bool,
24
25 pub last_updated: DateTime<Utc>,
27
28 #[serde(default)]
30 pub poll_interval_secs: Option<u64>,
31
32 #[serde(default)]
34 pub cached_clip_ids: Vec<crate::clip::ClipId>,
35
36 #[serde(default)]
38 pub cache_valid: bool,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum MatchMode {
44 All,
46 Any,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum SmartRule {
53 Keyword {
55 keyword: String,
57 },
58
59 Rating {
61 operator: Comparison,
63 value: Rating,
65 },
66
67 IsFavorite {
69 is_favorite: bool,
71 },
72
73 IsRejected {
75 is_rejected: bool,
77 },
78
79 FileName {
81 pattern: String,
83 },
84
85 Duration {
87 operator: Comparison,
89 frames: i64,
91 },
92
93 CreatedDate {
95 operator: Comparison,
97 date: DateTime<Utc>,
99 },
100
101 ModifiedDate {
103 operator: Comparison,
105 date: DateTime<Utc>,
107 },
108
109 HasMarkers,
111
112 HasNotes,
114
115 CustomMetadata {
117 key: String,
119 value: String,
121 },
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub enum Comparison {
127 Equal,
129 NotEqual,
131 GreaterThan,
133 GreaterThanOrEqual,
135 LessThan,
137 LessThanOrEqual,
139}
140
141impl SmartCollection {
142 #[must_use]
144 pub fn new(name: impl Into<String>, rules: Vec<SmartRule>, match_mode: MatchMode) -> Self {
145 Self {
146 collection: Collection::new(name),
147 rules,
148 match_mode,
149 auto_update: true,
150 last_updated: Utc::now(),
151 poll_interval_secs: None,
152 cached_clip_ids: Vec::new(),
153 cache_valid: false,
154 }
155 }
156
157 pub fn set_poll_interval(&mut self, interval: Duration) {
162 self.poll_interval_secs = Some(interval.as_secs().max(1));
163 }
164
165 pub fn clear_poll_interval(&mut self) {
167 self.poll_interval_secs = None;
168 }
169
170 #[must_use]
172 pub fn poll_interval(&self) -> Option<Duration> {
173 self.poll_interval_secs.map(Duration::from_secs)
174 }
175
176 #[must_use]
182 pub fn needs_refresh(&self) -> bool {
183 if !self.auto_update {
184 return false;
185 }
186 if !self.cache_valid {
187 return true;
188 }
189 if let Some(interval_secs) = self.poll_interval_secs {
190 let elapsed = Utc::now()
191 .signed_duration_since(self.last_updated)
192 .num_seconds();
193 return elapsed >= interval_secs as i64;
194 }
195 false
196 }
197
198 pub fn invalidate_cache(&mut self) {
201 self.cache_valid = false;
202 self.cached_clip_ids.clear();
203 }
204
205 #[must_use]
207 pub fn cached_clip_ids(&self) -> Option<&[crate::clip::ClipId]> {
208 if self.cache_valid {
209 Some(&self.cached_clip_ids)
210 } else {
211 None
212 }
213 }
214
215 #[must_use]
217 pub const fn id(&self) -> CollectionId {
218 self.collection.id
219 }
220
221 #[must_use]
223 pub fn matches(&self, clip: &Clip) -> bool {
224 if self.rules.is_empty() {
225 return false;
226 }
227
228 match self.match_mode {
229 MatchMode::All => self.rules.iter().all(|rule| rule.matches(clip)),
230 MatchMode::Any => self.rules.iter().any(|rule| rule.matches(clip)),
231 }
232 }
233
234 pub fn update(&mut self, clips: &[Clip]) {
239 self.collection.clear();
240 self.cached_clip_ids.clear();
241
242 for clip in clips {
243 if self.matches(clip) {
244 self.collection.add_clip(clip.id);
245 self.cached_clip_ids.push(clip.id);
246 }
247 }
248
249 self.last_updated = Utc::now();
250 self.cache_valid = true;
251 }
252
253 pub fn refresh_if_needed(&mut self, clips: &[Clip]) -> bool {
257 if self.needs_refresh() {
258 self.update(clips);
259 true
260 } else {
261 false
262 }
263 }
264
265 pub fn add_rule(&mut self, rule: SmartRule) {
267 self.rules.push(rule);
268 }
269
270 pub fn remove_rule(&mut self, index: usize) -> Option<SmartRule> {
272 if index < self.rules.len() {
273 Some(self.rules.remove(index))
274 } else {
275 None
276 }
277 }
278
279 pub fn set_match_mode(&mut self, mode: MatchMode) {
281 self.match_mode = mode;
282 }
283}
284
285impl SmartRule {
286 #[must_use]
288 #[allow(clippy::too_many_lines)]
289 pub fn matches(&self, clip: &Clip) -> bool {
290 match self {
291 Self::Keyword { keyword } => clip.keywords.contains(keyword),
292
293 Self::Rating { operator, value } => match operator {
294 Comparison::Equal => clip.rating == *value,
295 Comparison::NotEqual => clip.rating != *value,
296 Comparison::GreaterThan => clip.rating > *value,
297 Comparison::GreaterThanOrEqual => clip.rating >= *value,
298 Comparison::LessThan => clip.rating < *value,
299 Comparison::LessThanOrEqual => clip.rating <= *value,
300 },
301
302 Self::IsFavorite { is_favorite } => clip.is_favorite == *is_favorite,
303
304 Self::IsRejected { is_rejected } => clip.is_rejected == *is_rejected,
305
306 Self::FileName { pattern } => clip
307 .file_path
308 .file_name()
309 .and_then(|n| n.to_str())
310 .is_some_and(|name| name.contains(pattern)),
311
312 Self::Duration { operator, frames } => {
313 if let Some(duration) = clip.effective_duration() {
314 match operator {
315 Comparison::Equal => duration == *frames,
316 Comparison::NotEqual => duration != *frames,
317 Comparison::GreaterThan => duration > *frames,
318 Comparison::GreaterThanOrEqual => duration >= *frames,
319 Comparison::LessThan => duration < *frames,
320 Comparison::LessThanOrEqual => duration <= *frames,
321 }
322 } else {
323 false
324 }
325 }
326
327 Self::CreatedDate { operator, date } => match operator {
328 Comparison::Equal => clip.created_at == *date,
329 Comparison::NotEqual => clip.created_at != *date,
330 Comparison::GreaterThan => clip.created_at > *date,
331 Comparison::GreaterThanOrEqual => clip.created_at >= *date,
332 Comparison::LessThan => clip.created_at < *date,
333 Comparison::LessThanOrEqual => clip.created_at <= *date,
334 },
335
336 Self::ModifiedDate { operator, date } => match operator {
337 Comparison::Equal => clip.modified_at == *date,
338 Comparison::NotEqual => clip.modified_at != *date,
339 Comparison::GreaterThan => clip.modified_at > *date,
340 Comparison::GreaterThanOrEqual => clip.modified_at >= *date,
341 Comparison::LessThan => clip.modified_at < *date,
342 Comparison::LessThanOrEqual => clip.modified_at <= *date,
343 },
344
345 Self::HasMarkers => !clip.markers.is_empty(),
346
347 Self::HasNotes => false, Self::CustomMetadata { key, value } => clip
350 .custom_metadata
351 .as_ref()
352 .and_then(|json| {
353 serde_json::from_str::<serde_json::Value>(json)
354 .ok()
355 .and_then(|v| v.get(key).and_then(|val| val.as_str().map(String::from)))
356 })
357 .is_some_and(|v| &v == value),
358 }
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use std::path::PathBuf;
366
367 #[test]
368 fn test_smart_collection_keyword() {
369 let rule = SmartRule::Keyword {
370 keyword: "interview".to_string(),
371 };
372 let rules = vec![rule];
373 let smart = SmartCollection::new("Interviews", rules, MatchMode::All);
374
375 let mut clip = Clip::new(PathBuf::from("/test.mov"));
376 clip.add_keyword("interview");
377
378 assert!(smart.matches(&clip));
379 }
380
381 #[test]
382 fn test_smart_collection_rating() {
383 let rule = SmartRule::Rating {
384 operator: Comparison::GreaterThanOrEqual,
385 value: Rating::FourStars,
386 };
387 let smart = SmartCollection::new("High Rated", vec![rule], MatchMode::All);
388
389 let mut clip = Clip::new(PathBuf::from("/test.mov"));
390 clip.set_rating(Rating::FiveStars);
391
392 assert!(smart.matches(&clip));
393 }
394
395 #[test]
396 fn test_smart_collection_match_modes() {
397 let rules = vec![
398 SmartRule::IsFavorite { is_favorite: true },
399 SmartRule::Rating {
400 operator: Comparison::GreaterThanOrEqual,
401 value: Rating::FourStars,
402 },
403 ];
404
405 let smart_all = SmartCollection::new("Test All", rules.clone(), MatchMode::All);
406 let smart_any = SmartCollection::new("Test Any", rules, MatchMode::Any);
407
408 let mut clip = Clip::new(PathBuf::from("/test.mov"));
409 clip.set_favorite(true);
410
411 assert!(smart_any.matches(&clip));
413 assert!(!smart_all.matches(&clip));
414
415 clip.set_rating(Rating::FourStars);
416
417 assert!(smart_all.matches(&clip));
419 }
420
421 #[test]
422 fn test_smart_collection_auto_refresh_needs_refresh_when_cache_invalid() {
423 let rule = SmartRule::Keyword {
424 keyword: "interview".to_string(),
425 };
426 let mut smart = SmartCollection::new("Interviews", vec![rule], MatchMode::All);
427
428 assert!(smart.needs_refresh());
430
431 let clips: Vec<Clip> = Vec::new();
433 smart.update(&clips);
434 assert!(!smart.needs_refresh());
435 }
436
437 #[test]
438 fn test_smart_collection_invalidate_cache() {
439 let rule = SmartRule::Keyword {
440 keyword: "outdoor".to_string(),
441 };
442 let mut smart = SmartCollection::new("Outdoor", vec![rule], MatchMode::All);
443
444 let clips: Vec<Clip> = Vec::new();
445 smart.update(&clips);
446 assert!(!smart.needs_refresh());
447
448 smart.invalidate_cache();
450 assert!(smart.needs_refresh());
451 assert!(smart.cached_clip_ids().is_none());
452 }
453
454 #[test]
455 fn test_smart_collection_poll_interval_accessors() {
456 let rule = SmartRule::HasMarkers;
457 let mut smart = SmartCollection::new("Marked", vec![rule], MatchMode::All);
458
459 assert!(smart.poll_interval().is_none());
460
461 smart.set_poll_interval(Duration::from_secs(60));
462 assert_eq!(smart.poll_interval(), Some(Duration::from_secs(60)));
463
464 smart.clear_poll_interval();
465 assert!(smart.poll_interval().is_none());
466 }
467
468 #[test]
469 fn test_smart_collection_cache_populated_after_update() {
470 let rule = SmartRule::Keyword {
471 keyword: "interview".to_string(),
472 };
473 let mut smart = SmartCollection::new("Interviews", vec![rule], MatchMode::All);
474
475 let mut clip = Clip::new(PathBuf::from("/test.mov"));
476 clip.add_keyword("interview");
477
478 smart.update(&[clip.clone()]);
479
480 let cached = smart.cached_clip_ids().expect("cache should be valid");
481 assert_eq!(cached.len(), 1);
482 assert_eq!(cached[0], clip.id);
483 }
484
485 #[test]
486 fn test_smart_collection_auto_update_new_clips() {
487 let rule = SmartRule::Keyword {
488 keyword: "broll".to_string(),
489 };
490 let mut smart = SmartCollection::new("B-Roll", vec![rule], MatchMode::All);
491
492 smart.update(&[]);
494 assert_eq!(smart.collection.count(), 0);
495
496 let mut clip = Clip::new(PathBuf::from("/broll.mov"));
498 clip.add_keyword("broll");
499 smart.update(&[clip]);
500 assert_eq!(smart.collection.count(), 1);
501 }
502
503 #[test]
504 fn test_refresh_if_needed_skips_when_not_needed() {
505 let rule = SmartRule::Keyword {
506 keyword: "action".to_string(),
507 };
508 let mut smart = SmartCollection::new("Action", vec![rule], MatchMode::All);
509 let clips: Vec<Clip> = Vec::new();
510
511 let refreshed = smart.refresh_if_needed(&clips);
513 assert!(refreshed);
514
515 let refreshed = smart.refresh_if_needed(&clips);
517 assert!(!refreshed);
518 }
519}