1use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7
8use image_hasher::{HashAlg, HasherConfig};
9use rayon::prelude::*;
10use rusqlite::Connection;
11
12use crate::services::path_util::safe_join_relative;
13
14#[derive(Debug, Clone)]
16pub struct DuplicateGroup {
17 pub hash: String,
19
20 pub photo_ids: Vec<i64>,
22
23 pub suggested_keep_id: Option<i64>,
25
26 pub duplicate_type: &'static str,
28}
29
30#[derive(Debug, Clone)]
31pub struct DuplicateProgress {
32 pub stage: &'static str,
33 pub processed: u64,
34 pub total: Option<u64>,
35 pub message: String,
36}
37
38type ExactCandidate = (i64, String, Option<String>, i64);
39
40const PHASH_HAMMING_THRESHOLD: u32 = 4;
51
52pub struct DuplicateDetector;
54
55struct PendingHashPhoto {
56 id: i64,
57 file_hash: String,
58 file_path: String,
59 orientation: i32,
60 thumbnail_path: Option<String>,
61}
62
63fn is_cancelled(cancel: Option<&AtomicBool>) -> bool {
64 cancel
65 .map(|flag| flag.load(Ordering::Relaxed))
66 .unwrap_or(false)
67}
68
69impl DuplicateDetector {
70 pub fn find_duplicates(
74 conn: &Connection,
75 drive_root: &Path,
76 ) -> rusqlite::Result<Vec<DuplicateGroup>> {
77 let mut stmt = conn.prepare(
82 r#"
83 SELECT file_size, COUNT(*) as count
84 FROM photos
85 WHERE is_trashed = FALSE
86 GROUP BY file_size
87 HAVING count > 1
88 ORDER BY count DESC
89 "#,
90 )?;
91
92 let sizes: Vec<i64> = stmt
93 .query_map([], |row| row.get(0))?
94 .collect::<rusqlite::Result<Vec<_>>>()?;
95
96 let mut groups = Vec::new();
97
98 for size in sizes {
99 let mut photo_stmt = conn.prepare(
100 r#"
101 SELECT id, file_path, date_taken, file_size
102 FROM photos
103 WHERE file_size = ?1 AND is_trashed = FALSE
104 ORDER BY date_taken ASC, file_path ASC
105 "#,
106 )?;
107
108 let photos: Vec<ExactCandidate> = photo_stmt
109 .query_map([size], |row| {
110 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
111 })?
112 .collect::<rusqlite::Result<Vec<_>>>()?;
113
114 if photos.len() < 2 {
115 continue;
116 }
117
118 let mut by_full_hash: std::collections::HashMap<String, Vec<ExactCandidate>> =
119 std::collections::HashMap::new();
120 for photo in photos {
121 let Ok(path) = safe_join_relative(drive_root, &photo.1) else {
122 continue;
123 };
124 let Ok(full_hash) = crate::services::scanner::calculate_hash(&path) else {
125 continue;
126 };
127 by_full_hash.entry(full_hash).or_default().push(photo);
128 }
129
130 for (hash, photos) in by_full_hash {
131 if photos.len() < 2 {
132 continue;
133 }
134 let photo_ids: Vec<i64> = photos.iter().map(|(id, _, _, _)| *id).collect();
135 let suggested_keep_id = Self::suggest_keep(&photos);
136
137 groups.push(DuplicateGroup {
138 hash,
139 photo_ids,
140 suggested_keep_id,
141 duplicate_type: "exact",
142 });
143 }
144 }
145
146 groups.sort_by_key(|g| std::cmp::Reverse(g.photo_ids.len()));
147 Ok(groups)
148 }
149
150 pub fn find_perceptual_duplicates(
158 conn: &Connection,
159 drive_root: &Path,
160 exclude_ids: &std::collections::HashSet<i64>,
161 ) -> rusqlite::Result<Vec<DuplicateGroup>> {
162 Self::find_perceptual_duplicates_with_progress(conn, drive_root, exclude_ids, None, |_| {})
163 }
164
165 pub fn find_perceptual_duplicates_with_progress(
166 conn: &Connection,
167 drive_root: &Path,
168 exclude_ids: &std::collections::HashSet<i64>,
169 cancel: Option<&AtomicBool>,
170 mut progress: impl FnMut(DuplicateProgress),
171 ) -> rusqlite::Result<Vec<DuplicateGroup>> {
172 Self::backfill_phashes(conn, drive_root, cancel, &mut progress)?;
177 if is_cancelled(cancel) {
178 return Ok(Vec::new());
179 }
180
181 let mut stmt = conn.prepare(
185 r#"
186 SELECT id, phash, file_path, date_taken, file_size
187 FROM photos
188 WHERE is_trashed = FALSE AND phash IS NOT NULL
189 "#,
190 )?;
191 let rows: Vec<(i64, i64, String, Option<String>, i64)> = stmt
192 .query_map([], |r| {
193 Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
194 })?
195 .collect::<rusqlite::Result<Vec<_>>>()?
196 .into_iter()
197 .filter(|row| !exclude_ids.contains(&row.0))
198 .collect();
199
200 if rows.len() < 2 {
201 return Ok(Vec::new());
202 }
203 progress(DuplicateProgress {
204 stage: "perceptual-index",
205 processed: 0,
206 total: Some(rows.len() as u64),
207 message: format!("indexing {} visual fingerprints", rows.len()),
208 });
209
210 fn find(p: &mut [usize], mut x: usize) -> usize {
217 while p[x] != x {
218 p[x] = p[p[x]];
219 x = p[x];
220 }
221 x
222 }
223 let n = rows.len();
224 let phashes: Vec<u64> = rows.iter().map(|r| r.1 as u64).collect();
225 const BANDS: [(u32, u32); 5] = [(0, 13), (13, 13), (26, 13), (39, 13), (52, 12)];
226 let mut buckets: std::collections::HashMap<(usize, u64), Vec<usize>> =
227 std::collections::HashMap::with_capacity(n * BANDS.len());
228 for (idx, hash) in phashes.iter().copied().enumerate() {
229 if is_cancelled(cancel) {
230 return Ok(Vec::new());
231 }
232 for (band_idx, (shift, width)) in BANDS.iter().copied().enumerate() {
233 let mask = (1u64 << width) - 1;
234 buckets
235 .entry((band_idx, (hash >> shift) & mask))
236 .or_default()
237 .push(idx);
238 }
239 }
240
241 progress(DuplicateProgress {
242 stage: "perceptual-compare",
243 processed: 0,
244 total: Some(buckets.len() as u64),
245 message: format!("checking {} candidate buckets", buckets.len()),
246 });
247
248 let mut seen_pairs = std::collections::HashSet::new();
249 let mut pairs = Vec::new();
250 let total_buckets = buckets.len() as u64;
251 let tick = total_buckets.div_ceil(40).max(1_000);
252 for (bucket_idx, members) in buckets.into_values().enumerate() {
253 if is_cancelled(cancel) {
254 return Ok(Vec::new());
255 }
256 if members.len() > 1 {
257 for i in 0..members.len() {
258 for j in (i + 1)..members.len() {
259 let a_idx = members[i].min(members[j]);
260 let b_idx = members[i].max(members[j]);
261 let key = ((a_idx as u64) << 32) | b_idx as u64;
262 if !seen_pairs.insert(key) {
263 continue;
264 }
265 let dist = (phashes[a_idx] ^ phashes[b_idx]).count_ones();
266 if dist <= PHASH_HAMMING_THRESHOLD {
267 pairs.push((a_idx, b_idx));
268 }
269 }
270 }
271 }
272 let processed = (bucket_idx + 1) as u64;
273 if processed.is_multiple_of(tick) || processed == total_buckets {
274 progress(DuplicateProgress {
275 stage: "perceptual-compare",
276 processed,
277 total: Some(total_buckets),
278 message: format!("{} visual matches", pairs.len()),
279 });
280 }
281 }
282 if is_cancelled(cancel) {
283 return Ok(Vec::new());
284 }
285 let mut parent: Vec<usize> = (0..n).collect();
286 for (i, j) in pairs {
287 let ra = find(&mut parent, i);
288 let rb = find(&mut parent, j);
289 if ra != rb {
290 parent[ra] = rb;
291 }
292 }
293
294 let mut groups_map: std::collections::HashMap<usize, Vec<usize>> =
295 std::collections::HashMap::new();
296 for i in 0..rows.len() {
297 let r = find(&mut parent, i);
298 groups_map.entry(r).or_default().push(i);
299 }
300
301 let mut groups = Vec::new();
302 for (_root, members) in groups_map {
303 if members.len() < 2 {
304 continue;
305 }
306 let photo_quad: Vec<(i64, String, Option<String>, i64)> = members
307 .iter()
308 .map(|&m| (rows[m].0, rows[m].2.clone(), rows[m].3.clone(), rows[m].4))
309 .collect();
310 let suggested_keep_id = Self::suggest_keep(&photo_quad);
311 let phash_key = format!("phash:{:016x}", rows[members[0]].1 as u64);
314 groups.push(DuplicateGroup {
315 hash: phash_key,
316 photo_ids: photo_quad.into_iter().map(|(id, _, _, _)| id).collect(),
317 suggested_keep_id,
318 duplicate_type: "perceptual",
319 });
320 }
321 Ok(groups)
322 }
323
324 fn backfill_phashes(
325 conn: &Connection,
326 drive_root: &Path,
327 cancel: Option<&AtomicBool>,
328 progress: &mut impl FnMut(DuplicateProgress),
329 ) -> rusqlite::Result<()> {
330 let mut stmt = conn.prepare(
331 "SELECT id, file_hash, file_path, orientation, thumbnail_path
332 FROM photos
333 WHERE is_trashed = FALSE AND media_type = 'photo' AND phash IS NULL",
334 )?;
335 let pending: Vec<PendingHashPhoto> = stmt
336 .query_map([], |r| {
337 Ok(PendingHashPhoto {
338 id: r.get(0)?,
339 file_hash: r.get(1)?,
340 file_path: r.get(2)?,
341 orientation: r.get::<_, Option<i32>>(3)?.unwrap_or(1),
342 thumbnail_path: r.get(4)?,
343 })
344 })?
345 .collect::<rusqlite::Result<Vec<_>>>()?;
346 if pending.is_empty() {
347 return Ok(());
348 }
349 let total = pending.len() as u64;
350 progress(DuplicateProgress {
351 stage: "perceptual-hash",
352 processed: 0,
353 total: Some(total),
354 message: format!("building visual fingerprints for {} photos", total),
355 });
356
357 let processed = AtomicU64::new(0);
358
359 let computed: Vec<(i64, i64)> = pending
360 .par_iter()
361 .filter_map(|photo| {
362 if is_cancelled(cancel) {
363 return None;
364 }
365 let (source, apply_orientation) = Self::phash_source_path(drive_root, photo)?;
366 let result = match Self::compute_phash(
367 &source,
368 apply_orientation.then_some(photo.orientation),
369 ) {
370 Ok(phash) => Some((photo.id, phash)),
371 Err(e) => {
372 tracing::trace!("phash skip {}: {}", source.display(), e);
373 None
374 }
375 };
376 processed.fetch_add(1, Ordering::Relaxed);
377 result
378 })
379 .collect();
380 let done = processed.load(Ordering::Relaxed);
381 progress(DuplicateProgress {
382 stage: "perceptual-hash",
383 processed: done,
384 total: Some(total),
385 message: format!("{} visual fingerprints ready", computed.len()),
386 });
387 if is_cancelled(cancel) {
388 return Ok(());
389 }
390
391 let tx = conn.unchecked_transaction()?;
392 {
393 let mut update = tx.prepare("UPDATE photos SET phash = ?2 WHERE id = ?1")?;
394 for (id, phash) in &computed {
395 update.execute(rusqlite::params![id, phash])?;
396 }
397 }
398 tx.commit()?;
399 if !computed.is_empty() {
400 tracing::info!("Backfilled phash for {} photos", computed.len());
401 }
402 Ok(())
403 }
404
405 fn phash_source_path(drive_root: &Path, photo: &PendingHashPhoto) -> Option<(PathBuf, bool)> {
406 let mut candidates = Vec::with_capacity(5);
407 if let Some(path) = &photo.thumbnail_path {
408 if let Ok(path) = crate::services::path_util::safe_join_relative(drive_root, path) {
409 candidates.push((path, false));
410 }
411 }
412
413 let subdir = &photo.file_hash[..2.min(photo.file_hash.len())];
414 for size in ["small", "medium", "large"] {
415 candidates.push((
416 drive_root
417 .join(".photovault")
418 .join("thumbnails")
419 .join(size)
420 .join("v2")
421 .join(subdir)
422 .join(format!("{}.jpg", photo.file_hash)),
423 false,
424 ));
425 }
426
427 if let Ok(path) =
428 crate::services::path_util::safe_join_relative(drive_root, &photo.file_path)
429 {
430 candidates.push((path, true));
431 }
432 candidates.into_iter().find(|(p, _)| p.exists())
433 }
434
435 fn compute_phash(path: &Path, orientation: Option<i32>) -> Result<i64, String> {
436 let img = crate::services::image_io::open_image(path)?;
437 let img = match orientation {
438 Some(o) => crate::services::image_utils::apply_exif_orientation(img, o),
439 None => img,
440 };
441 let hasher = HasherConfig::new()
442 .hash_alg(HashAlg::DoubleGradient)
443 .hash_size(8, 8)
444 .to_hasher();
445 let h = hasher.hash_image(&img);
446 let bytes = h.as_bytes();
447 let mut buf = [0u8; 8];
448 let n = bytes.len().min(8);
449 buf[..n].copy_from_slice(&bytes[..n]);
450 Ok(i64::from_le_bytes(buf))
451 }
452
453 fn suggest_keep(photos: &[(i64, String, Option<String>, i64)]) -> Option<i64> {
461 if photos.is_empty() {
462 return None;
463 }
464
465 let bad_folder_patterns = ["backup", "copy", "old", "duplicate", "temp", "tmp"];
466
467 let mut scored: Vec<(i64, i32, i64, usize)> = photos
469 .iter()
470 .map(|(id, path, _date, size)| {
471 let path_lower = path.to_lowercase();
472 let mut bad_score = 0i32;
473
474 for pattern in &bad_folder_patterns {
476 if path_lower.contains(pattern) {
477 bad_score += 100;
478 }
479 }
480
481 (*id, bad_score, *size, path.len())
482 })
483 .collect();
484
485 scored.sort_by(|a, b| {
487 a.1.cmp(&b.1) .then_with(|| b.2.cmp(&a.2)) .then_with(|| a.3.cmp(&b.3)) });
491
492 scored.first().map(|(id, _, _, _)| *id)
493 }
494
495 pub fn calculate_wasted_space(conn: &Connection) -> rusqlite::Result<u64> {
506 let wasted: i64 = conn.query_row(
507 r#"
508 SELECT COALESCE(SUM(total_size - max_size), 0)
509 FROM (
510 SELECT SUM(p.file_size) AS total_size,
511 MAX(p.file_size) AS max_size
512 FROM duplicate_groups g
513 JOIN duplicate_group_members m ON m.group_id = g.id
514 JOIN photos p ON p.id = m.photo_id
515 WHERE p.is_trashed = FALSE
516 GROUP BY g.id
517 HAVING COUNT(*) > 1
518 )
519 "#,
520 [],
521 |row| row.get(0),
522 )?;
523
524 Ok(wasted.max(0) as u64)
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::db::schema::create_schema;
532 use image::{Rgb, RgbImage};
533 use rusqlite::Connection;
534 use tempfile::tempdir;
535
536 #[test]
537 fn test_suggest_keep_prefers_good_paths() {
538 let photos = vec![
539 (1, "/Photos/backup/image.jpg".to_string(), None, 1000),
540 (2, "/Photos/2019/image.jpg".to_string(), None, 1000),
541 (3, "/Photos/old/copy/image.jpg".to_string(), None, 1000),
542 ];
543
544 let suggested = DuplicateDetector::suggest_keep(&photos);
545
546 assert_eq!(suggested, Some(2));
548 }
549
550 #[test]
551 fn test_suggest_keep_prefers_shorter_path() {
552 let photos = vec![
553 (
554 1,
555 "/Photos/2019/March/Trip/image.jpg".to_string(),
556 None,
557 1000,
558 ),
559 (2, "/Photos/image.jpg".to_string(), None, 1000),
560 ];
561
562 let suggested = DuplicateDetector::suggest_keep(&photos);
563
564 assert_eq!(suggested, Some(2));
566 }
567
568 #[test]
569 fn exact_duplicates_use_full_file_hash_not_scanner_fast_hash() {
570 let temp = tempdir().unwrap();
571 let conn = Connection::open_in_memory().unwrap();
572 create_schema(&conn).unwrap();
573 std::fs::create_dir_all(temp.path().join("photos")).unwrap();
574 std::fs::write(temp.path().join("photos/a.jpg"), b"identical bytes").unwrap();
575 std::fs::write(temp.path().join("photos/a-copy.jpg"), b"identical bytes").unwrap();
576
577 conn.execute(
578 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
579 VALUES
580 (1, 'photos/a.jpg', 'a.jpg', 'fast-hash-a', 15, FALSE),
581 (2, 'photos/a-copy.jpg', 'a-copy.jpg', 'fast-hash-b', 15, FALSE)",
582 [],
583 )
584 .unwrap();
585
586 let groups = DuplicateDetector::find_duplicates(&conn, temp.path()).unwrap();
587
588 assert_eq!(groups.len(), 1);
589 assert_eq!(groups[0].duplicate_type, "exact");
590 let mut ids = groups[0].photo_ids.clone();
591 ids.sort_unstable();
592 assert_eq!(ids, vec![1, 2]);
593 }
594
595 #[test]
596 fn perceptual_duplicates_use_medium_thumbnail_when_small_is_missing() {
597 let temp = tempdir().unwrap();
598 let conn = Connection::open_in_memory().unwrap();
599 create_schema(&conn).unwrap();
600
601 insert_photo_with_thumb(
602 &conn,
603 temp.path(),
604 1,
605 "photos/a.jpg",
606 "aa11111111111111111111111111111111111111111111111111111111111111",
607 );
608 insert_photo_with_thumb(
609 &conn,
610 temp.path(),
611 2,
612 "exports/a-copy.jpg",
613 "bb22222222222222222222222222222222222222222222222222222222222222",
614 );
615
616 let groups =
617 DuplicateDetector::find_perceptual_duplicates(&conn, temp.path(), &Default::default())
618 .unwrap();
619
620 assert_eq!(groups.len(), 1);
621 assert_eq!(groups[0].duplicate_type, "perceptual");
622 assert_eq!(groups[0].photo_ids.len(), 2);
623
624 let phash_count: i64 = conn
625 .query_row(
626 "SELECT COUNT(*) FROM photos WHERE phash IS NOT NULL",
627 [],
628 |r| r.get(0),
629 )
630 .unwrap();
631 assert_eq!(phash_count, 2);
632 }
633
634 #[test]
635 fn perceptual_duplicates_fall_back_to_original_file() {
636 let temp = tempdir().unwrap();
637 let conn = Connection::open_in_memory().unwrap();
638 create_schema(&conn).unwrap();
639
640 write_test_image(&temp.path().join("photos/a.jpg"));
641 write_test_image(&temp.path().join("exports/a-copy.jpg"));
642 conn.execute(
643 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, media_type, is_trashed)
644 VALUES
645 (1, 'photos/a.jpg', 'a.jpg', 'hash-a', 100, 'photo', 0),
646 (2, 'exports/a-copy.jpg', 'a-copy.jpg', 'hash-b', 100, 'photo', 0)",
647 [],
648 )
649 .unwrap();
650
651 let groups =
652 DuplicateDetector::find_perceptual_duplicates(&conn, temp.path(), &Default::default())
653 .unwrap();
654
655 assert_eq!(groups.len(), 1);
656 assert_eq!(groups[0].photo_ids.len(), 2);
657 }
658
659 fn insert_photo_with_thumb(
660 conn: &Connection,
661 drive_root: &Path,
662 id: i64,
663 file_path: &str,
664 file_hash: &str,
665 ) {
666 let subdir = &file_hash[..2];
667 let rel_thumb = format!(
668 ".photovault/thumbnails/medium/v2/{}/{}.jpg",
669 subdir, file_hash
670 );
671 write_test_image(&drive_root.join(&rel_thumb));
672 conn.execute(
673 "INSERT INTO photos
674 (id, file_path, file_name, file_hash, file_size, media_type, thumbnail_path, is_trashed)
675 VALUES (?1, ?2, ?3, ?4, 100, 'photo', ?5, 0)",
676 rusqlite::params![id, file_path, file_path, file_hash, rel_thumb],
677 )
678 .unwrap();
679 }
680
681 fn write_test_image(path: &Path) {
682 if let Some(parent) = path.parent() {
683 std::fs::create_dir_all(parent).unwrap();
684 }
685 let mut img = RgbImage::new(64, 64);
686 for y in 0..64 {
687 for x in 0..64 {
688 let color = if x < 32 {
689 Rgb([220, 40, 40])
690 } else if y < 32 {
691 Rgb([40, 180, 80])
692 } else {
693 Rgb([40, 80, 220])
694 };
695 img.put_pixel(x, y, color);
696 }
697 }
698 img.save(path).unwrap();
699 }
700}