Skip to main content

smriti/services/
duplicate_detector.rs

1//! Duplicate detection: byte-identical (SHA-256) and near-identical
2//! (perceptual DCT hash). The two passes run in sequence and emit
3//! groups with `duplicate_type = 'exact' | 'perceptual'`.
4
5use 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/// Result of duplicate detection
15#[derive(Debug, Clone)]
16pub struct DuplicateGroup {
17    /// Unique group identifier (SHA hash for exact, `phash:<hex>` for perceptual).
18    pub hash: String,
19
20    /// Photo IDs in this group
21    pub photo_ids: Vec<i64>,
22
23    /// Suggested photo ID to keep
24    pub suggested_keep_id: Option<i64>,
25
26    /// 'exact' | 'perceptual'.
27    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
40/// Hamming-distance threshold (out of 64 bits) below which two photos
41/// are considered the same image. 4 bits ≈ 94% bit agreement — the
42/// floor for "really actually the same shot, different file":
43/// re-encoded JPEGs, stripped-EXIF copies, scale variants, watermark-
44/// added copies. The previous 6-bit threshold (91%) still flagged too
45/// many compositionally-similar but visually distinct photos as
46/// "duplicates" (same wall + same lighting → near-identical pHash but
47/// different subjects). Tighter is the right error: missing a dup is
48/// fine, false-flagging real photos as dups erodes trust in the
49/// listing. Burst-style near-dupes belong to the burst detector.
50const PHASH_HAMMING_THRESHOLD: u32 = 4;
51
52/// Duplicate detection service
53pub 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    /// Find all exact duplicate groups in the database
71    ///
72    /// Returns groups where 2+ photos share the same SHA256 hash.
73    pub fn find_duplicates(
74        conn: &Connection,
75        drive_root: &Path,
76    ) -> rusqlite::Result<Vec<DuplicateGroup>> {
77        // Fast scanner hashes include file metadata, so byte-identical
78        // copies with different mtimes may not share photos.file_hash.
79        // Use file_size only to narrow candidates, then compute the
80        // true full-file SHA-256 for exact duplicate grouping.
81        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    /// Find perceptually-similar duplicate groups (re-edits, re-saves,
151    /// quality-adjusted exports). Computes phash on demand for any
152    /// photo where it's NULL and persists into `photos.phash`, then
153    /// groups by Hamming distance ≤ 10 across the 64-bit hashes.
154    ///
155    /// Excludes photos already grouped as exact duplicates so the UI
156    /// doesn't show the same pair twice.
157    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        // Backfill phash for any non-trashed photo that doesn't yet
173        // have one. Prefer the DB thumbnail path, then known thumbnail
174        // tiers, then the original file so duplicate detection is not
175        // coupled to a particular cache size.
176        Self::backfill_phashes(conn, drive_root, cancel, &mut progress)?;
177        if is_cancelled(cancel) {
178            return Ok(Vec::new());
179        }
180
181        // Pull (id, phash, file_path, date_taken, file_size) for
182        // photos with non-null phash that aren't already in an exact
183        // group. file_path / size feed `suggest_keep`.
184        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        // Candidate generation uses five disjoint pHash bands. With a
211        // threshold of four bit differences across the whole 64-bit
212        // hash, the pigeonhole principle guarantees that any valid
213        // match has at least one identical band. That avoids the old
214        // O(n^2) full comparison while preserving recall for the
215        // configured threshold.
216        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            // Use the first member's phash as the group key (any
312            // member would work — they're all within Hamming 10).
313            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    /// Suggest which photo to keep from a duplicate group
454    ///
455    /// Priority:
456    /// 1. Prefer paths NOT containing "backup", "copy", "old", "duplicate"
457    /// 2. Prefer larger file size
458    /// 3. Prefer shortest path (better organized)
459    /// 4. Prefer oldest by date_taken (stable tie-break via query order)
460    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        // Score each photo (lower bad-pattern score is better, larger size is better)
468        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                // Penalize bad folder names
475                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        // Stable sort preserves original order (oldest first from query) when keys are equal.
486        scored.sort_by(|a, b| {
487            a.1.cmp(&b.1) // fewer bad-pattern penalties first
488                .then_with(|| b.2.cmp(&a.2)) // larger file first
489                .then_with(|| a.3.cmp(&b.3)) // shorter path first
490        });
491
492        scored.first().map(|(id, _, _, _)| *id)
493    }
494
495    /// Wasted bytes across the duplicate listing.
496    ///
497    /// Counts every detected duplicate group — both byte-exact dupes
498    /// (same file_hash) AND perceptual ones (pHash match). The earlier
499    /// version of this function only summed exact matches, which on a
500    /// perceptual-only library reported "0 MB potentially wasted" even
501    /// when the listing had dozens of groups. Now: walk the
502    /// duplicate_groups table directly and sum (total_size - largest)
503    /// per group, since the user's win is keeping one copy and
504    /// trashing the rest.
505    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        // Should prefer ID 2 (no bad patterns, shorter path)
547        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        // Should prefer ID 2 (shorter path)
565        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}