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 db.optimize();
463 total
464}
465
466pub struct AnalysisEvent<'a> {
468 pub path: &'a str,
469 pub success: bool,
470 pub current: usize,
471 pub total: usize,
472}
473
474pub fn analyze_missing(
477 db: &Database,
478 on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
479) -> (usize, usize) {
480 let missing = match queries::tracks_missing_vectors(&db.conn) {
481 Ok(m) => m,
482 Err(e) => {
483 log::error!("failed to query missing vectors: {}", e);
484 return (0, 0);
485 }
486 };
487
488 if missing.is_empty() {
489 return (0, 0);
490 }
491
492 let total = missing.len();
493 log::info!("analyzing {} tracks for acoustic features", total);
494
495 let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
497 .par_iter()
498 .enumerate()
499 .map(|(i, (track_id, path))| {
500 let result = match catch_unwind(AssertUnwindSafe(|| {
501 features::analyze_track(Path::new(path))
502 })) {
503 Ok(r) => r,
504 Err(_) => Err(features::AnalysisError::Bliss(format!(
505 "panicked while analyzing {}",
506 path
507 ))),
508 };
509 if let Some(cb) = &on_track {
510 cb(AnalysisEvent {
511 path,
512 success: result.is_ok(),
513 current: i + 1,
514 total,
515 });
516 }
517 (*track_id, path.clone(), result)
518 })
519 .collect();
520
521 let mut analyzed = 0usize;
523 let mut errors = 0usize;
524 let tx = match db.conn.unchecked_transaction() {
525 Ok(tx) => tx,
526 Err(e) => {
527 log::error!("failed to begin analysis transaction: {}", e);
528 return (0, 0);
529 }
530 };
531 for (track_id, path, result) in results {
532 match result {
533 Ok(embedding) => {
534 if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
535 log::warn!("failed to store vector for {}: {}", path, e);
536 errors += 1;
537 } else {
538 analyzed += 1;
539 }
540 }
541 Err(e) => {
542 log::warn!("analysis failed for {}: {}", path, e);
543 errors += 1;
544 }
545 }
546 }
547 if let Err(e) = tx.commit() {
548 log::error!("failed to commit analysis transaction: {}", e);
549 }
550
551 log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
552 (analyzed, errors)
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use crate::db::connection::Database;
559 use crate::db::queries;
560 use crate::test_utils;
561
562 fn test_db(dir: &Path) -> Database {
563 let db_path = dir.join("test.db");
564 Database::open(&db_path).unwrap()
565 }
566
567 #[test]
568 fn scan_folder_indexes_new_files() {
569 let dir = tempfile::tempdir().unwrap();
570 let music_dir = dir.path().join("music");
571 std::fs::create_dir_all(&music_dir).unwrap();
572
573 let wav_path = music_dir.join("silence.wav");
575 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
576
577 let db = test_db(dir.path());
578 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
579
580 assert_eq!(result.added, 1, "expected 1 track added");
581 assert_eq!(result.skipped, 0);
582 assert_eq!(result.removed, 0);
583 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
584
585 let stats = queries::library_stats(&db.conn).unwrap();
587 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
588 }
589
590 #[test]
591 fn import_paths_indexes_files_where_they_lie() {
592 let dir = tempfile::tempdir().unwrap();
593 let drop = dir.path().join("Downloads/rip");
596 std::fs::create_dir_all(&drop).unwrap();
597 test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
598 test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
599 std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
600
601 let db = test_db(dir.path());
602 let result = import_paths(&db, std::slice::from_ref(&drop));
603
604 assert_eq!(result.added, 2);
605 assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
606 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
607
608 for id in &result.track_ids {
610 let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
611 assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
612 }
613 }
614
615 #[test]
617 fn import_paths_is_idempotent() {
618 let dir = tempfile::tempdir().unwrap();
619 let drop = dir.path().join("rip");
620 std::fs::create_dir_all(&drop).unwrap();
621 test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
622
623 let db = test_db(dir.path());
624 let first = import_paths(&db, std::slice::from_ref(&drop));
625 let second = import_paths(&db, std::slice::from_ref(&drop));
626
627 assert_eq!(first.added, 1);
628 assert_eq!(second.added, 0);
629 assert_eq!(second.updated, 1);
630 assert_eq!(first.track_ids, second.track_ids);
631 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
632 }
633
634 #[test]
636 fn import_paths_deduplicates_overlapping_selections() {
637 let dir = tempfile::tempdir().unwrap();
638 let drop = dir.path().join("rip");
639 std::fs::create_dir_all(&drop).unwrap();
640 let track = drop.join("a.wav");
641 test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
642
643 let db = test_db(dir.path());
644 let result = import_paths(&db, &[drop.clone(), track.clone()]);
645
646 assert_eq!(result.track_ids.len(), 1);
647 }
648
649 #[test]
650 fn scan_folder_skips_unchanged_files() {
651 let dir = tempfile::tempdir().unwrap();
652 let music_dir = dir.path().join("music");
653 std::fs::create_dir_all(&music_dir).unwrap();
654
655 let wav_path = music_dir.join("unchanged.wav");
656 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
657
658 let db = test_db(dir.path());
659
660 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
662 assert_eq!(r1.added, 1);
663
664 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
666 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
667 assert_eq!(r2.added, 0, "no new files should be added");
668 }
669
670 #[test]
671 fn scan_folder_removes_deleted_tracks() {
672 let dir = tempfile::tempdir().unwrap();
673 let music_dir = dir.path().join("music");
674 std::fs::create_dir_all(&music_dir).unwrap();
675
676 let wav_path = music_dir.join("ephemeral.wav");
677 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
678 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
679
680 let db = test_db(dir.path());
681
682 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
684 assert_eq!(r1.added, 2);
685
686 std::fs::remove_file(&wav_path).unwrap();
688
689 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
691 assert_eq!(
692 r2.removed, 1,
693 "expected 1 track removed after file deletion"
694 );
695
696 let stats = queries::library_stats(&db.conn).unwrap();
697 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
698 }
699
700 #[test]
701 fn empty_folder_does_not_wipe_the_library() {
702 let dir = tempfile::tempdir().unwrap();
703 let music_dir = dir.path().join("music");
704 std::fs::create_dir_all(&music_dir).unwrap();
705 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
706 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
707
708 let db = test_db(dir.path());
709 assert_eq!(
710 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
711 2
712 );
713
714 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
717 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
718
719 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
720 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
721 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
722 }
723
724 #[cfg(unix)]
725 #[test]
726 fn unreadable_folder_is_not_a_deletion() {
727 use std::os::unix::fs::PermissionsExt;
728
729 let dir = tempfile::tempdir().unwrap();
730 let music_dir = dir.path().join("music");
731 let locked_dir = music_dir.join("locked");
732 std::fs::create_dir_all(&locked_dir).unwrap();
733 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
734 let locked_file = locked_dir.join("locked.wav");
735 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
736
737 let db = test_db(dir.path());
738 assert_eq!(
739 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
740 2
741 );
742
743 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
744 if locked_file.try_exists().is_ok() {
745 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
747 return;
748 }
749
750 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
751 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
752
753 assert!(
754 r.unreadable >= 1,
755 "the unreadable subtree should be counted"
756 );
757 assert_eq!(r.removed, 0, "an IO error is not a deletion");
758 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
759 }
760
761 #[test]
762 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
763 let dir = tempfile::tempdir().unwrap();
764 let music_dir = dir.path().join("music");
765 std::fs::create_dir_all(&music_dir).unwrap();
766 for i in 0..6 {
767 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
768 }
769
770 let db = test_db(dir.path());
771
772 let seen = std::cell::Cell::new(0usize);
774 let abort = |_: ScanEvent| {
775 seen.set(seen.get() + 1);
776 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
777 };
778 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
779 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
780 }));
781 assert!(panicked.is_err());
782
783 assert_eq!(
785 queries::library_stats(&db.conn).unwrap().total_tracks,
786 CHUNK_SIZE as i64
787 );
788
789 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
791 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
792 assert_eq!(r.added, 6 - CHUNK_SIZE);
793 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
794 }
795
796 #[test]
797 fn failing_file_is_named_and_the_scan_continues() {
798 let dir = tempfile::tempdir().unwrap();
799 let music_dir = dir.path().join("music");
800 std::fs::create_dir_all(&music_dir).unwrap();
801 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
802 let broken = music_dir.join("broken.flac");
803 std::fs::write(&broken, b"").unwrap();
804
805 let db = test_db(dir.path());
806 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
807
808 assert_eq!(r.added, 1, "the good file must still be indexed");
809 assert_eq!(r.errors.len(), 1);
810 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
811 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
812 }
813
814 #[test]
815 fn a_panicking_tag_read_becomes_an_error() {
816 let path = Path::new("/music/hostile.mp3");
817 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
818 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
819 }
820
821 #[test]
822 fn scan_folder_updates_modified_files() {
823 let dir = tempfile::tempdir().unwrap();
824 let music_dir = dir.path().join("music");
825 std::fs::create_dir_all(&music_dir).unwrap();
826
827 let wav_path = music_dir.join("modified.wav");
828 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
829
830 let db = test_db(dir.path());
831
832 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
834 assert_eq!(r1.added, 1);
835
836 std::thread::sleep(std::time::Duration::from_millis(1100));
839 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
840
841 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
843 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
844 assert_eq!(r2.added, 0, "the row already exists");
845 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
846 }
847}