1use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use rusqlite::{params, Connection, Result as SqliteResult};
8use walkdir::WalkDir;
9
10use crate::db::face_repo::FaceRepo;
11use crate::services::exclusions::ExclusionMatcher;
12use crate::services::path_util::safe_join_relative;
13use crate::services::scanner::calculate_fast_hash;
14
15#[derive(Debug, Default, Clone)]
16pub struct IndexChanges {
17 pub added: Vec<PathBuf>,
18 pub removed: Vec<(i64, PathBuf)>,
19 pub moved: Vec<(i64, PathBuf, PathBuf)>,
20 pub modified: Vec<(i64, PathBuf)>,
21}
22
23impl IndexChanges {
24 pub fn is_empty(&self) -> bool {
25 self.added.is_empty()
26 && self.removed.is_empty()
27 && self.moved.is_empty()
28 && self.modified.is_empty()
29 }
30}
31
32#[derive(Debug, Default, Clone)]
33pub struct ApplyResult {
34 pub new_files: usize,
35 pub moves_applied: usize,
36 pub removals_applied: usize,
37 pub updates_applied: usize,
38}
39
40pub struct Reindexer {
41 supported_extensions: HashSet<String>,
42 skip_patterns: Vec<String>,
43 scan_hidden_folders: bool,
44}
45
46impl Default for Reindexer {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl Reindexer {
53 pub fn new() -> Self {
54 Self::new_with_options(false)
55 }
56
57 pub fn new_with_options(scan_hidden_folders: bool) -> Self {
58 let mut supported_extensions = HashSet::new();
63 for ext in [
64 "jpg", "jpeg", "png", "heic", "heif", "webp", "tif", "tiff", "avif", "bmp", "gif",
66 "nef", "cr2", "cr3", "arw", "dng", "orf", "rw2", "pef", "rwl", "srw", "raf",
68 "mp4", "m4v", "mov", "webm", "mkv", "avi", "3gp", "3g2", "mts", "m2ts",
70 ] {
71 supported_extensions.insert(ext.to_string());
72 }
73
74 let skip_patterns = vec![
75 "System Volume Information".to_string(),
76 "$RECYCLE.BIN".to_string(),
77 ".Trash".to_string(),
78 ".photovault".to_string(),
79 ];
80
81 Self {
82 supported_extensions,
83 skip_patterns,
84 scan_hidden_folders,
85 }
86 }
87
88 pub fn detect_changes(
89 &self,
90 conn: &Connection,
91 drive_root: &Path,
92 ) -> SqliteResult<IndexChanges> {
93 let mut changes = IndexChanges::default();
94 let exclusions = ExclusionMatcher::from_db(conn)?;
95
96 conn.execute_batch(
99 "CREATE TEMP TABLE IF NOT EXISTS found_files (
100 path TEXT PRIMARY KEY,
101 mtime INTEGER
102 );
103 DELETE FROM found_files;",
104 )?;
105
106 let mut insert_stmt =
107 conn.prepare("INSERT OR IGNORE INTO found_files (path, mtime) VALUES (?1, ?2)")?;
108
109 for entry in WalkDir::new(drive_root)
110 .follow_links(false)
111 .into_iter()
112 .filter_entry(|e| !self.should_skip(drive_root, e.path(), &exclusions))
113 {
114 let entry = match entry {
115 Ok(e) => e,
116 Err(_) => continue,
117 };
118
119 if !entry.file_type().is_file() {
120 continue;
121 }
122
123 let ext = entry
124 .path()
125 .extension()
126 .and_then(|e| e.to_str())
127 .map(|e| e.to_lowercase())
128 .unwrap_or_default();
129
130 if !self.supported_extensions.contains(&ext) {
131 continue;
132 }
133
134 let relative_path = match entry.path().strip_prefix(drive_root).ok() {
137 Some(p) => crate::services::path_util::relative_path_for_storage(p),
138 None => continue,
139 };
140
141 let metadata = match fs::metadata(entry.path()) {
142 Ok(metadata) => metadata,
143 Err(_) => continue,
144 };
145 let mtime = metadata
146 .modified()
147 .ok()
148 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
149 .map(|d| d.as_secs() as i64);
150
151 insert_stmt.execute(params![relative_path, mtime])?;
152 }
153 drop(insert_stmt);
154
155 {
157 let mut stmt = conn.prepare(
158 "SELECT f.path FROM temp.found_files f
159 LEFT JOIN photos p ON p.file_path = f.path AND p.is_trashed = FALSE
160 WHERE p.id IS NULL",
161 )?;
162 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
163 for row in rows {
164 let path = row?;
165 if let Ok(full) = safe_join_relative(drive_root, &path) {
166 changes.added.push(full);
167 }
168 }
169 }
170
171 {
173 let mut stmt = conn.prepare(
174 "SELECT p.id, p.file_path FROM photos p
175 INNER JOIN temp.found_files f ON f.path = p.file_path
176 WHERE p.is_trashed = FALSE
177 AND f.mtime IS NOT NULL
178 AND (p.file_mtime IS NULL OR f.mtime > p.file_mtime)",
179 )?;
180 let rows = stmt.query_map([], |row| {
181 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
182 })?;
183 for row in rows {
184 let (id, path) = row?;
185 if let Ok(full) = safe_join_relative(drive_root, &path) {
186 changes.modified.push((id, full));
187 }
188 }
189 }
190
191 {
194 let mut stmt = conn.prepare(
195 "SELECT p.id, p.file_path, p.file_hash FROM photos p
196 LEFT JOIN temp.found_files f ON f.path = p.file_path
197 WHERE p.is_trashed = FALSE AND f.path IS NULL",
198 )?;
199 let rows = stmt.query_map([], |row| {
200 Ok((
201 row.get::<_, i64>(0)?,
202 row.get::<_, String>(1)?,
203 row.get::<_, String>(2)?,
204 ))
205 })?;
206
207 let mut missing: Vec<(i64, String, String)> = Vec::new();
208 for row in rows {
209 missing.push(row?);
210 }
211
212 let candidate_paths: Vec<String> = changes
217 .added
218 .iter()
219 .filter_map(|p| p.strip_prefix(drive_root).ok())
220 .map(crate::services::path_util::relative_path_for_storage)
221 .collect();
222
223 let mut hash_to_candidate: HashMap<String, String> = HashMap::new();
224 for relative in &candidate_paths {
225 let Ok(full) = safe_join_relative(drive_root, relative) else {
226 continue;
227 };
228 let Ok(meta) = fs::metadata(&full) else {
229 continue;
230 };
231 let mtime = meta
232 .modified()
233 .ok()
234 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
235 .map(|d| d.as_secs() as i64);
236 if let Ok(hash) = calculate_fast_hash(&full, meta.len(), mtime) {
237 hash_to_candidate
238 .entry(hash)
239 .or_insert_with(|| relative.clone());
240 }
241 }
242
243 let mut consumed_candidates: HashSet<String> = HashSet::new();
244 for (id, old_path, hash) in &missing {
245 match hash_to_candidate.get(hash) {
246 Some(new_path) if !consumed_candidates.contains(new_path) => {
247 consumed_candidates.insert(new_path.clone());
248 changes
249 .moved
250 .push((*id, PathBuf::from(old_path), PathBuf::from(new_path)));
251 }
252 _ => {
253 changes.removed.push((*id, PathBuf::from(old_path)));
254 }
255 }
256 }
257
258 if !consumed_candidates.is_empty() {
260 changes.added.retain(|p| {
261 let relative = p
262 .strip_prefix(drive_root)
263 .ok()
264 .map(crate::services::path_util::relative_path_for_storage)
265 .unwrap_or_default();
266 !consumed_candidates.contains(&relative)
267 });
268 }
269 }
270
271 conn.execute("DROP TABLE IF EXISTS temp.found_files", [])?;
272
273 Ok(changes)
274 }
275
276 pub fn apply_changes(
277 &self,
278 conn: &Connection,
279 changes: &IndexChanges,
280 ) -> SqliteResult<ApplyResult> {
281 let mut result = ApplyResult::default();
282 let tx = conn.unchecked_transaction()?;
283
284 for (photo_id, _old_path, new_path) in &changes.moved {
285 let new_relative = crate::services::path_util::relative_path_for_storage(new_path);
286 let new_file_name = new_path
287 .file_name()
288 .and_then(|n| n.to_str())
289 .unwrap_or(&new_relative)
290 .to_string();
291 tx.execute(
292 "UPDATE photos
293 SET file_path = ?1,
294 file_name = ?2,
295 updated_at = CURRENT_TIMESTAMP
296 WHERE id = ?3",
297 params![new_relative, new_file_name, photo_id],
298 )?;
299 result.moves_applied += 1;
300 }
301
302 for (photo_id, path) in &changes.removed {
303 let original_path = path.to_string_lossy().to_string();
304 tx.execute(
305 "INSERT OR IGNORE INTO trash (photo_id, original_path) VALUES (?1, ?2)",
306 params![photo_id, original_path],
307 )?;
308 tx.execute(
309 "UPDATE photos SET is_trashed = TRUE, trashed_at = CURRENT_TIMESTAMP WHERE id = ?1",
310 params![photo_id],
311 )?;
312 result.removals_applied += 1;
313 }
314
315 for (photo_id, path) in &changes.modified {
316 clear_face_derivatives_for_photo(&tx, *photo_id)?;
317 let file_facts = fs::metadata(path).ok().and_then(|meta| {
318 let size = i64::try_from(meta.len()).ok()?;
319 let mtime = meta
320 .modified()
321 .ok()
322 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
323 .map(|d| d.as_secs() as i64);
324 let hash = calculate_fast_hash(path, meta.len(), mtime).ok()?;
325 Some((size, mtime, hash))
326 });
327 let (file_size, file_mtime, file_hash) = match file_facts {
328 Some((size, mtime, hash)) => (Some(size), mtime, Some(hash)),
329 None => (None, None, None),
330 };
331 tx.execute(
332 "UPDATE photos
333 SET file_size = COALESCE(?2, file_size),
334 file_mtime = COALESCE(?3, file_mtime),
335 file_hash = COALESCE(?4, file_hash),
336 metadata_extracted = FALSE,
337 thumbnailed = FALSE,
338 thumbnail_path = NULL,
339 faces_processed = FALSE,
340 updated_at = CURRENT_TIMESTAMP
341 WHERE id = ?1",
342 params![photo_id, file_size, file_mtime, file_hash],
343 )?;
344 result.updates_applied += 1;
345 }
346
347 tx.commit()?;
348 if !changes.modified.is_empty() {
349 FaceRepo::new(conn).normalize_cluster_stats()?;
350 }
351 result.new_files = changes.added.len();
352 Ok(result)
353 }
354
355 fn should_skip(&self, drive_root: &Path, path: &Path, exclusions: &ExclusionMatcher) -> bool {
356 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
357 if !self.scan_hidden_folders && name.starts_with('.') {
358 return true;
359 }
360 self.skip_patterns.iter().any(|p| name.starts_with(p))
361 || exclusions.should_skip_path(drive_root, path)
362 }
363}
364
365fn clear_face_derivatives_for_photo(
366 tx: &rusqlite::Transaction<'_>,
367 photo_id: i64,
368) -> SqliteResult<()> {
369 let face_ids = {
370 let mut stmt = tx.prepare("SELECT id FROM faces WHERE photo_id = ?1")?;
371 let rows = stmt.query_map(params![photo_id], |row| row.get::<_, i64>(0))?;
372 rows.collect::<SqliteResult<Vec<_>>>()?
373 };
374
375 for face_id in face_ids {
376 tx.execute(
377 "DELETE FROM face_review_queue WHERE face_id = ?1",
378 params![face_id],
379 )?;
380 tx.execute(
381 "DELETE FROM face_negatives WHERE face_id = ?1",
382 params![face_id],
383 )?;
384 tx.execute(
385 "DELETE FROM person_gallery_embeddings WHERE face_id = ?1",
386 params![face_id],
387 )?;
388 tx.execute("DELETE FROM faces WHERE id = ?1", params![face_id])?;
389 }
390
391 tx.execute(
392 "DELETE FROM photo_inferred_identities WHERE photo_id = ?1 OR source_photo_id = ?1",
393 params![photo_id],
394 )?;
395
396 Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use crate::db::create_schema;
403 use crate::services::scanner::calculate_hash;
404 use std::io::Write;
405 use tempfile::tempdir;
406
407 #[test]
412 fn full_hash_distinguishes_files_with_shared_prefix() {
413 let temp = tempdir().unwrap();
414 let prefix = vec![0xABu8; 65_536];
415
416 let path_a = temp.path().join("a.bin");
417 let mut f = fs::File::create(&path_a).unwrap();
418 f.write_all(&prefix).unwrap();
419 f.write_all(&[0x01u8; 4096]).unwrap();
420 drop(f);
421
422 let path_b = temp.path().join("b.bin");
423 let mut f = fs::File::create(&path_b).unwrap();
424 f.write_all(&prefix).unwrap();
425 f.write_all(&[0x02u8; 4096]).unwrap();
426 drop(f);
427
428 let hash_a = calculate_hash(&path_a).unwrap();
429 let hash_b = calculate_hash(&path_b).unwrap();
430 assert_ne!(
431 hash_a, hash_b,
432 "files differing past the first 64 KB must produce different hashes"
433 );
434 }
435
436 #[test]
437 fn reindexer_excludes_folder_descendants() {
438 let temp = tempdir().unwrap();
439 let excluded = temp.path().join("Trips").join("Goa");
440 let similar = temp.path().join("Trips").join("Goa2");
441 fs::create_dir_all(&excluded).unwrap();
442 fs::create_dir_all(&similar).unwrap();
443
444 let matcher = ExclusionMatcher::new(vec!["Trips/Goa".into()]);
445 let reindexer = Reindexer::new();
446
447 assert!(reindexer.should_skip(temp.path(), &excluded, &matcher));
448 assert!(!reindexer.should_skip(temp.path(), &similar, &matcher));
449 }
450
451 #[test]
452 fn apply_removed_files_creates_trash_rows() {
453 let conn = Connection::open_in_memory().unwrap();
454 create_schema(&conn).unwrap();
455 conn.execute(
456 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
457 VALUES (1, 'missing.jpg', 'missing.jpg', 'hash', 8, FALSE)",
458 [],
459 )
460 .unwrap();
461
462 let changes = IndexChanges {
463 removed: vec![(1, PathBuf::from("missing.jpg"))],
464 ..IndexChanges::default()
465 };
466 let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
467
468 assert_eq!(result.removals_applied, 1);
469 let is_trashed: bool = conn
470 .query_row("SELECT is_trashed FROM photos WHERE id = 1", [], |row| {
471 row.get(0)
472 })
473 .unwrap();
474 assert!(is_trashed);
475 let original_path: String = conn
476 .query_row(
477 "SELECT original_path FROM trash WHERE photo_id = 1",
478 [],
479 |row| row.get(0),
480 )
481 .unwrap();
482 assert_eq!(original_path, "missing.jpg");
483 }
484
485 #[test]
486 fn detect_changes_uses_file_mtime_for_modified_files() {
487 let temp = tempdir().unwrap();
488 let path = temp.path().join("photo.jpg");
489 fs::write(&path, vec![0xABu8; 12_000]).unwrap();
490 let metadata = fs::metadata(&path).unwrap();
491 let current_mtime = metadata
492 .modified()
493 .unwrap()
494 .duration_since(std::time::UNIX_EPOCH)
495 .unwrap()
496 .as_secs() as i64;
497
498 let conn = Connection::open_in_memory().unwrap();
499 create_schema(&conn).unwrap();
500 conn.execute(
501 "INSERT INTO photos (
502 id, file_path, file_name, file_hash, file_size, file_mtime,
503 metadata_extracted, thumbnailed, faces_processed, updated_at
504 ) VALUES (
505 1, 'photo.jpg', 'photo.jpg', 'hash', 12000, ?1,
506 TRUE, TRUE, TRUE, datetime('now', '+1 day')
507 )",
508 [current_mtime - 10],
509 )
510 .unwrap();
511
512 let changes = Reindexer::new_with_options(true)
513 .detect_changes(&conn, temp.path())
514 .unwrap();
515 assert_eq!(changes.modified.len(), 1);
516 assert_eq!(changes.modified[0].0, 1);
517 }
518
519 #[test]
520 fn detect_changes_identifies_moved_files() {
521 let temp = tempdir().unwrap();
522 let new_dir = temp.path().join("new");
523 fs::create_dir_all(&new_dir).unwrap();
524 let new_path = new_dir.join("photo.jpg");
525 fs::write(&new_path, vec![0xEFu8; 12_000]).unwrap();
526 let meta = fs::metadata(&new_path).unwrap();
527 let mtime = meta
528 .modified()
529 .ok()
530 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
531 .map(|d| d.as_secs() as i64);
532 let hash = calculate_fast_hash(&new_path, meta.len(), mtime).unwrap();
533
534 let conn = Connection::open_in_memory().unwrap();
535 create_schema(&conn).unwrap();
536 conn.execute(
537 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size, is_trashed)
538 VALUES (1, 'old/photo.jpg', 'photo.jpg', ?1, ?2, FALSE)",
539 params![hash, meta.len() as i64],
540 )
541 .unwrap();
542
543 let changes = Reindexer::new_with_options(true)
544 .detect_changes(&conn, temp.path())
545 .unwrap();
546
547 assert_eq!(changes.moved.len(), 1);
548 assert_eq!(changes.moved[0].0, 1);
549 assert_eq!(changes.moved[0].1, PathBuf::from("old/photo.jpg"));
550 assert_eq!(changes.moved[0].2, PathBuf::from("new/photo.jpg"));
551 assert!(changes.added.is_empty());
552 assert!(changes.removed.is_empty());
553 }
554
555 #[test]
556 fn apply_modified_files_resets_stale_processing_state() {
557 let temp = tempdir().unwrap();
558 let changed_path = temp.path().join("changed.jpg");
559 fs::write(&changed_path, vec![0xCDu8; 12_000]).unwrap();
560 let meta = fs::metadata(&changed_path).unwrap();
561 let mtime = meta
562 .modified()
563 .ok()
564 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
565 .map(|d| d.as_secs() as i64);
566 let expected_hash = calculate_fast_hash(&changed_path, meta.len(), mtime).unwrap();
567
568 let conn = Connection::open_in_memory().unwrap();
569 create_schema(&conn).unwrap();
570 conn.execute(
571 "INSERT INTO photos (
572 id, file_path, file_name, file_hash, file_size, thumbnail_path,
573 file_mtime,
574 metadata_extracted, thumbnailed, faces_processed
575 ) VALUES (
576 1, 'changed.jpg', 'changed.jpg', 'old-hash', 8, '.photovault/thumbnails/medium/v2/ha/hash.jpg',
577 1,
578 TRUE, TRUE, TRUE
579 )",
580 [],
581 )
582 .unwrap();
583 conn.execute(
584 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
585 VALUES (2, 'other.jpg', 'other.jpg', 'other-hash', 8)",
586 [],
587 )
588 .unwrap();
589 conn.execute(
590 "INSERT INTO face_clusters (id, name, representative_face_id, face_count, photo_count)
591 VALUES (10, 'Stale', NULL, 1, 2), (20, 'Other', NULL, 0, 0)",
592 [],
593 )
594 .unwrap();
595 conn.execute(
596 "INSERT INTO faces (
597 id, photo_id, bbox_x, bbox_y, bbox_width, bbox_height,
598 embedding, cluster_id, confidence, user_confirmed
599 )
600 VALUES (100, 1, 0.1, 0.1, 0.2, 0.2, zeroblob(16), 10, 0.99, 0)",
601 [],
602 )
603 .unwrap();
604 conn.execute(
605 "UPDATE face_clusters SET representative_face_id = 100 WHERE id = 10",
606 [],
607 )
608 .unwrap();
609 conn.execute(
610 "INSERT INTO face_review_queue (face_id, candidate_cluster_id, score)
611 VALUES (100, 20, 0.75)",
612 [],
613 )
614 .unwrap();
615 conn.execute(
616 "INSERT INTO face_negatives (face_id, not_cluster_id) VALUES (100, 20)",
617 [],
618 )
619 .unwrap();
620 conn.execute(
621 "INSERT INTO person_gallery_embeddings (cluster_id, face_id, embedding)
622 VALUES (10, 100, zeroblob(16))",
623 [],
624 )
625 .unwrap();
626 conn.execute(
627 "INSERT INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
628 VALUES (1, 10, 2, 0.5)",
629 [],
630 )
631 .unwrap();
632 conn.execute(
633 "INSERT INTO photo_inferred_identities (photo_id, cluster_id, source_photo_id, confidence)
634 VALUES (2, 10, 1, 0.5)",
635 [],
636 )
637 .unwrap();
638
639 let changes = IndexChanges {
640 modified: vec![(1, changed_path.clone())],
641 ..IndexChanges::default()
642 };
643 let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
644 assert_eq!(result.updates_applied, 1);
645
646 let row: (bool, bool, bool, Option<String>, i64, Option<i64>, String) = conn
647 .query_row(
648 "SELECT metadata_extracted, thumbnailed, faces_processed, thumbnail_path,
649 file_size, file_mtime, file_hash
650 FROM photos WHERE id = 1",
651 [],
652 |row| {
653 Ok((
654 row.get(0)?,
655 row.get(1)?,
656 row.get(2)?,
657 row.get(3)?,
658 row.get(4)?,
659 row.get(5)?,
660 row.get(6)?,
661 ))
662 },
663 )
664 .unwrap();
665 assert_eq!(
666 row,
667 (
668 false,
669 false,
670 false,
671 None,
672 meta.len() as i64,
673 mtime,
674 expected_hash
675 )
676 );
677
678 for table in [
679 "faces",
680 "face_review_queue",
681 "face_negatives",
682 "person_gallery_embeddings",
683 "photo_inferred_identities",
684 ] {
685 let count: i64 = conn
686 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| {
687 row.get(0)
688 })
689 .unwrap();
690 assert_eq!(count, 0, "{table} should be cleared");
691 }
692 let cluster_count: i64 = conn
693 .query_row(
694 "SELECT COUNT(*) FROM face_clusters WHERE id = 10",
695 [],
696 |row| row.get(0),
697 )
698 .unwrap();
699 assert_eq!(cluster_count, 0, "empty stale cluster should be pruned");
700 }
701
702 #[test]
703 fn apply_moved_files_updates_file_name() {
704 let conn = Connection::open_in_memory().unwrap();
705 create_schema(&conn).unwrap();
706 conn.execute(
707 "INSERT INTO photos (id, file_path, file_name, file_hash, file_size)
708 VALUES (1, 'old/name.jpg', 'name.jpg', 'hash', 8)",
709 [],
710 )
711 .unwrap();
712
713 let changes = IndexChanges {
714 moved: vec![(
715 1,
716 PathBuf::from("old/name.jpg"),
717 PathBuf::from("new/renamed.jpg"),
718 )],
719 ..IndexChanges::default()
720 };
721 let result = Reindexer::new().apply_changes(&conn, &changes).unwrap();
722 assert_eq!(result.moves_applied, 1);
723
724 let row: (String, String) = conn
725 .query_row(
726 "SELECT file_path, file_name FROM photos WHERE id = 1",
727 [],
728 |row| Ok((row.get(0)?, row.get(1)?)),
729 )
730 .unwrap();
731 assert_eq!(row, ("new/renamed.jpg".into(), "renamed.jpg".into()));
732 }
733}