1use chrono::{DateTime, Duration, Utc};
4use image::{imageops::FilterType, DynamicImage};
5use rusqlite::Connection;
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8
9#[derive(Debug, Clone)]
11pub struct BurstGroup {
12 pub photo_ids: Vec<i64>,
14
15 pub start_time: DateTime<Utc>,
17
18 pub end_time: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone)]
23pub struct BurstProgress {
24 pub processed: u64,
25 pub total: u64,
26 pub groups_found: u64,
27 pub message: String,
28}
29
30#[derive(Debug, Clone)]
32pub struct BurstConfig {
33 pub max_gap_seconds: i64,
35
36 pub min_photos: usize,
38
39 pub max_burst_span_seconds: i64,
41
42 pub similarity_threshold: f32,
44
45 pub require_same_folder: bool,
47}
48
49impl Default for BurstConfig {
50 fn default() -> Self {
51 Self {
59 max_gap_seconds: 10,
60 min_photos: 2,
61 max_burst_span_seconds: 60,
62 similarity_threshold: 0.65,
63 require_same_folder: false,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
69struct BurstPhotoCandidate {
70 id: i64,
71 date: DateTime<Utc>,
72 file_path: String,
73 file_hash: String,
74 thumbnail_path: Option<String>,
75 signature: Option<Vec<f32>>,
76}
77
78pub struct BurstDetector {
80 config: BurstConfig,
81}
82
83impl BurstDetector {
84 pub fn new(config: BurstConfig) -> Self {
85 Self { config }
86 }
87
88 pub fn find_bursts(
97 &self,
98 conn: &Connection,
99 drive_root: Option<&Path>,
100 thumb_root: Option<&Path>,
101 ) -> rusqlite::Result<Vec<BurstGroup>> {
102 self.find_bursts_with_progress(conn, drive_root, thumb_root, None, |_| {})
103 }
104
105 pub fn find_bursts_with_progress(
106 &self,
107 conn: &Connection,
108 drive_root: Option<&Path>,
109 thumb_root: Option<&Path>,
110 cancel: Option<&AtomicBool>,
111 mut progress: impl FnMut(BurstProgress),
112 ) -> rusqlite::Result<Vec<BurstGroup>> {
113 self.find_bursts_streaming(conn, drive_root, thumb_root, cancel, &mut progress, |_| {})
114 }
115
116 pub fn find_bursts_streaming(
117 &self,
118 conn: &Connection,
119 drive_root: Option<&Path>,
120 thumb_root: Option<&Path>,
121 cancel: Option<&AtomicBool>,
122 mut progress: impl FnMut(BurstProgress),
123 mut on_group: impl FnMut(&BurstGroup),
124 ) -> rusqlite::Result<Vec<BurstGroup>> {
125 let mut stmt = conn.prepare(
129 r#"
130 SELECT id, date_taken, file_path, file_hash, thumbnail_path
131 FROM photos
132 WHERE date_taken IS NOT NULL AND is_trashed = FALSE
133 ORDER BY date_taken ASC
134 "#,
135 )?;
136
137 let photos: Vec<(i64, String, String, String, Option<String>)> = stmt
138 .query_map([], |row| {
139 Ok((
140 row.get(0)?,
141 row.get(1)?,
142 row.get(2)?,
143 row.get(3)?,
144 row.get(4)?,
145 ))
146 })?
147 .collect::<rusqlite::Result<Vec<_>>>()?;
148 drop(stmt);
149
150 if photos.is_empty() {
151 return Ok(Vec::new());
152 }
153
154 let mut photos: Vec<BurstPhotoCandidate> = photos
155 .into_iter()
156 .filter_map(|(id, date_str, file_path, file_hash, thumbnail_path)| {
157 Some(BurstPhotoCandidate {
158 id,
159 date: Self::parse_datetime(&date_str)?,
160 file_path,
161 file_hash,
162 thumbnail_path,
163 signature: None,
164 })
165 })
166 .collect();
167 photos.sort_by_key(|p| (p.date, p.id));
168
169 if photos.is_empty() {
170 return Ok(Vec::new());
171 }
172 let total = photos.len() as u64;
173 progress(BurstProgress {
174 processed: 0,
175 total,
176 groups_found: 0,
177 message: format!("checking {} dated photos", total),
178 });
179
180 let mut groups = Vec::new();
181 let mut current_group: Vec<BurstPhotoCandidate> = Vec::new();
182 let tick = total.div_ceil(40).max(250);
183
184 for (idx, candidate) in photos.into_iter().enumerate() {
185 if cancel
186 .map(|flag| flag.load(Ordering::Relaxed))
187 .unwrap_or(false)
188 {
189 break;
190 }
191
192 if current_group.is_empty() {
193 current_group.push(candidate);
194 } else {
195 let mut candidate = candidate;
196 let should_join = self.should_join_group(
197 &mut current_group,
198 &mut candidate,
199 drive_root,
200 thumb_root,
201 );
202
203 if should_join {
204 current_group.push(candidate);
205 } else {
206 if current_group.len() >= self.config.min_photos {
208 if let Some(group) = self.finalize_candidate_group(¤t_group) {
209 on_group(&group);
210 groups.push(group);
211 }
212 }
213 current_group = vec![candidate];
214 }
215 }
216
217 let processed = (idx + 1) as u64;
218 if processed.is_multiple_of(tick) || processed == total {
219 progress(BurstProgress {
220 processed,
221 total,
222 groups_found: groups.len() as u64,
223 message: format!("{} burst groups so far", groups.len()),
224 });
225 }
226 }
227
228 if !cancel
230 .map(|flag| flag.load(Ordering::Relaxed))
231 .unwrap_or(false)
232 && current_group.len() >= self.config.min_photos
233 {
234 if let Some(group) = self.finalize_candidate_group(¤t_group) {
235 on_group(&group);
236 groups.push(group);
237 }
238 }
239
240 Ok(groups)
241 }
242
243 fn finalize_candidate_group(&self, photos: &[BurstPhotoCandidate]) -> Option<BurstGroup> {
244 let first = photos.first()?;
245 let last = photos.last()?;
246 let photo_ids: Vec<i64> = photos.iter().map(|p| p.id).collect();
247
248 Some(BurstGroup {
249 photo_ids,
250 start_time: first.date,
251 end_time: last.date,
252 })
253 }
254
255 fn should_join_group(
256 &self,
257 group: &mut [BurstPhotoCandidate],
258 candidate: &mut BurstPhotoCandidate,
259 drive_root: Option<&Path>,
260 thumb_root: Option<&Path>,
261 ) -> bool {
262 let last = match group.last() {
263 Some(p) => p,
264 None => return true,
265 };
266
267 let gap = candidate.date.signed_duration_since(last.date);
268 if gap > Duration::seconds(self.config.max_gap_seconds) {
269 return false;
270 }
271
272 let start = match group.first() {
273 Some(p) => p.date,
274 None => return true,
275 };
276 let span = candidate.date.signed_duration_since(start);
277 if span > Duration::seconds(self.config.max_burst_span_seconds) {
278 return false;
279 }
280
281 if self.config.require_same_folder
282 && Self::folder_key(&last.file_path) != Self::folder_key(&candidate.file_path)
283 {
284 return false;
285 }
286
287 let Some(last) = group.last_mut() else {
288 return true;
289 };
290 Self::ensure_signature(last, drive_root, thumb_root);
291 Self::ensure_signature(candidate, drive_root, thumb_root);
292
293 if let (Some(a), Some(b)) = (&last.signature, &candidate.signature) {
294 let sim = Self::cosine_similarity(a, b);
295 if sim < self.config.similarity_threshold {
296 return false;
297 }
298 }
299
300 true
301 }
302
303 fn ensure_signature(
304 photo: &mut BurstPhotoCandidate,
305 drive_root: Option<&Path>,
306 thumb_root: Option<&Path>,
307 ) {
308 if photo.signature.is_some() {
309 return;
310 }
311 photo.signature = Self::signature_source_path(drive_root, thumb_root, photo)
312 .and_then(|p| Self::build_signature(&p));
313 }
314
315 fn signature_source_path(
316 drive_root: Option<&Path>,
317 thumb_root: Option<&Path>,
318 photo: &BurstPhotoCandidate,
319 ) -> Option<PathBuf> {
320 let mut candidates = Vec::with_capacity(5);
321 if let (Some(root), Some(path)) = (drive_root, &photo.thumbnail_path) {
322 if let Ok(path) = crate::services::path_util::safe_join_relative(root, path) {
323 candidates.push(path);
324 }
325 }
326
327 if let Some(root) = thumb_root {
328 let subdir = &photo.file_hash[..2.min(photo.file_hash.len())];
329 candidates.push(root.join(subdir).join(format!("{}.jpg", photo.file_hash)));
330 }
331
332 if let Some(root) = drive_root {
333 let subdir = &photo.file_hash[..2.min(photo.file_hash.len())];
334 for size in ["medium", "small", "large"] {
335 candidates.push(
336 root.join(".photovault")
337 .join("thumbnails")
338 .join(size)
339 .join("v2")
340 .join(subdir)
341 .join(format!("{}.jpg", photo.file_hash)),
342 );
343 }
344 if let Ok(path) = crate::services::path_util::safe_join_relative(root, &photo.file_path)
345 {
346 candidates.push(path);
347 }
348 }
349
350 candidates.into_iter().find(|p| p.exists())
351 }
352
353 fn folder_key(file_path: &str) -> String {
354 Path::new(file_path)
355 .parent()
356 .map(|p| p.to_string_lossy().to_string())
357 .unwrap_or_default()
358 }
359
360 fn build_signature(path: &Path) -> Option<Vec<f32>> {
361 let img = crate::services::image_io::open_image(path).ok()?;
364 Some(Self::signature_from_image(&img))
365 }
366
367 fn signature_from_image(img: &DynamicImage) -> Vec<f32> {
368 let gray = img
369 .grayscale()
370 .resize_exact(48, 48, FilterType::Triangle)
371 .to_luma8();
372 let mut sig = Vec::with_capacity(48 * 48);
373 for px in gray.pixels() {
374 sig.push(f32::from(px.0[0]) / 255.0);
375 }
376 sig
377 }
378
379 fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
380 if a.len() != b.len() || a.is_empty() {
381 return 0.0;
382 }
383
384 let mut dot = 0.0f32;
385 let mut a_norm = 0.0f32;
386 let mut b_norm = 0.0f32;
387 for (x, y) in a.iter().zip(b.iter()) {
388 dot += x * y;
389 a_norm += x * x;
390 b_norm += y * y;
391 }
392
393 if a_norm <= f32::EPSILON || b_norm <= f32::EPSILON {
394 return 0.0;
395 }
396
397 dot / (a_norm.sqrt() * b_norm.sqrt())
398 }
399
400 fn parse_datetime(s: &str) -> Option<DateTime<Utc>> {
402 if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
404 return Some(dt.with_timezone(&Utc));
405 }
406
407 if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
409 return Some(DateTime::from_naive_utc_and_offset(naive, Utc));
410 }
411
412 None
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419 use chrono::Datelike;
420 use image::{DynamicImage, ImageBuffer, Luma};
421 use rusqlite::params;
422
423 #[test]
424 fn test_parse_datetime() {
425 let dt = BurstDetector::parse_datetime("2019-03-15 14:30:22");
426 assert!(dt.is_some());
427
428 let dt = dt.unwrap();
429 assert_eq!(dt.year(), 2019);
430 assert_eq!(dt.month(), 3);
431 assert_eq!(dt.day(), 15);
432 }
433
434 #[test]
435 fn test_similarity_identical_images_is_high() {
436 let img = DynamicImage::ImageLuma8(ImageBuffer::from_fn(32, 32, |_x, _y| Luma([200u8])));
437 let a = BurstDetector::signature_from_image(&img);
438 let b = BurstDetector::signature_from_image(&img);
439 let sim = BurstDetector::cosine_similarity(&a, &b);
440 assert!(sim > 0.999);
441 }
442
443 #[test]
444 fn finalize_candidate_group_ignores_empty_input() {
445 let detector = BurstDetector::new(BurstConfig::default());
446 assert!(detector.finalize_candidate_group(&[]).is_none());
447 }
448
449 #[test]
450 fn bursts_sort_by_parsed_utc_time_not_raw_timestamp_text() {
451 let conn = Connection::open_in_memory().unwrap();
452 conn.execute_batch(
453 r#"
454 CREATE TABLE photos (
455 id INTEGER PRIMARY KEY,
456 date_taken TEXT,
457 file_path TEXT NOT NULL,
458 file_hash TEXT NOT NULL,
459 thumbnail_path TEXT,
460 is_trashed BOOLEAN NOT NULL DEFAULT 0
461 );
462 "#,
463 )
464 .unwrap();
465 conn.execute(
466 "INSERT INTO photos (id, date_taken, file_path, file_hash, is_trashed)
467 VALUES (1, '2024-01-01T00:00:00-09:00', 'a.jpg', 'aa111', 0)",
468 [],
469 )
470 .unwrap();
471 conn.execute(
472 "INSERT INTO photos (id, date_taken, file_path, file_hash, is_trashed)
473 VALUES (2, '2024-01-01T00:00:20+09:00', 'b.jpg', 'bb222', 0)",
474 [],
475 )
476 .unwrap();
477
478 let detector = BurstDetector::new(BurstConfig::default());
479 let groups = detector.find_bursts(&conn, None, None).unwrap();
480
481 assert!(groups.is_empty());
482 }
483
484 #[test]
485 fn bursts_use_db_thumbnail_path_when_small_thumb_is_missing() {
486 let temp = tempfile::tempdir().unwrap();
487 let conn = Connection::open_in_memory().unwrap();
488 conn.execute_batch(
489 r#"
490 CREATE TABLE photos (
491 id INTEGER PRIMARY KEY,
492 date_taken TEXT,
493 file_path TEXT NOT NULL,
494 file_hash TEXT NOT NULL,
495 thumbnail_path TEXT,
496 is_trashed BOOLEAN NOT NULL DEFAULT 0
497 );
498 "#,
499 )
500 .unwrap();
501
502 let thumb_dir = temp.path().join(".photovault/thumbnails/medium/v2/aa");
503 std::fs::create_dir_all(&thumb_dir).unwrap();
504 for (id, hash, value) in [(1, "aa111", 190u8), (2, "aa222", 192u8)] {
505 let thumb = thumb_dir.join(format!("{hash}.jpg"));
506 DynamicImage::ImageLuma8(ImageBuffer::from_fn(32, 32, |_x, _y| Luma([value])))
507 .save(&thumb)
508 .unwrap();
509 conn.execute(
510 "INSERT INTO photos (id, date_taken, file_path, file_hash, thumbnail_path, is_trashed)
511 VALUES (?1, ?2, ?3, ?4, ?5, 0)",
512 params![
513 id,
514 format!("2025-01-01 10:00:0{id}"),
515 format!("missing/original-{id}.jpg"),
516 hash,
517 format!(".photovault/thumbnails/medium/v2/aa/{hash}.jpg")
518 ],
519 )
520 .unwrap();
521 }
522
523 let detector = BurstDetector::new(BurstConfig::default());
524 let groups = detector
525 .find_bursts(
526 &conn,
527 Some(temp.path()),
528 Some(&temp.path().join("missing-small")),
529 )
530 .unwrap();
531
532 assert_eq!(groups.len(), 1);
533 assert_eq!(groups[0].photo_ids, vec![1, 2]);
534 }
535}