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