1use std::collections::{HashMap, HashSet};
7use std::hash::{DefaultHasher, Hash, Hasher};
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use chrono::{Datelike, NaiveDate, Timelike, Utc};
11use rusqlite::{params, Connection};
12
13use crate::db::album_suggestion_repo::AlbumSuggestionRepo;
14
15#[derive(Debug, Clone)]
17pub struct DetectedSuggestion {
18 pub kind: String,
19 pub title: String,
20 pub photo_ids: Vec<i64>,
21 pub cover_photo_id: Option<i64>,
22 pub fingerprint: String,
23}
24
25#[allow(dead_code)]
26#[derive(Debug, Clone, Default)]
27pub struct SuggestionDiagnostics {
28 pub total_photos_with_date: i64,
29 pub photos_with_city: i64,
30 pub photos_with_gps: i64,
35 pub home_city: Option<String>,
36 pub trip_rows: usize,
37 pub trip_gate_duration_rejected: usize,
38 pub trip_gate_photo_count_rejected: usize,
39 pub trip_gate_rarity_rejected: usize,
40 pub trip_gate_home_distance_rejected: usize,
41 pub trip_gate_album_overlap_rejected: usize,
42 pub trip_candidates_passed: usize,
43 pub event_windows: usize,
44 pub event_gate_photo_count_rejected: usize,
45 pub event_gate_trip_overlap_rejected: usize,
46 pub event_gate_signal_rejected: usize,
47 pub event_gate_album_overlap_rejected: usize,
48 pub event_candidates_passed: usize,
49 pub persisted_new: usize,
50 pub skipped_existing_fingerprint: usize,
51}
52
53pub fn haversine_km(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
59 let r = 6371.0; let dlat = (lat2 - lat1).to_radians();
61 let dlng = ((lng2 - lng1 + 540.0).rem_euclid(360.0) - 180.0).to_radians();
62 let a = (dlat / 2.0).sin().powi(2)
63 + lat1.to_radians().cos() * lat2.to_radians().cos() * (dlng / 2.0).sin().powi(2);
64 let c = 2.0 * a.sqrt().asin();
65 r * c
66}
67
68fn parse_date_prefix(date_str: &str) -> Option<NaiveDate> {
69 NaiveDate::parse_from_str(date_str.get(..10)?, "%Y-%m-%d").ok()
70}
71
72pub fn compute_fingerprint(photo_ids: &[i64]) -> String {
75 let mut sorted = photo_ids.to_vec();
76 sorted.sort_unstable();
77 let mut hasher = DefaultHasher::new();
78 sorted.hash(&mut hasher);
79 format!("{:016x}", hasher.finish())
80}
81
82pub fn pick_cover(conn: &Connection, photo_ids: &[i64]) -> Option<i64> {
86 if photo_ids.is_empty() {
87 return None;
88 }
89
90 let placeholders: Vec<String> = photo_ids.iter().map(|_| "?".to_string()).collect();
92 let in_clause = placeholders.join(",");
93
94 let sql = format!(
95 r#"SELECT p.id FROM photos p
96 LEFT JOIN faces f ON p.id = f.photo_id
97 WHERE p.id IN ({})
98 AND p.is_trashed = FALSE
99 GROUP BY p.id
100 ORDER BY
101 p.media_type = 'photo' DESC,
102 p.is_favorite DESC,
103 COALESCE((
104 SELECT MAX(psm.quality_score)
105 FROM photo_stack_members psm
106 WHERE psm.photo_id = p.id
107 ), 0) DESC,
108 COUNT(f.id) > 0 DESC,
109 p.width > p.height DESC,
110 p.date_taken DESC
111 LIMIT 1"#,
112 in_clause,
113 );
114
115 let mut stmt = conn.prepare(&sql).ok()?;
116 let params: Vec<rusqlite::types::Value> = photo_ids
117 .iter()
118 .map(|id| rusqlite::types::Value::Integer(*id))
119 .collect();
120 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
121 .iter()
122 .map(|v| v as &dyn rusqlite::types::ToSql)
123 .collect();
124 stmt.query_row(&*param_refs, |row| row.get::<_, i64>(0))
125 .ok()
126}
127
128pub fn detect_home_city(
135 conn: &Connection,
136 override_city: Option<&str>,
137) -> Option<(String, String, f64, f64)> {
138 if let Some(city_name) = override_city {
140 if !city_name.trim().is_empty() {
141 let result: Option<(String, String, f64, f64)> = conn
142 .query_row(
143 r#"SELECT location_city, COALESCE(location_country,''),
144 AVG(gps_latitude), AVG(gps_longitude)
145 FROM photos
146 WHERE location_city = ?1
147 AND gps_latitude IS NOT NULL
148 AND is_trashed = FALSE
149 GROUP BY location_city, COALESCE(location_country,'')
150 ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
151 LIMIT 1"#,
152 params![city_name.trim()],
153 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
154 )
155 .ok();
156 if result.is_some() {
157 return result;
158 }
159 }
160 }
161
162 conn.query_row(
164 r#"SELECT location_city, COALESCE(location_country,''),
165 AVG(gps_latitude), AVG(gps_longitude)
166 FROM photos
167 WHERE location_city IS NOT NULL
168 AND gps_latitude IS NOT NULL
169 AND is_trashed = FALSE
170 GROUP BY location_city, COALESCE(location_country,'')
171 ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
172 LIMIT 1"#,
173 [],
174 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
175 )
176 .ok()
177}
178
179#[derive(Debug, Clone)]
184struct TripPhoto {
185 id: i64,
186 city: String,
187 country: String,
188 date: NaiveDate,
189 people: HashSet<i64>,
190}
191
192#[derive(Debug, Default)]
193struct TripSpan {
194 start: Option<NaiveDate>,
195 end: Option<NaiveDate>,
196 located_photo_ids: Vec<i64>,
197 stops: Vec<(String, String)>,
198 people: HashSet<i64>,
199}
200
201impl TripSpan {
202 fn push(&mut self, photo: &TripPhoto) {
203 self.start.get_or_insert(photo.date);
204 self.end = Some(photo.date);
205 self.located_photo_ids.push(photo.id);
206 self.people.extend(photo.people.iter().copied());
207 let stop = (photo.city.clone(), photo.country.clone());
208 if self.stops.last() != Some(&stop) {
209 self.stops.push(stop);
210 }
211 }
212
213 fn is_empty(&self) -> bool {
214 self.located_photo_ids.is_empty()
215 }
216}
217
218pub fn detect_trips(
227 conn: &Connection,
228 home: Option<&(String, String, f64, f64)>,
229) -> Vec<DetectedSuggestion> {
230 let rows: Vec<TripPhoto> = {
231 let mut stmt = match conn.prepare(
232 r#"SELECT p.id, p.location_city, COALESCE(p.location_country,''), p.date_taken,
233 (SELECT GROUP_CONCAT(DISTINCT f.cluster_id)
234 FROM faces f
235 WHERE f.photo_id = p.id AND f.cluster_id IS NOT NULL)
236 FROM photos p
237 WHERE p.location_city IS NOT NULL AND p.location_city != ''
238 AND p.date_taken IS NOT NULL
239 AND p.is_trashed = FALSE
240 AND COALESCE(p.content_category, 'photo') = 'photo'
241 ORDER BY p.date_taken, p.id"#,
242 ) {
243 Ok(s) => s,
244 Err(_) => return Vec::new(),
245 };
246 stmt.query_map([], |row| {
247 Ok((
248 row.get::<_, i64>(0)?,
249 row.get::<_, String>(1)?,
250 row.get::<_, String>(2)?,
251 row.get::<_, String>(3)?,
252 row.get::<_, Option<String>>(4)?,
253 ))
254 })
255 .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
256 .unwrap_or_else(|e| {
257 tracing::warn!("album trip detection skipped: failed reading city rows: {e}");
258 Vec::new()
259 })
260 .into_iter()
261 .filter_map(|(id, city, country, raw_date, people_csv)| {
262 Some(TripPhoto {
263 id,
264 city,
265 country,
266 date: parse_date_prefix(&raw_date)?,
267 people: people_csv
268 .as_deref()
269 .unwrap_or_default()
270 .split(',')
271 .filter_map(|value| value.parse().ok())
272 .collect(),
273 })
274 })
275 .collect()
276 };
277
278 if rows.is_empty() {
279 return Vec::new();
280 }
281
282 let city_centroids: HashMap<(String, String), (f64, f64)> = {
283 let mut stmt = match conn.prepare(
284 r#"SELECT location_city, COALESCE(location_country,''), AVG(gps_latitude), AVG(gps_longitude)
285 FROM photos
286 WHERE location_city IS NOT NULL AND gps_latitude IS NOT NULL AND is_trashed = FALSE
287 GROUP BY location_city, COALESCE(location_country,'')"#,
288 ) {
289 Ok(stmt) => stmt,
290 Err(_) => return Vec::new(),
291 };
292 stmt.query_map([], |row| {
293 Ok((
294 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
295 (row.get::<_, f64>(2)?, row.get::<_, f64>(3)?),
296 ))
297 })
298 .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
299 .unwrap_or_else(|e| {
300 tracing::warn!("album trip detection skipped: failed reading city centroid rows: {e}");
301 HashMap::new()
302 })
303 };
304
305 let album_photo_set: HashSet<i64> = {
306 match conn.prepare("SELECT DISTINCT photo_id FROM album_photos") {
307 Ok(mut stmt) => stmt
308 .query_map([], |row| row.get::<_, i64>(0))
309 .and_then(|iter| iter.collect::<rusqlite::Result<HashSet<_>>>())
310 .unwrap_or_else(|e| {
311 tracing::warn!(
312 "album trip detection continuing without album membership filter: {e}"
313 );
314 HashSet::new()
315 }),
316 Err(_) => HashSet::new(),
317 }
318 };
319
320 let inferred_home: Option<(String, String)> = if home.is_none() {
321 conn.query_row(
322 r#"SELECT location_city, COALESCE(location_country, '')
323 FROM photos
324 WHERE is_trashed = FALSE
325 AND location_city IS NOT NULL AND location_city != ''
326 AND date_taken IS NOT NULL
327 GROUP BY location_city, COALESCE(location_country, '')
328 ORDER BY COUNT(DISTINCT strftime('%Y-%W', date_taken)) DESC
329 LIMIT 1"#,
330 [],
331 |row| Ok((row.get(0)?, row.get(1)?)),
332 )
333 .ok()
334 } else {
335 None
336 };
337 let home_place = home.map(|h| (h.0.as_str(), h.1.as_str())).or_else(|| {
338 inferred_home
339 .as_ref()
340 .map(|(city, country)| (city.as_str(), country.as_str()))
341 });
342 let home_coords = home.map(|h| (h.2, h.3));
343 let is_home = |photo: &TripPhoto| {
344 if home_place == Some((photo.city.as_str(), photo.country.as_str())) {
345 return true;
346 }
347 let place = (photo.city.clone(), photo.country.clone());
348 matches!(
349 (home_coords, city_centroids.get(&place)),
350 (Some((hlat, hlng)), Some((clat, clng)))
351 if haversine_km(hlat, hlng, *clat, *clng) < 50.0
352 )
353 };
354
355 let mut spans = Vec::new();
356 let mut current = TripSpan::default();
357 for photo in &rows {
358 if is_home(photo) {
359 if !current.is_empty() {
360 spans.push(std::mem::take(&mut current));
361 }
362 continue;
363 }
364 if let Some(end) = current.end {
365 let gap = (photo.date - end).num_days();
366 let people_continue = !photo.people.is_disjoint(¤t.people);
367 if gap > 10 || (gap > 4 && !people_continue) {
368 spans.push(std::mem::take(&mut current));
369 }
370 }
371 current.push(photo);
372 }
373 if !current.is_empty() {
374 spans.push(current);
375 }
376
377 let mut suggestions = Vec::new();
378 for span in spans {
379 let (Some(start), Some(end)) = (span.start, span.end) else {
380 continue;
381 };
382 let duration_days = (end - start).num_days() + 1;
383 if duration_days < 3 {
384 continue;
385 }
386
387 let mut photo_ids = photos_in_date_envelope(conn, start, end);
388 if photo_ids.is_empty() {
389 photo_ids = span.located_photo_ids;
390 }
391 photo_ids.sort_unstable();
392 photo_ids.dedup();
393 if photo_ids.len() < 15 {
394 continue;
395 }
396
397 let in_album = photo_ids
398 .iter()
399 .filter(|id| album_photo_set.contains(id))
400 .count();
401 if in_album as f64 / photo_ids.len() as f64 > 0.60 {
402 continue;
403 }
404
405 let stops = compact_stops(&span.stops);
406 let destination = trip_destination(&stops);
407 let people = top_named_people(conn, &photo_ids, 2);
408 let lead = if people.is_empty() {
409 if stops.len() > 1 {
410 format!("A journey through {destination}")
411 } else {
412 format!("Time away in {destination}")
413 }
414 } else {
415 format!("{destination} with {}", join_names(&people))
416 };
417 let title = format!("{lead} · {}", format_date_range(start, end));
418
419 let fp = compute_fingerprint(&photo_ids);
420 let cover = pick_cover(conn, &photo_ids);
421
422 suggestions.push(DetectedSuggestion {
423 kind: "trip".to_string(),
424 title,
425 photo_ids,
426 cover_photo_id: cover,
427 fingerprint: fp,
428 });
429 }
430
431 suggestions
432}
433
434fn photos_in_date_envelope(conn: &Connection, start: NaiveDate, end: NaiveDate) -> Vec<i64> {
435 let end_exclusive = end.succ_opt().unwrap_or(end).format("%Y-%m-%d").to_string();
436 let mut stmt = match conn.prepare(
437 r#"SELECT id
438 FROM photos
439 WHERE is_trashed = FALSE
440 AND COALESCE(content_category, 'photo') = 'photo'
441 AND date_taken >= ?1 AND date_taken < ?2
442 ORDER BY date_taken, id"#,
443 ) {
444 Ok(stmt) => stmt,
445 Err(_) => return Vec::new(),
446 };
447 stmt.query_map(
448 params![start.format("%Y-%m-%d").to_string(), end_exclusive],
449 |row| row.get(0),
450 )
451 .and_then(|rows| rows.collect::<rusqlite::Result<Vec<_>>>())
452 .unwrap_or_default()
453}
454
455fn compact_stops(stops: &[(String, String)]) -> Vec<(String, String)> {
456 let mut out = Vec::new();
457 for stop in stops {
458 if !out.contains(stop) {
459 out.push(stop.clone());
460 }
461 }
462 out
463}
464
465fn trip_destination(stops: &[(String, String)]) -> String {
466 let cities: Vec<&str> = stops.iter().map(|(city, _)| city.as_str()).collect();
467 if cities.len() <= 3 {
468 return join_words(&cities);
469 }
470 let countries: HashSet<&str> = stops
471 .iter()
472 .map(|(_, country)| country.as_str())
473 .filter(|country| !country.is_empty())
474 .collect();
475 if countries.len() == 1 {
476 return countries
477 .into_iter()
478 .next()
479 .unwrap_or("your journey")
480 .to_string();
481 }
482 format!("{} and {} more stops", cities[0], cities.len() - 1)
483}
484
485fn join_words(words: &[&str]) -> String {
486 match words {
487 [] => "your journey".to_string(),
488 [one] => (*one).to_string(),
489 [first, second] => format!("{first} & {second}"),
490 _ => format!("{}, {} & {}", words[0], words[1], words[2]),
491 }
492}
493
494fn join_names(names: &[String]) -> String {
495 let words: Vec<&str> = names.iter().map(String::as_str).collect();
496 join_words(&words)
497}
498
499fn format_date_range(start: NaiveDate, end: NaiveDate) -> String {
500 if start == end {
501 return start.format("%b %-d, %Y").to_string();
502 }
503 if start.year() == end.year() && start.month() == end.month() {
504 format!(
505 "{} – {}, {}",
506 start.format("%b %-d"),
507 end.format("%-d"),
508 start.format("%Y")
509 )
510 } else if start.year() == end.year() {
511 format!("{} – {}", start.format("%b %-d"), end.format("%b %-d, %Y"))
512 } else {
513 format!(
514 "{} – {}",
515 start.format("%b %-d, %Y"),
516 end.format("%b %-d, %Y")
517 )
518 }
519}
520
521fn top_named_people(conn: &Connection, photo_ids: &[i64], limit: usize) -> Vec<String> {
522 if photo_ids.is_empty() || limit == 0 {
523 return Vec::new();
524 }
525 let mut counts: HashMap<String, usize> = HashMap::new();
526 for chunk in photo_ids.chunks(900) {
527 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
528 let sql = format!(
529 r#"SELECT fc.name, COUNT(DISTINCT f.photo_id)
530 FROM faces f
531 JOIN face_clusters fc ON fc.id = f.cluster_id
532 WHERE f.photo_id IN ({placeholders})
533 AND fc.name IS NOT NULL AND TRIM(fc.name) != ''
534 GROUP BY fc.id, fc.name"#
535 );
536 let Ok(mut stmt) = conn.prepare(&sql) else {
537 return Vec::new();
538 };
539 let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
540 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
541 }) else {
542 return Vec::new();
543 };
544 for row in rows.flatten() {
545 *counts.entry(row.0).or_default() += row.1;
546 }
547 }
548 let mut ranked: Vec<_> = counts.into_iter().collect();
549 ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
550 ranked
551 .into_iter()
552 .take(limit)
553 .map(|(name, _)| name)
554 .collect()
555}
556
557fn curate_story_photos(conn: &Connection, photo_ids: &[i64]) -> Vec<i64> {
558 if photo_ids.is_empty() {
559 return Vec::new();
560 }
561 let mut out = Vec::new();
562 for chunk in photo_ids.chunks(900) {
563 let placeholders = (0..chunk.len()).map(|_| "?").collect::<Vec<_>>().join(",");
564 let sql = format!(
565 r#"SELECT p.id
566 FROM photos p
567 WHERE p.id IN ({placeholders})
568 AND p.is_trashed = FALSE
569 AND COALESCE(p.content_category, 'photo') = 'photo'
570 AND NOT EXISTS (
571 SELECT 1
572 FROM photo_stack_members psm
573 JOIN photo_stacks ps ON ps.id = psm.stack_id
574 WHERE psm.photo_id = p.id
575 AND ps.dismissed = FALSE
576 AND psm.is_cover = FALSE
577 )
578 ORDER BY p.date_taken, p.id"#
579 );
580 let Ok(mut stmt) = conn.prepare(&sql) else {
581 return photo_ids.to_vec();
582 };
583 let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(chunk.iter().copied()), |row| {
584 row.get::<_, i64>(0)
585 }) else {
586 return photo_ids.to_vec();
587 };
588 out.extend(rows.flatten());
589 }
590 out
591}
592
593pub fn detect_events(conn: &Connection, trip_photo_ids: &HashSet<i64>) -> Vec<DetectedSuggestion> {
601 let rows: Vec<(i64, String, Option<String>, Option<i64>)> = {
603 let mut stmt = match conn.prepare(
604 r#"SELECT p.id, p.date_taken, p.location_city,
605 (SELECT f.cluster_id FROM faces f WHERE f.photo_id = p.id AND f.cluster_id IS NOT NULL LIMIT 1)
606 FROM photos p
607 WHERE p.date_taken IS NOT NULL AND p.is_trashed = FALSE
608 ORDER BY p.date_taken"#,
609 ) {
610 Ok(s) => s,
611 Err(_) => return Vec::new(),
612 };
613 stmt.query_map([], |row| {
614 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
615 })
616 .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
617 .unwrap_or_else(|e| {
618 tracing::warn!("album event detection skipped: failed reading event rows: {e}");
619 Vec::new()
620 })
621 };
622
623 if rows.is_empty() {
624 return Vec::new();
625 }
626
627 let album_photo_set: HashSet<i64> = {
629 match conn.prepare("SELECT DISTINCT photo_id FROM album_photos") {
630 Ok(mut stmt) => stmt
631 .query_map([], |row| row.get::<_, i64>(0))
632 .and_then(|iter| iter.collect::<rusqlite::Result<HashSet<_>>>())
633 .unwrap_or_else(|e| {
634 tracing::warn!(
635 "album event detection continuing without album membership filter: {e}"
636 );
637 HashSet::new()
638 }),
639 Err(_) => HashSet::new(),
640 }
641 };
642
643 struct EventPhoto {
645 id: i64,
646 ts: i64, city: Option<String>,
648 cluster_id: Option<i64>,
649 }
650
651 let parsed: Vec<EventPhoto> = rows
652 .into_iter()
653 .filter_map(|(id, dt, city, cluster)| {
654 let normalized = if dt.contains('T') {
655 dt.replace('T', " ")
656 } else {
657 dt.clone()
658 };
659 let ts = chrono::NaiveDateTime::parse_from_str(
660 &normalized[..normalized.len().min(19)],
661 "%Y-%m-%d %H:%M:%S",
662 )
663 .ok()
664 .map(|ndt| ndt.and_utc().timestamp())
665 .or_else(|| {
666 parse_date_prefix(&normalized)
667 .map(|d| d.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp())
668 })?;
669 Some(EventPhoto {
670 id,
671 ts,
672 city,
673 cluster_id: cluster,
674 })
675 })
676 .collect();
677
678 let four_hours = 4 * 3600;
680 let mut windows: Vec<Vec<&EventPhoto>> = Vec::new();
681 let mut current_window: Vec<&EventPhoto> = Vec::new();
682
683 for photo in &parsed {
684 if let Some(last) = current_window.last() {
685 if photo.ts - last.ts > four_hours && !current_window.is_empty() {
686 windows.push(std::mem::take(&mut current_window));
687 }
688 }
689 current_window.push(photo);
690 }
691 if !current_window.is_empty() {
692 windows.push(current_window);
693 }
694
695 let mut suggestions = Vec::new();
696
697 for window in &windows {
698 if window.len() < 15 {
701 continue;
702 }
703
704 let ids: Vec<i64> = window.iter().map(|p| p.id).collect();
705
706 let in_trip = ids.iter().filter(|id| trip_photo_ids.contains(id)).count();
708 if !ids.is_empty() && in_trip as f64 / ids.len() as f64 > 0.70 {
709 continue;
710 }
711
712 let cluster_ids: HashSet<i64> = window.iter().filter_map(|p| p.cluster_id).collect();
714 let distinct_days: HashSet<i64> = window.iter().map(|p| p.ts / 86400).collect();
715 let location_days: HashSet<String> = window.iter().filter_map(|p| p.city.clone()).collect();
716
717 let has_face_signal = cluster_ids.len() >= 2;
718 let has_location_signal = !location_days.is_empty() && distinct_days.len() <= 3;
719
720 if !has_face_signal && !has_location_signal {
721 continue;
722 }
723
724 let in_album = ids.iter().filter(|id| album_photo_set.contains(id)).count();
726 if !ids.is_empty() && in_album as f64 / ids.len() as f64 > 0.60 {
727 continue;
728 }
729
730 let primary_city = window
732 .iter()
733 .filter_map(|p| p.city.as_ref())
734 .fold(HashMap::new(), |mut acc, c| {
735 *acc.entry(c.clone()).or_insert(0usize) += 1;
736 acc
737 })
738 .into_iter()
739 .max_by_key(|(_, count)| *count)
740 .map(|(c, _)| c);
741
742 let first_ts = window.first().map(|p| p.ts).unwrap_or(0);
743 let date = chrono::DateTime::from_timestamp(first_ts, 0)
744 .map(|dt| dt.format("%b %d, %Y").to_string())
745 .unwrap_or_else(|| "Event".to_string());
746
747 let people = top_named_people(conn, &ids, 2);
748 let daypart = chrono::DateTime::from_timestamp(first_ts, 0)
749 .map(|dt| match dt.hour() {
750 5..=11 => "A morning",
751 12..=16 => "An afternoon",
752 17..=21 => "An evening",
753 _ => "A night",
754 })
755 .unwrap_or("A day");
756 let title = if !people.is_empty() {
757 if let Some(city) = primary_city.as_ref() {
758 format!(
759 "{daypart} in {city} with {} · {date}",
760 join_names(&people)
761 )
762 } else {
763 format!("Time with {} · {date}", join_names(&people))
764 }
765 } else if let Some(city) = primary_city {
766 format!("{daypart} in {city} · {date}")
767 } else {
768 format!("A day worth keeping · {date}")
769 };
770
771 let fp = compute_fingerprint(&ids);
772 let cover = pick_cover(conn, &ids);
773
774 suggestions.push(DetectedSuggestion {
775 kind: "event".to_string(),
776 title,
777 photo_ids: ids,
778 cover_photo_id: cover,
779 fingerprint: fp,
780 });
781 }
782
783 suggestions
784}
785
786pub fn detect_gatherings(
798 conn: &Connection,
799 excluded_photo_ids: &HashSet<i64>,
800) -> Vec<DetectedSuggestion> {
801 let mut stmt = match conn.prepare(
804 r#"
805 SELECT DISTINCT
806 p.id,
807 CAST(strftime('%s', p.date_taken) AS INTEGER) AS ts,
808 f.cluster_id
809 FROM photos p
810 JOIN faces f ON f.photo_id = p.id
811 WHERE p.is_trashed = FALSE
812 AND p.date_taken IS NOT NULL
813 AND f.cluster_id IS NOT NULL
814 ORDER BY p.date_taken
815 "#,
816 ) {
817 Ok(s) => s,
818 Err(_) => return Vec::new(),
819 };
820
821 type Row = (i64, i64, i64); let rows: Vec<Row> = stmt
823 .query_map([], |row| {
824 Ok((
825 row.get::<_, i64>(0)?,
826 row.get::<_, i64>(1)?,
827 row.get::<_, i64>(2)?,
828 ))
829 })
830 .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
831 .unwrap_or_else(|e| {
832 tracing::warn!("album people-gathering detection skipped: failed reading rows: {e}");
833 Vec::new()
834 });
835
836 if rows.is_empty() {
837 return Vec::new();
838 }
839
840 let cluster_names: HashMap<i64, Option<String>> = {
842 let mut s = match conn.prepare("SELECT id, name FROM face_clusters") {
843 Ok(s) => s,
844 Err(_) => return Vec::new(),
845 };
846 s.query_map([], |row| {
847 Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?))
848 })
849 .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
850 .unwrap_or_else(|e| {
851 tracing::warn!(
852 "album people-gathering detection continuing without cluster names: {e}"
853 );
854 HashMap::new()
855 })
856 };
857
858 let secs_per_day: i64 = 86_400;
861 let max_span_days: i64 = 2;
862
863 struct Window {
864 start_ts: i64,
865 end_ts: i64,
866 photo_ids: Vec<i64>,
867 cluster_ids: HashSet<i64>,
868 }
869
870 let mut windows: Vec<Window> = Vec::new();
871 let mut cur: Option<Window> = None;
872 let mut last_seen_photo: HashMap<i64, i64> = HashMap::new(); for (photo_id, ts, cluster_id) in &rows {
875 let ts = *ts;
876 let pid = *photo_id;
877 let cid = *cluster_id;
878 let mut new_window = false;
879 if let Some(c) = &cur {
880 if (ts - c.start_ts) <= max_span_days * secs_per_day {
882 } else {
884 new_window = true;
885 }
886 } else {
887 new_window = true;
888 }
889 if new_window {
890 if let Some(w) = cur.take() {
891 if !w.photo_ids.is_empty() {
892 windows.push(w);
893 }
894 }
895 cur = Some(Window {
896 start_ts: ts,
897 end_ts: ts,
898 photo_ids: Vec::new(),
899 cluster_ids: HashSet::new(),
900 });
901 }
902 let Some(w) = cur.as_mut() else {
903 continue;
904 };
905 w.cluster_ids.insert(cid);
906 w.end_ts = w.end_ts.max(ts);
907 let prev_ts = last_seen_photo.get(&pid).copied();
910 if prev_ts.is_none() {
911 w.photo_ids.push(pid);
912 }
913 last_seen_photo.insert(pid, ts);
914 }
915 if let Some(w) = cur.take() {
916 if !w.photo_ids.is_empty() {
917 windows.push(w);
918 }
919 }
920
921 let mut out = Vec::new();
922 for mut w in windows {
923 w.photo_ids.retain(|p| !excluded_photo_ids.contains(p));
925 if w.photo_ids.len() < 8 || w.cluster_ids.len() < 2 {
926 continue;
927 }
928
929 let mut counts: HashMap<i64, usize> = HashMap::new();
933 for (pid, _, cid) in &rows {
934 if w.photo_ids.contains(pid) {
935 *counts.entry(*cid).or_insert(0) += 1;
936 }
937 }
938 let mut sorted: Vec<(i64, usize)> = counts.into_iter().collect();
939 sorted.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
940 let top_names: Vec<String> = sorted
941 .iter()
942 .take(2)
943 .filter_map(|(cid, _)| cluster_names.get(cid).and_then(|n| n.clone()))
944 .collect();
945
946 let start_dt = chrono::DateTime::<Utc>::from_timestamp(w.start_ts, 0);
948 let end_dt = chrono::DateTime::<Utc>::from_timestamp(w.end_ts, 0);
949 let date_part = match (start_dt, end_dt) {
950 (Some(s), Some(e)) if s.date_naive() == e.date_naive() => {
951 s.format("%b %-d, %Y").to_string()
952 }
953 (Some(s), Some(e)) => format!("{} – {}", s.format("%b %-d"), e.format("%b %-d, %Y")),
954 _ => String::from("—"),
955 };
956
957 let title = if top_names.is_empty() {
958 format!("Gathering · {}", date_part)
959 } else if top_names.len() == 1 {
960 format!("Time with {} · {}", top_names[0], date_part)
961 } else {
962 format!("{} & {} · {}", top_names[0], top_names[1], date_part)
963 };
964
965 let cover_photo_id = pick_cover(conn, &w.photo_ids);
966 let fingerprint = compute_fingerprint(&w.photo_ids);
967
968 out.push(DetectedSuggestion {
969 kind: "gathering".to_string(),
970 title,
971 photo_ids: w.photo_ids,
972 cover_photo_id,
973 fingerprint,
974 });
975 }
976
977 out
978}
979
980#[allow(dead_code)]
988pub fn detect_suggestions(
989 conn: &Connection,
990 home_city_override: Option<&str>,
991) -> Vec<DetectedSuggestion> {
992 detect_suggestions_with_diagnostics(conn, home_city_override).0
993}
994
995pub fn detect_suggestions_with_diagnostics(
996 conn: &Connection,
997 home_city_override: Option<&str>,
998) -> (Vec<DetectedSuggestion>, SuggestionDiagnostics) {
999 detect_suggestions_with_diagnostics_cancel(conn, home_city_override, None)
1000}
1001
1002pub fn detect_suggestions_with_diagnostics_cancel(
1003 conn: &Connection,
1004 home_city_override: Option<&str>,
1005 cancel: Option<&AtomicBool>,
1006) -> (Vec<DetectedSuggestion>, SuggestionDiagnostics) {
1007 let mut diag = SuggestionDiagnostics {
1008 total_photos_with_date: conn
1009 .query_row(
1010 "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND date_taken IS NOT NULL",
1011 [],
1012 |row| row.get(0),
1013 )
1014 .unwrap_or(0),
1015 photos_with_city: conn
1016 .query_row(
1017 "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND location_city IS NOT NULL AND location_city != ''",
1018 [],
1019 |row| row.get(0),
1020 )
1021 .unwrap_or(0),
1022 photos_with_gps: conn
1023 .query_row(
1024 "SELECT COUNT(*) FROM photos WHERE is_trashed = FALSE AND gps_latitude IS NOT NULL AND gps_longitude IS NOT NULL",
1025 [],
1026 |row| row.get(0),
1027 )
1028 .unwrap_or(0),
1029 ..SuggestionDiagnostics::default()
1030 };
1031
1032 if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1033 return (Vec::new(), diag);
1034 }
1035
1036 let repo = AlbumSuggestionRepo::new(conn);
1037 let existing_fps: HashSet<String> = repo
1038 .get_all_fingerprints()
1039 .unwrap_or_default()
1040 .into_iter()
1041 .collect();
1042
1043 if diag.total_photos_with_date < 20 {
1044 tracing::info!(
1045 "suggestions: insufficient dated photos ({})",
1046 diag.total_photos_with_date
1047 );
1048 return (Vec::new(), diag);
1049 }
1050
1051 let home = detect_home_city(conn, home_city_override);
1052 diag.home_city = home.as_ref().map(|h| h.0.clone());
1053 let trips = detect_trips(conn, home.as_ref());
1054 diag.trip_candidates_passed = trips.len();
1055
1056 if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1057 return (Vec::new(), diag);
1058 }
1059
1060 {
1062 let mut stmt = conn
1063 .prepare(
1064 r#"SELECT p.id, p.location_city, COALESCE(p.location_country,''), p.date_taken
1065 FROM photos p
1066 WHERE p.location_city IS NOT NULL
1067 AND p.date_taken IS NOT NULL
1068 AND p.is_trashed = FALSE
1069 ORDER BY p.location_city, COALESCE(p.location_country,''), p.date_taken"#,
1070 )
1071 .ok();
1072 if let Some(ref mut stmt) = stmt {
1073 let rows: Vec<(i64, String, String, String)> = stmt
1074 .query_map([], |row| {
1075 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
1076 })
1077 .and_then(|iter| iter.collect::<rusqlite::Result<Vec<_>>>())
1078 .unwrap_or_default();
1079 diag.trip_rows = rows.len();
1080
1081 let total_weeks: i64 = conn
1082 .query_row(
1083 "SELECT COUNT(DISTINCT strftime('%Y-%W', date_taken)) FROM photos WHERE date_taken IS NOT NULL AND is_trashed = FALSE",
1084 [],
1085 |row| row.get(0),
1086 )
1087 .unwrap_or(1)
1088 .max(1);
1089 let city_weeks: HashMap<(String, String), i64> = {
1090 match conn.prepare(
1091 r#"SELECT location_city, COALESCE(location_country,''), COUNT(DISTINCT strftime('%Y-%W', date_taken))
1092 FROM photos
1093 WHERE location_city IS NOT NULL AND date_taken IS NOT NULL AND is_trashed = FALSE
1094 GROUP BY location_city, COALESCE(location_country,'')"#,
1095 ) {
1096 Ok(mut stmt) => stmt
1097 .query_map([], |row| {
1098 Ok((
1099 (row.get::<_, String>(0)?, row.get::<_, String>(1)?),
1100 row.get::<_, i64>(2)?,
1101 ))
1102 })
1103 .and_then(|iter| iter.collect::<rusqlite::Result<HashMap<_, _>>>())
1104 .unwrap_or_default(),
1105 Err(_) => HashMap::new(),
1106 }
1107 };
1108
1109 let mut spans: Vec<(String, String, NaiveDate, NaiveDate, usize)> = Vec::new();
1110 let mut city = String::new();
1111 let mut country = String::new();
1112 let mut dates: Vec<NaiveDate> = Vec::new();
1113 let mut flush = |city: &str, country: &str, dates: &mut Vec<NaiveDate>| {
1114 if city.is_empty() || dates.is_empty() {
1115 dates.clear();
1116 return;
1117 }
1118 dates.sort();
1119 let mut start = dates[0];
1120 let mut end = dates[0];
1121 let mut count = 1usize;
1122 for d in dates.iter().skip(1).copied() {
1123 if (d - end).num_days() > 3 {
1124 spans.push((city.to_string(), country.to_string(), start, end, count));
1125 start = d;
1126 count = 0;
1127 }
1128 end = d;
1129 count += 1;
1130 }
1131 spans.push((city.to_string(), country.to_string(), start, end, count));
1132 dates.clear();
1133 };
1134 for (_, c, co, ds) in rows {
1135 let Some(d) = parse_date_prefix(&ds) else {
1136 continue;
1137 };
1138 if c != city || co != country {
1139 flush(&city, &country, &mut dates);
1140 city = c;
1141 country = co;
1142 }
1143 dates.push(d);
1144 }
1145 flush(&city, &country, &mut dates);
1146
1147 for (city, country, start, end, count) in spans {
1148 let duration_days = (end - start).num_days() + 1;
1149 if duration_days < 3 {
1150 diag.trip_gate_duration_rejected += 1;
1151 continue;
1152 }
1153 if count < 15 {
1154 diag.trip_gate_photo_count_rejected += 1;
1155 continue;
1156 }
1157 let cw = city_weeks.get(&(city, country)).copied().unwrap_or(0);
1158 if cw as f64 / total_weeks as f64 >= 0.10 {
1159 diag.trip_gate_rarity_rejected += 1;
1160 continue;
1161 }
1162 }
1163 }
1164 }
1165
1166 let trip_photo_ids: HashSet<i64> = trips
1168 .iter()
1169 .flat_map(|t| t.photo_ids.iter().copied())
1170 .collect();
1171
1172 let events = detect_events(conn, &trip_photo_ids);
1173 diag.event_candidates_passed = events.len();
1174
1175 if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1176 return (Vec::new(), diag);
1177 }
1178
1179 let already_used: HashSet<i64> = trips
1183 .iter()
1184 .chain(events.iter())
1185 .flat_map(|s| s.photo_ids.iter().copied())
1186 .collect();
1187 let gatherings = detect_gatherings(conn, &already_used);
1188
1189 let mut all: Vec<DetectedSuggestion> = Vec::new();
1190 all.extend(trips);
1191 all.extend(events);
1192 all.extend(gatherings);
1193 for suggestion in &mut all {
1194 suggestion.photo_ids = curate_story_photos(conn, &suggestion.photo_ids);
1195 suggestion.cover_photo_id = pick_cover(conn, &suggestion.photo_ids);
1196 suggestion.fingerprint = compute_fingerprint(&suggestion.photo_ids);
1197 }
1198 all.retain(|suggestion| !suggestion.photo_ids.is_empty());
1199
1200 let mut persisted = Vec::new();
1202 for s in all {
1203 if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
1204 break;
1205 }
1206 if existing_fps.contains(&s.fingerprint) {
1207 diag.skipped_existing_fingerprint += 1;
1208 continue;
1209 }
1210 match repo.insert(
1211 &s.kind,
1212 &s.title,
1213 &s.photo_ids,
1214 s.cover_photo_id,
1215 &s.fingerprint,
1216 ) {
1217 Ok(_id) => {
1218 tracing::info!("New {} suggestion: {}", s.kind, s.title);
1219 persisted.push(s);
1220 diag.persisted_new += 1;
1221 }
1222 Err(e) => {
1223 tracing::warn!("Failed to insert suggestion: {}", e);
1224 }
1225 }
1226 }
1227
1228 let _ = repo.cleanup_old(180);
1230
1231 tracing::info!(
1232 "suggestions diagnostics: dated={}, with_city={}, home_city={:?}, trip_passed={}, event_passed={}, persisted={}, skipped_fp={}",
1233 diag.total_photos_with_date,
1234 diag.photos_with_city,
1235 diag.home_city,
1236 diag.trip_candidates_passed,
1237 diag.event_candidates_passed,
1238 diag.persisted_new,
1239 diag.skipped_existing_fingerprint
1240 );
1241
1242 (persisted, diag)
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248 use rusqlite::Connection;
1249 use std::sync::atomic::AtomicBool;
1250
1251 fn create_trip_test_schema(conn: &Connection) {
1252 conn.execute_batch(
1253 r#"
1254 CREATE TABLE photos (
1255 id INTEGER PRIMARY KEY,
1256 location_city TEXT,
1257 location_country TEXT,
1258 date_taken TEXT,
1259 gps_latitude REAL,
1260 gps_longitude REAL,
1261 is_trashed BOOLEAN DEFAULT FALSE
1262 );
1263 "#,
1264 )
1265 .unwrap();
1266 }
1267
1268 #[test]
1269 fn detect_trips_tolerates_missing_album_photos_table() {
1270 let conn = Connection::open_in_memory().unwrap();
1271 create_trip_test_schema(&conn);
1272 conn.execute_batch(
1273 r#"
1274 INSERT INTO photos
1275 (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1276 VALUES
1277 (1, 'Goa', 'India', '2024-01-01T10:00:00Z', 15.2993, 74.1240, FALSE),
1278 (2, 'Goa', 'India', '2024-01-02T10:00:00Z', 15.2993, 74.1240, FALSE),
1279 (3, 'Goa', 'India', '2024-01-03T10:00:00Z', 15.2993, 74.1240, FALSE);
1280 "#,
1281 )
1282 .unwrap();
1283
1284 let suggestions = detect_trips(&conn, None);
1285 assert!(suggestions.is_empty());
1286 }
1287
1288 #[test]
1289 fn detect_trips_does_not_merge_same_city_name_across_countries() {
1290 let conn = Connection::open_in_memory().unwrap();
1291 create_trip_test_schema(&conn);
1292 let mut id = 1;
1293 for country in ["Canada", "United Kingdom"] {
1294 for day in 1..=4 {
1295 for shot in 0..2 {
1296 conn.execute(
1297 r#"INSERT INTO photos
1298 (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1299 VALUES (?1, 'London', ?2, ?3, 51.5, -0.1, FALSE)"#,
1300 params![
1301 id,
1302 country,
1303 format!("2024-01-{day:02}T10:00:{shot:02}Z")
1304 ],
1305 )
1306 .unwrap();
1307 id += 1;
1308 }
1309 }
1310 }
1311 for week in 1..=20 {
1312 let date = NaiveDate::from_ymd_opt(2023, 1, 1)
1313 .unwrap()
1314 .checked_add_days(chrono::Days::new((week - 1) * 7))
1315 .unwrap();
1316 conn.execute(
1317 "INSERT INTO photos (id, date_taken, is_trashed) VALUES (?1, ?2, FALSE)",
1318 params![id, format!("{date}T00:00:00Z")],
1319 )
1320 .unwrap();
1321 id += 1;
1322 }
1323
1324 let suggestions = detect_trips(&conn, None);
1325 assert!(suggestions.is_empty());
1326 }
1327
1328 #[test]
1329 fn detect_trips_keeps_multiple_stops_and_unlocated_photos_in_one_journey() {
1330 let conn = Connection::open_in_memory().unwrap();
1331 crate::db::schema::create_schema(&conn).unwrap();
1332 let stops = [
1333 ("Jaipur", 1_u32, 4_u32, 26.9124, 75.7873),
1334 ("Jodhpur", 5, 8, 26.2389, 73.0243),
1335 ("Udaipur", 9, 12, 24.5854, 73.7125),
1336 ];
1337 let mut id = 1_i64;
1338 for (city, first_day, last_day, lat, lng) in stops {
1339 for day in first_day..=last_day {
1340 for shot in 0..2 {
1341 conn.execute(
1342 r#"INSERT INTO photos
1343 (id, file_path, file_name, file_hash, file_size, date_taken,
1344 gps_latitude, gps_longitude, location_city, location_country)
1345 VALUES (?1, ?2, ?3, ?4, 1, ?5, ?6, ?7, ?8, 'India')"#,
1346 params![
1347 id,
1348 format!("{id}.jpg"),
1349 format!("{id}.jpg"),
1350 format!("hash-{id}"),
1351 format!("2024-03-{day:02}T10:00:{shot:02}Z"),
1352 lat,
1353 lng,
1354 city,
1355 ],
1356 )
1357 .unwrap();
1358 id += 1;
1359 }
1360 }
1361 }
1362 let unlocated_id = id;
1363 conn.execute(
1364 r#"INSERT INTO photos
1365 (id, file_path, file_name, file_hash, file_size, date_taken)
1366 VALUES (?1, ?2, ?3, ?4, 1, '2024-03-06T20:00:00Z')"#,
1367 params![
1368 unlocated_id,
1369 format!("{unlocated_id}.jpg"),
1370 format!("{unlocated_id}.jpg"),
1371 format!("hash-{unlocated_id}")
1372 ],
1373 )
1374 .unwrap();
1375
1376 let home = ("Delhi".to_string(), "India".to_string(), 28.6139, 77.2090);
1377 let suggestions = detect_trips(&conn, Some(&home));
1378
1379 assert_eq!(suggestions.len(), 1);
1380 let trip = &suggestions[0];
1381 assert!(trip.title.contains("Jaipur, Jodhpur & Udaipur"));
1382 assert!(trip.photo_ids.contains(&unlocated_id));
1383 assert_eq!(trip.photo_ids.len(), 25);
1384 }
1385
1386 #[test]
1387 fn suggestion_detection_skips_short_malformed_dates() {
1388 let conn = Connection::open_in_memory().unwrap();
1389 conn.execute_batch(
1390 r#"
1391 CREATE TABLE photos (
1392 id INTEGER PRIMARY KEY,
1393 location_city TEXT,
1394 location_country TEXT,
1395 date_taken TEXT,
1396 gps_latitude REAL,
1397 gps_longitude REAL,
1398 is_trashed BOOLEAN DEFAULT FALSE
1399 );
1400 CREATE TABLE album_suggestions (
1401 id INTEGER PRIMARY KEY,
1402 kind TEXT NOT NULL,
1403 title TEXT NOT NULL,
1404 photo_ids_json TEXT NOT NULL,
1405 cover_photo_id INTEGER,
1406 fingerprint TEXT NOT NULL,
1407 status TEXT NOT NULL,
1408 seen_count INTEGER NOT NULL DEFAULT 0,
1409 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
1410 );
1411 "#,
1412 )
1413 .unwrap();
1414 for id in 1..=22 {
1415 conn.execute(
1416 r#"INSERT INTO photos
1417 (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1418 VALUES (?1, 'Goa', 'India', 'bad', 15.2993, 74.1240, FALSE)"#,
1419 params![id],
1420 )
1421 .unwrap();
1422 }
1423
1424 let (suggestions, _diag) = detect_suggestions_with_diagnostics_cancel(&conn, None, None);
1425 assert!(suggestions.is_empty());
1426 }
1427
1428 #[test]
1429 fn haversine_uses_shortest_path_across_date_line() {
1430 assert!(haversine_km(0.0, 179.9, 0.0, -179.9) < 25.0);
1431 }
1432
1433 #[test]
1434 fn cancelled_detection_persists_no_suggestions() {
1435 let conn = Connection::open_in_memory().unwrap();
1436 conn.execute_batch(
1437 r#"
1438 CREATE TABLE photos (
1439 id INTEGER PRIMARY KEY,
1440 location_city TEXT,
1441 location_country TEXT,
1442 date_taken TEXT,
1443 gps_latitude REAL,
1444 gps_longitude REAL,
1445 is_trashed BOOLEAN DEFAULT FALSE
1446 );
1447 CREATE TABLE album_suggestions (
1448 id INTEGER PRIMARY KEY,
1449 kind TEXT NOT NULL,
1450 title TEXT NOT NULL,
1451 photo_ids_json TEXT NOT NULL,
1452 cover_photo_id INTEGER,
1453 fingerprint TEXT NOT NULL,
1454 status TEXT NOT NULL,
1455 seen_count INTEGER NOT NULL DEFAULT 0,
1456 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
1457 );
1458 INSERT INTO photos
1459 (id, location_city, location_country, date_taken, gps_latitude, gps_longitude, is_trashed)
1460 VALUES
1461 (1, 'Goa', 'India', '2024-01-01T10:00:00Z', 15.2993, 74.1240, FALSE),
1462 (2, 'Goa', 'India', '2024-01-02T10:00:00Z', 15.2993, 74.1240, FALSE);
1463 "#,
1464 )
1465 .unwrap();
1466
1467 let cancel = AtomicBool::new(true);
1468 let (suggestions, _diag) =
1469 detect_suggestions_with_diagnostics_cancel(&conn, None, Some(&cancel));
1470 let count: i64 = conn
1471 .query_row("SELECT COUNT(*) FROM album_suggestions", [], |row| {
1472 row.get(0)
1473 })
1474 .unwrap();
1475
1476 assert!(suggestions.is_empty());
1477 assert_eq!(count, 0);
1478 }
1479}