1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::AtomicBool;
5use std::time::UNIX_EPOCH;
6
7use rayon::prelude::*;
8
9use crate::db::connection::Database;
10use crate::db::queries::{self, TrackMeta};
11
12use super::features;
13use super::metadata::{self, is_audio_file};
14
15#[derive(Debug, Default)]
17pub struct ScanResult {
18 pub added: usize,
19 pub updated: usize,
20 pub removed: usize,
21 pub skipped: usize,
22 pub unreadable: usize,
25 pub removed_paths: Vec<String>,
28 pub errors: Vec<(PathBuf, String)>,
29 pub cancelled: bool,
31}
32
33#[derive(Debug, Clone, Default)]
35pub struct ScanOptions {
36 pub force: bool,
38 pub cancel: Option<Arc<AtomicBool>>,
44 pub force_remove: bool,
49}
50
51#[cfg(not(test))]
55const CHUNK_SIZE: usize = 1000;
56#[cfg(test)]
57const CHUNK_SIZE: usize = 4;
58
59pub struct ScanEvent<'a> {
61 pub artist: &'a str,
62 pub album: &'a str,
63 pub title: &'a str,
64 pub path: &'a Path,
65 pub is_new: bool,
66}
67
68pub fn scan_folder(
71 db: &Database,
72 path: &Path,
73 opts: ScanOptions,
74 on_track: Option<&dyn Fn(ScanEvent)>,
75) -> ScanResult {
76 let mut result = ScanResult::default();
77
78 let mut audio_files: Vec<PathBuf> = Vec::new();
81 for entry in walkdir::WalkDir::new(path).follow_links(true) {
82 match entry {
83 Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
84 audio_files.push(e.path().to_path_buf())
85 }
86 Ok(_) => {}
87 Err(e) => {
88 result.unreadable += 1;
89 log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
90 }
91 }
92 }
93
94 let total_files = audio_files.len();
95 log::info!("found {} audio files in {}", total_files, path.display());
96
97 let files_to_scan: Vec<PathBuf> = if opts.force {
101 std::mem::take(&mut audio_files)
102 } else {
103 let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
104 audio_files
105 .iter()
106 .filter(|file_path| {
107 let Ok(file_meta) = std::fs::metadata(file_path) else {
108 return true;
109 };
110 let mtime = file_meta
111 .modified()
112 .ok()
113 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
114 .map(|d| d.as_secs() as i64)
115 .unwrap_or(0);
116 let size = file_meta.len() as i64;
117 let path_str = file_path.to_string_lossy();
118 match scan_cache.get(path_str.as_ref()) {
119 Some(&(cached_mtime, cached_size)) => {
120 mtime != cached_mtime || size != cached_size
121 }
122 None => true,
123 }
124 })
125 .cloned()
126 .collect()
127 };
128
129 result.skipped = total_files - files_to_scan.len();
130
131 let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
143 CHUNK_SIZE.saturating_mul(2),
144 );
145 let reader = std::thread::Builder::new()
146 .name("koan-scan-read".into())
147 .spawn(move || {
148 files_to_scan.par_iter().for_each(|file_path| {
149 let _ = send.send((
151 file_path.clone(),
152 isolate_read(file_path, metadata::read_metadata),
153 ));
154 });
155 });
156 if let Err(e) = &reader {
157 log::error!("failed to spawn scan reader: {}", e);
158 result
159 .errors
160 .push((path.to_path_buf(), format!("scan error: {}", e)));
161 return result;
162 }
163
164 loop {
165 let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
167 recv.iter().take(CHUNK_SIZE).collect();
168 if batch.is_empty() {
169 break;
170 }
171 if opts
172 .cancel
173 .as_ref()
174 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
175 {
176 log::info!("scan cancelled — keeping what was already committed");
177 result.cancelled = true;
178 break;
179 }
180
181 let tx = match db.conn.unchecked_transaction() {
182 Ok(tx) => tx,
183 Err(e) => {
184 log::error!("failed to begin scan transaction: {}", e);
185 result
186 .errors
187 .push((path.to_path_buf(), format!("db error: {}", e)));
188 return result;
189 }
190 };
191
192 let (mut added, mut updated) = (0usize, 0usize);
193 for (file_path, meta_result) in batch {
194 match meta_result {
195 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
196 Ok((track_id, is_new)) => {
197 if is_new {
198 added += 1;
199 } else {
200 updated += 1;
201 }
202 if let Some(cb) = &on_track {
203 cb(ScanEvent {
204 artist: &meta.artist,
205 album: &meta.album,
206 title: &meta.title,
207 path: &file_path,
208 is_new,
209 });
210 }
211 if let Err(e) = queries::update_scan_cache(
212 &tx,
213 meta.path.as_deref().unwrap_or(""),
214 meta.mtime.unwrap_or(0),
215 meta.size_bytes.unwrap_or(0),
216 track_id,
217 ) {
218 log::warn!("failed to cache {}: {}", file_path.display(), e);
220 }
221 }
222 Err(e) => {
223 result.errors.push((file_path, format!("db error: {}", e)));
224 }
225 },
226 Err(e) => {
227 result.errors.push((file_path, e));
228 }
229 }
230 }
231
232 match tx.commit() {
233 Ok(()) => {
234 result.added += added;
235 result.updated += updated;
236 }
237 Err(e) => {
238 log::error!("failed to commit scan transaction: {}", e);
239 result
240 .errors
241 .push((path.to_path_buf(), format!("db error: {}", e)));
242 }
243 }
244 }
245
246 if let Ok(handle) = reader
247 && handle.join().is_err()
248 {
249 log::error!("scan reader thread panicked");
250 }
251
252 if result.cancelled {
253 return result;
257 }
258
259 if total_files == 0 {
263 log::error!(
264 "{} contains no audio files — skipping stale-track removal. \
265 If this folder should have music in it, it is probably not mounted or not readable.",
266 path.display()
267 );
268 return result;
269 }
270
271 let tx = match db.conn.unchecked_transaction() {
272 Ok(tx) => tx,
273 Err(e) => {
274 log::error!("failed to begin stale-removal transaction: {}", e);
275 result
276 .errors
277 .push((path.to_path_buf(), format!("db error: {}", e)));
278 return result;
279 }
280 };
281 match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
282 Ok(removed) => {
283 result.removed = removed.len();
284 result.removed_paths = removed;
285 if let Err(e) = tx.commit() {
286 log::error!("failed to commit stale removals: {}", e);
287 result.removed = 0;
288 result.removed_paths.clear();
289 result
290 .errors
291 .push((path.to_path_buf(), format!("db error: {}", e)));
292 }
293 }
294 Err(e) => {
295 log::error!("failed to remove stale tracks: {}", e);
296 result.errors.push((path.to_path_buf(), e.to_string()));
297 }
298 }
299
300 result
301}
302
303fn isolate_read(
308 path: &Path,
309 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
310) -> Result<TrackMeta, String> {
311 match catch_unwind(AssertUnwindSafe(|| read(path))) {
312 Ok(result) => result.map_err(|e| e.to_string()),
313 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
314 }
315}
316
317#[derive(Debug, Default)]
319pub struct ImportResult {
320 pub track_ids: Vec<i64>,
323 pub added: usize,
324 pub updated: usize,
325 pub errors: Vec<(PathBuf, String)>,
326}
327
328pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
339 let mut result = ImportResult::default();
340
341 let mut files: Vec<PathBuf> = Vec::new();
342 let mut seen = std::collections::HashSet::new();
343 for path in paths {
344 let mut found: Vec<PathBuf> = walkdir::WalkDir::new(path)
345 .follow_links(true)
346 .into_iter()
347 .filter_map(Result::ok)
348 .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
349 .map(|e| e.path().to_path_buf())
350 .collect();
351 found.sort();
352 files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
354 }
355
356 if files.is_empty() {
357 return result;
358 }
359
360 let read: Vec<(PathBuf, Result<TrackMeta, String>)> = files
362 .par_iter()
363 .map(|path| (path.clone(), isolate_read(path, metadata::read_metadata)))
364 .collect();
365
366 let tx = match db.conn.unchecked_transaction() {
367 Ok(tx) => tx,
368 Err(e) => {
369 result
370 .errors
371 .push((PathBuf::new(), format!("db error: {e}")));
372 return result;
373 }
374 };
375
376 for (path, meta_result) in read {
377 let meta = match meta_result {
378 Ok(meta) => meta,
379 Err(e) => {
380 result.errors.push((path, e));
381 continue;
382 }
383 };
384 match queries::upsert_track_status(&tx, &meta) {
385 Ok((track_id, is_new)) => {
386 if is_new {
387 result.added += 1;
388 } else {
389 result.updated += 1;
390 }
391 result.track_ids.push(track_id);
392 if let Err(e) = queries::update_scan_cache(
393 &tx,
394 meta.path.as_deref().unwrap_or(""),
395 meta.mtime.unwrap_or(0),
396 meta.size_bytes.unwrap_or(0),
397 track_id,
398 ) {
399 log::warn!("failed to cache {}: {}", path.display(), e);
401 }
402 }
403 Err(e) => result.errors.push((path, format!("db error: {e}"))),
404 }
405 }
406
407 if let Err(e) = tx.commit() {
408 result.track_ids.clear();
409 result.added = 0;
410 result.updated = 0;
411 result
412 .errors
413 .push((PathBuf::new(), format!("db error: {e}")));
414 }
415
416 result
417}
418
419pub fn count_audio_files(folders: &[PathBuf]) -> u64 {
424 folders
425 .iter()
426 .flat_map(|folder| {
427 walkdir::WalkDir::new(folder)
428 .follow_links(true)
429 .into_iter()
430 .filter_map(Result::ok)
431 })
432 .filter(|e| e.file_type().is_file() && metadata::is_audio_file(e.path()))
433 .count() as u64
434}
435
436pub fn full_scan(
438 db: &Database,
439 folders: &[PathBuf],
440 opts: ScanOptions,
441 on_track: Option<&dyn Fn(ScanEvent)>,
442) -> ScanResult {
443 let mut total = ScanResult::default();
444 for folder in folders {
445 if total.cancelled {
446 break;
447 }
448 if !folder.exists() {
449 log::warn!("library folder does not exist: {}", folder.display());
450 continue;
451 }
452 let r = scan_folder(db, folder, opts.clone(), on_track);
453 total.cancelled |= r.cancelled;
454 total.added += r.added;
455 total.updated += r.updated;
456 total.removed += r.removed;
457 total.skipped += r.skipped;
458 total.unreadable += r.unreadable;
459 total.removed_paths.extend(r.removed_paths);
460 total.errors.extend(r.errors);
461 }
462 total
463}
464
465pub struct AnalysisEvent<'a> {
467 pub path: &'a str,
468 pub success: bool,
469 pub current: usize,
470 pub total: usize,
471}
472
473pub fn analyze_missing(
476 db: &Database,
477 on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
478) -> (usize, usize) {
479 let missing = match queries::tracks_missing_vectors(&db.conn) {
480 Ok(m) => m,
481 Err(e) => {
482 log::error!("failed to query missing vectors: {}", e);
483 return (0, 0);
484 }
485 };
486
487 if missing.is_empty() {
488 return (0, 0);
489 }
490
491 let total = missing.len();
492 log::info!("analyzing {} tracks for acoustic features", total);
493
494 let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
496 .par_iter()
497 .enumerate()
498 .map(|(i, (track_id, path))| {
499 let result = match catch_unwind(AssertUnwindSafe(|| {
500 features::analyze_track(Path::new(path))
501 })) {
502 Ok(r) => r,
503 Err(_) => Err(features::AnalysisError::Bliss(format!(
504 "panicked while analyzing {}",
505 path
506 ))),
507 };
508 if let Some(cb) = &on_track {
509 cb(AnalysisEvent {
510 path,
511 success: result.is_ok(),
512 current: i + 1,
513 total,
514 });
515 }
516 (*track_id, path.clone(), result)
517 })
518 .collect();
519
520 let mut analyzed = 0usize;
522 let mut errors = 0usize;
523 let tx = match db.conn.unchecked_transaction() {
524 Ok(tx) => tx,
525 Err(e) => {
526 log::error!("failed to begin analysis transaction: {}", e);
527 return (0, 0);
528 }
529 };
530 for (track_id, path, result) in results {
531 match result {
532 Ok(embedding) => {
533 if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
534 log::warn!("failed to store vector for {}: {}", path, e);
535 errors += 1;
536 } else {
537 analyzed += 1;
538 }
539 }
540 Err(e) => {
541 log::warn!("analysis failed for {}: {}", path, e);
542 errors += 1;
543 }
544 }
545 }
546 if let Err(e) = tx.commit() {
547 log::error!("failed to commit analysis transaction: {}", e);
548 }
549
550 log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
551 (analyzed, errors)
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use crate::db::connection::Database;
558 use crate::db::queries;
559 use crate::test_utils;
560
561 fn test_db(dir: &Path) -> Database {
562 let db_path = dir.join("test.db");
563 Database::open(&db_path).unwrap()
564 }
565
566 #[test]
567 fn scan_folder_indexes_new_files() {
568 let dir = tempfile::tempdir().unwrap();
569 let music_dir = dir.path().join("music");
570 std::fs::create_dir_all(&music_dir).unwrap();
571
572 let wav_path = music_dir.join("silence.wav");
574 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
575
576 let db = test_db(dir.path());
577 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
578
579 assert_eq!(result.added, 1, "expected 1 track added");
580 assert_eq!(result.skipped, 0);
581 assert_eq!(result.removed, 0);
582 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
583
584 let stats = queries::library_stats(&db.conn).unwrap();
586 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
587 }
588
589 #[test]
590 fn import_paths_indexes_files_where_they_lie() {
591 let dir = tempfile::tempdir().unwrap();
592 let drop = dir.path().join("Downloads/rip");
595 std::fs::create_dir_all(&drop).unwrap();
596 test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
597 test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
598 std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
599
600 let db = test_db(dir.path());
601 let result = import_paths(&db, std::slice::from_ref(&drop));
602
603 assert_eq!(result.added, 2);
604 assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
605 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
606
607 for id in &result.track_ids {
609 let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
610 assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
611 }
612 }
613
614 #[test]
616 fn import_paths_is_idempotent() {
617 let dir = tempfile::tempdir().unwrap();
618 let drop = dir.path().join("rip");
619 std::fs::create_dir_all(&drop).unwrap();
620 test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
621
622 let db = test_db(dir.path());
623 let first = import_paths(&db, std::slice::from_ref(&drop));
624 let second = import_paths(&db, std::slice::from_ref(&drop));
625
626 assert_eq!(first.added, 1);
627 assert_eq!(second.added, 0);
628 assert_eq!(second.updated, 1);
629 assert_eq!(first.track_ids, second.track_ids);
630 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
631 }
632
633 #[test]
635 fn import_paths_deduplicates_overlapping_selections() {
636 let dir = tempfile::tempdir().unwrap();
637 let drop = dir.path().join("rip");
638 std::fs::create_dir_all(&drop).unwrap();
639 let track = drop.join("a.wav");
640 test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
641
642 let db = test_db(dir.path());
643 let result = import_paths(&db, &[drop.clone(), track.clone()]);
644
645 assert_eq!(result.track_ids.len(), 1);
646 }
647
648 #[test]
649 fn scan_folder_skips_unchanged_files() {
650 let dir = tempfile::tempdir().unwrap();
651 let music_dir = dir.path().join("music");
652 std::fs::create_dir_all(&music_dir).unwrap();
653
654 let wav_path = music_dir.join("unchanged.wav");
655 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
656
657 let db = test_db(dir.path());
658
659 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
661 assert_eq!(r1.added, 1);
662
663 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
665 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
666 assert_eq!(r2.added, 0, "no new files should be added");
667 }
668
669 #[test]
670 fn scan_folder_removes_deleted_tracks() {
671 let dir = tempfile::tempdir().unwrap();
672 let music_dir = dir.path().join("music");
673 std::fs::create_dir_all(&music_dir).unwrap();
674
675 let wav_path = music_dir.join("ephemeral.wav");
676 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
677 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
678
679 let db = test_db(dir.path());
680
681 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
683 assert_eq!(r1.added, 2);
684
685 std::fs::remove_file(&wav_path).unwrap();
687
688 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
690 assert_eq!(
691 r2.removed, 1,
692 "expected 1 track removed after file deletion"
693 );
694
695 let stats = queries::library_stats(&db.conn).unwrap();
696 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
697 }
698
699 #[test]
700 fn empty_folder_does_not_wipe_the_library() {
701 let dir = tempfile::tempdir().unwrap();
702 let music_dir = dir.path().join("music");
703 std::fs::create_dir_all(&music_dir).unwrap();
704 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
705 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
706
707 let db = test_db(dir.path());
708 assert_eq!(
709 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
710 2
711 );
712
713 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
716 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
717
718 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
719 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
720 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
721 }
722
723 #[cfg(unix)]
724 #[test]
725 fn unreadable_folder_is_not_a_deletion() {
726 use std::os::unix::fs::PermissionsExt;
727
728 let dir = tempfile::tempdir().unwrap();
729 let music_dir = dir.path().join("music");
730 let locked_dir = music_dir.join("locked");
731 std::fs::create_dir_all(&locked_dir).unwrap();
732 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
733 let locked_file = locked_dir.join("locked.wav");
734 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
735
736 let db = test_db(dir.path());
737 assert_eq!(
738 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
739 2
740 );
741
742 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
743 if locked_file.try_exists().is_ok() {
744 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
746 return;
747 }
748
749 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
750 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
751
752 assert!(
753 r.unreadable >= 1,
754 "the unreadable subtree should be counted"
755 );
756 assert_eq!(r.removed, 0, "an IO error is not a deletion");
757 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
758 }
759
760 #[test]
761 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
762 let dir = tempfile::tempdir().unwrap();
763 let music_dir = dir.path().join("music");
764 std::fs::create_dir_all(&music_dir).unwrap();
765 for i in 0..6 {
766 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
767 }
768
769 let db = test_db(dir.path());
770
771 let seen = std::cell::Cell::new(0usize);
773 let abort = |_: ScanEvent| {
774 seen.set(seen.get() + 1);
775 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
776 };
777 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
778 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
779 }));
780 assert!(panicked.is_err());
781
782 assert_eq!(
784 queries::library_stats(&db.conn).unwrap().total_tracks,
785 CHUNK_SIZE as i64
786 );
787
788 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
790 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
791 assert_eq!(r.added, 6 - CHUNK_SIZE);
792 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
793 }
794
795 #[test]
796 fn failing_file_is_named_and_the_scan_continues() {
797 let dir = tempfile::tempdir().unwrap();
798 let music_dir = dir.path().join("music");
799 std::fs::create_dir_all(&music_dir).unwrap();
800 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
801 let broken = music_dir.join("broken.flac");
802 std::fs::write(&broken, b"").unwrap();
803
804 let db = test_db(dir.path());
805 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
806
807 assert_eq!(r.added, 1, "the good file must still be indexed");
808 assert_eq!(r.errors.len(), 1);
809 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
810 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
811 }
812
813 #[test]
814 fn a_panicking_tag_read_becomes_an_error() {
815 let path = Path::new("/music/hostile.mp3");
816 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
817 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
818 }
819
820 #[test]
821 fn scan_folder_updates_modified_files() {
822 let dir = tempfile::tempdir().unwrap();
823 let music_dir = dir.path().join("music");
824 std::fs::create_dir_all(&music_dir).unwrap();
825
826 let wav_path = music_dir.join("modified.wav");
827 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
828
829 let db = test_db(dir.path());
830
831 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
833 assert_eq!(r1.added, 1);
834
835 std::thread::sleep(std::time::Duration::from_millis(1100));
838 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
839
840 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
842 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
843 assert_eq!(r2.added, 0, "the row already exists");
844 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
845 }
846}