Skip to main content

oximedia_clips/group/
smart.rs

1//! Smart collection with auto-updating rules.
2
3use 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/// A smart collection that auto-updates based on rules.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SmartCollection {
13    /// Base collection.
14    pub collection: Collection,
15
16    /// Rules for matching clips.
17    pub rules: Vec<SmartRule>,
18
19    /// Match mode.
20    pub match_mode: MatchMode,
21
22    /// Auto-update enabled.
23    pub auto_update: bool,
24
25    /// Last update timestamp.
26    pub last_updated: DateTime<Utc>,
27
28    /// Polling interval for auto-refresh in seconds. `None` means no polling.
29    #[serde(default)]
30    pub poll_interval_secs: Option<u64>,
31
32    /// Cached result: clip IDs that currently match the rules.
33    #[serde(default)]
34    pub cached_clip_ids: Vec<crate::clip::ClipId>,
35
36    /// Whether the cache is considered valid.
37    #[serde(default)]
38    pub cache_valid: bool,
39}
40
41/// Rule match mode.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum MatchMode {
44    /// Match all rules (AND).
45    All,
46    /// Match any rule (OR).
47    Any,
48}
49
50/// A rule for smart collection matching.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum SmartRule {
53    /// Match by keyword.
54    Keyword {
55        /// Keyword to match.
56        keyword: String,
57    },
58
59    /// Match by rating.
60    Rating {
61        /// Comparison operator.
62        operator: Comparison,
63        /// Rating value.
64        value: Rating,
65    },
66
67    /// Match by favorite status.
68    IsFavorite {
69        /// Whether the clip is favorite.
70        is_favorite: bool,
71    },
72
73    /// Match by rejected status.
74    IsRejected {
75        /// Whether the clip is rejected.
76        is_rejected: bool,
77    },
78
79    /// Match by file name pattern.
80    FileName {
81        /// File name pattern.
82        pattern: String,
83    },
84
85    /// Match by duration.
86    Duration {
87        /// Comparison operator.
88        operator: Comparison,
89        /// Duration in frames.
90        frames: i64,
91    },
92
93    /// Match by creation date.
94    CreatedDate {
95        /// Comparison operator.
96        operator: Comparison,
97        /// Creation date.
98        date: DateTime<Utc>,
99    },
100
101    /// Match by modification date.
102    ModifiedDate {
103        /// Comparison operator.
104        operator: Comparison,
105        /// Modification date.
106        date: DateTime<Utc>,
107    },
108
109    /// Match clips with markers.
110    HasMarkers,
111
112    /// Match clips with notes.
113    HasNotes,
114
115    /// Match by custom metadata field.
116    CustomMetadata {
117        /// Metadata key.
118        key: String,
119        /// Metadata value.
120        value: String,
121    },
122}
123
124/// Comparison operator for rules.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
126pub enum Comparison {
127    /// Equal to.
128    Equal,
129    /// Not equal to.
130    NotEqual,
131    /// Greater than.
132    GreaterThan,
133    /// Greater than or equal.
134    GreaterThanOrEqual,
135    /// Less than.
136    LessThan,
137    /// Less than or equal.
138    LessThanOrEqual,
139}
140
141impl SmartCollection {
142    /// Creates a new smart collection.
143    #[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    /// Sets the polling interval for auto-refresh.
158    ///
159    /// When set, `needs_refresh()` will return `true` if more than `interval`
160    /// has elapsed since the last update.
161    pub fn set_poll_interval(&mut self, interval: Duration) {
162        self.poll_interval_secs = Some(interval.as_secs().max(1));
163    }
164
165    /// Clears the polling interval (disables timed auto-refresh).
166    pub fn clear_poll_interval(&mut self) {
167        self.poll_interval_secs = None;
168    }
169
170    /// Returns the configured poll interval, if any.
171    #[must_use]
172    pub fn poll_interval(&self) -> Option<Duration> {
173        self.poll_interval_secs.map(Duration::from_secs)
174    }
175
176    /// Returns `true` if the collection should be refreshed.
177    ///
178    /// Criteria:
179    /// - `auto_update` is enabled, AND
180    /// - either the cache is marked invalid, OR the poll interval has elapsed.
181    #[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    /// Invalidates the cached results, forcing the next call to `needs_refresh`
199    /// (when `auto_update` is enabled) to return `true`.
200    pub fn invalidate_cache(&mut self) {
201        self.cache_valid = false;
202        self.cached_clip_ids.clear();
203    }
204
205    /// Returns the cached clip IDs if the cache is valid.
206    #[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    /// Returns the collection ID.
216    #[must_use]
217    pub const fn id(&self) -> CollectionId {
218        self.collection.id
219    }
220
221    /// Checks if a clip matches the smart collection rules.
222    #[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    /// Updates the collection by evaluating all clips.
235    ///
236    /// Also refreshes the internal cache so that `cached_clip_ids()` returns
237    /// the up-to-date list.
238    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    /// Updates the collection only if `needs_refresh()` returns `true`.
254    ///
255    /// Returns `true` if a refresh was performed.
256    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    /// Adds a rule.
266    pub fn add_rule(&mut self, rule: SmartRule) {
267        self.rules.push(rule);
268    }
269
270    /// Removes a rule at index.
271    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    /// Sets the match mode.
280    pub fn set_match_mode(&mut self, mode: MatchMode) {
281        self.match_mode = mode;
282    }
283}
284
285impl SmartRule {
286    /// Checks if a clip matches this rule.
287    #[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, // Would need access to note database
348
349            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        // Matches ANY but not ALL
412        assert!(smart_any.matches(&clip));
413        assert!(!smart_all.matches(&clip));
414
415        clip.set_rating(Rating::FourStars);
416
417        // Now matches ALL
418        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        // Fresh collection with no cache should need refresh (cache_valid = false).
429        assert!(smart.needs_refresh());
430
431        // After update the cache is valid, no polling interval set → no refresh needed.
432        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        // Invalidate the cache explicitly.
449        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        // No clips initially.
493        smart.update(&[]);
494        assert_eq!(smart.collection.count(), 0);
495
496        // Add a matching clip and re-update.
497        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        // First refresh always runs (cache invalid).
512        let refreshed = smart.refresh_if_needed(&clips);
513        assert!(refreshed);
514
515        // Second call: cache is valid, no polling interval → skip.
516        let refreshed = smart.refresh_if_needed(&clips);
517        assert!(!refreshed);
518    }
519}