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::metadata::{self, is_audio_file};
13
14#[derive(Debug, Default)]
16pub struct ScanResult {
17 pub added: usize,
18 pub updated: usize,
19 pub removed: usize,
20 pub skipped: usize,
21 pub unreadable: usize,
24 pub removed_paths: Vec<String>,
27 pub errors: Vec<(PathBuf, String)>,
28 pub cancelled: bool,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct ScanOptions {
35 pub force: bool,
37 pub cancel: Option<Arc<AtomicBool>>,
43 pub force_remove: bool,
48}
49
50#[cfg(not(test))]
54const CHUNK_SIZE: usize = 1000;
55#[cfg(test)]
56const CHUNK_SIZE: usize = 4;
57
58pub struct ScanEvent<'a> {
60 pub artist: &'a str,
61 pub album: &'a str,
62 pub title: &'a str,
63 pub path: &'a Path,
64 pub is_new: bool,
65}
66
67pub fn scan_folder(
70 db: &Database,
71 path: &Path,
72 opts: ScanOptions,
73 on_track: Option<&dyn Fn(ScanEvent)>,
74) -> ScanResult {
75 scan_folders(
76 db,
77 std::slice::from_ref(&path.to_path_buf()),
78 opts,
79 None,
80 on_track,
81 )
82}
83
84pub fn scan_folders(
89 db: &Database,
90 folders: &[PathBuf],
91 opts: ScanOptions,
92 on_started: Option<&dyn Fn(u64)>,
93 on_track: Option<&dyn Fn(ScanEvent)>,
94) -> ScanResult {
95 let walked: Vec<(PathBuf, Vec<PathBuf>, ScanResult)> = folders
96 .iter()
97 .map(|folder| {
98 let path = super::spelling::on_disk(folder);
102 let mut result = ScanResult::default();
103 let files = walk_audio(&path, &mut result);
104 (path, files, result)
105 })
106 .collect();
107 if let Some(started) = on_started {
108 started(walked.iter().map(|(_, files, _)| files.len() as u64).sum());
109 }
110
111 let mut total = ScanResult::default();
112 for (path, files, mut result) in walked {
113 if total.cancelled {
114 break;
115 }
116 index_folder(db, &path, files, &opts, on_track, &mut result);
117 merge(&mut total, result);
118 }
119 total
120}
121
122fn index_folder(
123 db: &Database,
124 path: &Path,
125 audio_files: Vec<PathBuf>,
126 opts: &ScanOptions,
127 on_track: Option<&dyn Fn(ScanEvent)>,
128 result: &mut ScanResult,
129) {
130 let total_files = audio_files.len();
131 log::info!("found {} audio files in {}", total_files, path.display());
132
133 if !index_files(db, audio_files, opts, on_track, result, path) {
134 return;
135 }
136
137 if result.cancelled {
138 return;
142 }
143
144 if total_files == 0 {
148 log::error!(
149 "{} contains no audio files — skipping stale-track removal. \
150 If this folder should have music in it, it is probably not mounted or not readable.",
151 path.display()
152 );
153 return;
154 }
155
156 remove_stale(db, path, opts.force_remove, result);
157}
158
159pub fn scan_dirs(
175 db: &Database,
176 library: &[PathBuf],
177 dirs: &[PathBuf],
178 opts: ScanOptions,
179 on_track: Option<&dyn Fn(ScanEvent)>,
180) -> ScanResult {
181 let mut result = ScanResult::default();
182 let mut spelling = super::spelling::Spelling::default();
183 let library: Vec<PathBuf> = library.iter().map(|f| spelling.on_disk(f)).collect();
184 let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
185
186 let mut files = Vec::new();
187 let mut settled = Vec::new();
188 for dir in minimal_dirs(dirs) {
189 let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
190 log::warn!("not in a library folder, not scanning: {}", dir.display());
191 continue;
192 };
193 if dir == *root {
194 merge(&mut result, scan_folder(db, root, opts.clone(), on_track));
195 continue;
196 }
197 if !is_populated(root) {
198 log::warn!(
199 "{} is empty or unreadable — not scanning {}",
200 root.display(),
201 dir.display()
202 );
203 continue;
204 }
205 match dir.try_exists() {
206 Ok(true) => {
207 let before = result.unreadable;
208 files.extend(walk_audio(&dir, &mut result));
209 if result.unreadable == before {
210 settled.push(dir);
211 }
212 }
213 Ok(false) => settled.push(dir),
214 Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
215 }
216 }
217
218 if !index_files(db, files, &opts, on_track, &mut result, Path::new("")) || result.cancelled {
219 return result;
220 }
221 for dir in &settled {
222 remove_stale(db, dir, true, &mut result);
223 }
224 result
225}
226
227pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
230 dirs.sort();
232 dirs.dedup();
233 let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
234 for dir in dirs {
235 if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
236 kept.push(dir);
237 }
238 }
239 kept
240}
241
242fn is_populated(root: &Path) -> bool {
244 std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
245}
246
247fn merge(total: &mut ScanResult, r: ScanResult) {
248 total.cancelled |= r.cancelled;
249 total.added += r.added;
250 total.updated += r.updated;
251 total.removed += r.removed;
252 total.skipped += r.skipped;
253 total.unreadable += r.unreadable;
254 total.removed_paths.extend(r.removed_paths);
255 total.errors.extend(r.errors);
256}
257
258fn walk_audio(path: &Path, result: &mut ScanResult) -> Vec<PathBuf> {
261 let mut audio_files = Vec::new();
262 for entry in walkdir::WalkDir::new(path).follow_links(true) {
263 match entry {
264 Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
265 audio_files.push(e.path().to_path_buf())
266 }
267 Ok(_) => {}
268 Err(e) => {
269 result.unreadable += 1;
270 log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
271 }
272 }
273 }
274 audio_files
275}
276
277fn index_files(
282 db: &Database,
283 mut audio_files: Vec<PathBuf>,
284 opts: &ScanOptions,
285 on_track: Option<&dyn Fn(ScanEvent)>,
286 result: &mut ScanResult,
287 context: &Path,
288) -> bool {
289 let total_files = audio_files.len();
290 if total_files == 0 {
291 return true;
292 }
293
294 let files_to_scan: Vec<PathBuf> = if opts.force {
298 std::mem::take(&mut audio_files)
299 } else {
300 let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
301 audio_files
303 .par_iter()
304 .filter(|file_path| {
305 let Ok(file_meta) = std::fs::metadata(file_path) else {
306 return true;
307 };
308 let mtime = file_meta
309 .modified()
310 .ok()
311 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
312 .map(|d| d.as_secs() as i64)
313 .unwrap_or(0);
314 let size = file_meta.len() as i64;
315 let path_str = file_path.to_string_lossy();
316 match scan_cache.get(path_str.as_ref()) {
317 Some(&(cached_mtime, cached_size)) => {
318 mtime != cached_mtime || size != cached_size
319 }
320 None => true,
321 }
322 })
323 .cloned()
324 .collect()
325 };
326
327 result.skipped += total_files - files_to_scan.len();
328
329 let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
341 CHUNK_SIZE.saturating_mul(2),
342 );
343 let cancel = opts.cancel.clone();
344 let reader = std::thread::Builder::new()
345 .name("koan-scan-read".into())
346 .spawn(move || {
347 let _ = files_to_scan.par_iter().try_for_each(|file_path| {
349 if cancel
350 .as_ref()
351 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
352 {
353 return Err(());
354 }
355 send.send((
356 file_path.clone(),
357 isolate_read(file_path, metadata::read_metadata),
358 ))
359 .map_err(|_| ())
360 });
361 });
362 if let Err(e) = &reader {
363 log::error!("failed to spawn scan reader: {}", e);
364 result
365 .errors
366 .push((context.to_path_buf(), format!("scan error: {}", e)));
367 return false;
368 }
369
370 loop {
371 let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
373 recv.iter().take(CHUNK_SIZE).collect();
374 if batch.is_empty() {
375 break;
376 }
377 if opts
378 .cancel
379 .as_ref()
380 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
381 {
382 log::info!("scan cancelled — keeping what was already committed");
383 result.cancelled = true;
384 break;
385 }
386
387 let tx = match crate::db::queries::write_transaction(&db.conn) {
388 Ok(tx) => tx,
389 Err(e) => {
390 log::error!("failed to begin scan transaction: {}", e);
391 result
392 .errors
393 .push((context.to_path_buf(), format!("db error: {}", e)));
394 return false;
395 }
396 };
397
398 let (mut added, mut updated) = (0usize, 0usize);
399 for (file_path, meta_result) in batch {
400 match meta_result {
401 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
402 Ok((track_id, is_new)) => {
403 if is_new {
404 added += 1;
405 } else {
406 updated += 1;
407 }
408 if let Some(cb) = &on_track {
409 cb(ScanEvent {
410 artist: &meta.artist,
411 album: &meta.album,
412 title: &meta.title,
413 path: &file_path,
414 is_new,
415 });
416 }
417 if let Err(e) = queries::update_scan_cache(
418 &tx,
419 meta.path.as_deref().unwrap_or(""),
420 meta.mtime.unwrap_or(0),
421 meta.size_bytes.unwrap_or(0),
422 track_id,
423 ) {
424 log::warn!("failed to cache {}: {}", file_path.display(), e);
426 }
427 }
428 Err(e) => {
429 result.errors.push((file_path, format!("db error: {}", e)));
430 }
431 },
432 Err(e) => {
433 result.errors.push((file_path, e));
434 }
435 }
436 }
437
438 match tx.commit() {
439 Ok(()) => {
440 result.added += added;
441 result.updated += updated;
442 }
443 Err(e) => {
444 log::error!("failed to commit scan transaction: {}", e);
445 result
446 .errors
447 .push((context.to_path_buf(), format!("db error: {}", e)));
448 }
449 }
450 }
451
452 drop(recv);
455 if let Ok(handle) = reader
456 && handle.join().is_err()
457 {
458 log::error!("scan reader thread panicked");
459 }
460 true
461}
462
463fn remove_stale(db: &Database, path: &Path, force_remove: bool, result: &mut ScanResult) {
465 let tx = match crate::db::queries::write_transaction(&db.conn) {
466 Ok(tx) => tx,
467 Err(e) => {
468 log::error!("failed to begin stale-removal transaction: {}", e);
469 result
470 .errors
471 .push((path.to_path_buf(), format!("db error: {}", e)));
472 return;
473 }
474 };
475 match queries::remove_stale_tracks(&tx, path, force_remove) {
476 Ok(removed) => {
477 if let Err(e) = tx.commit() {
478 log::error!("failed to commit stale removals: {}", e);
479 result
480 .errors
481 .push((path.to_path_buf(), format!("db error: {}", e)));
482 return;
483 }
484 result.removed += removed.len();
485 result.removed_paths.extend(removed);
486 }
487 Err(e) => {
488 log::error!("failed to remove stale tracks: {}", e);
489 result.errors.push((path.to_path_buf(), e.to_string()));
490 }
491 }
492}
493
494fn isolate_read(
499 path: &Path,
500 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
501) -> Result<TrackMeta, String> {
502 match catch_unwind(AssertUnwindSafe(|| read(path))) {
503 Ok(result) => result.map_err(|e| e.to_string()),
504 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
505 }
506}
507
508#[derive(Debug, Default)]
510pub struct ImportResult {
511 pub track_ids: Vec<i64>,
514 pub added: usize,
515 pub updated: usize,
516 pub errors: Vec<(PathBuf, String)>,
517}
518
519pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
530 let mut result = ImportResult::default();
531
532 let mut files: Vec<PathBuf> = Vec::new();
533 let mut seen = std::collections::HashSet::new();
534 let mut spelling = super::spelling::Spelling::default();
538 for path in paths {
539 let path = spelling.on_disk(path);
540 let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
541 .follow_links(true)
542 .into_iter()
543 .filter_map(Result::ok)
544 .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
545 .map(|e| e.path().to_path_buf())
546 .collect();
547 found.sort();
548 files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
550 }
551
552 if files.is_empty() {
553 return result;
554 }
555
556 let read: Vec<(PathBuf, Result<TrackMeta, String>)> = files
558 .par_iter()
559 .map(|path| (path.clone(), isolate_read(path, metadata::read_metadata)))
560 .collect();
561
562 let tx = match crate::db::queries::write_transaction(&db.conn) {
563 Ok(tx) => tx,
564 Err(e) => {
565 result
566 .errors
567 .push((PathBuf::new(), format!("db error: {e}")));
568 return result;
569 }
570 };
571
572 for (path, meta_result) in read {
573 let meta = match meta_result {
574 Ok(meta) => meta,
575 Err(e) => {
576 result.errors.push((path, e));
577 continue;
578 }
579 };
580 match queries::upsert_track_status(&tx, &meta) {
581 Ok((track_id, is_new)) => {
582 if is_new {
583 result.added += 1;
584 } else {
585 result.updated += 1;
586 }
587 result.track_ids.push(track_id);
588 if let Err(e) = queries::update_scan_cache(
589 &tx,
590 meta.path.as_deref().unwrap_or(""),
591 meta.mtime.unwrap_or(0),
592 meta.size_bytes.unwrap_or(0),
593 track_id,
594 ) {
595 log::warn!("failed to cache {}: {}", path.display(), e);
597 }
598 }
599 Err(e) => result.errors.push((path, format!("db error: {e}"))),
600 }
601 }
602
603 if let Err(e) = tx.commit() {
604 result.track_ids.clear();
605 result.added = 0;
606 result.updated = 0;
607 result
608 .errors
609 .push((PathBuf::new(), format!("db error: {e}")));
610 }
611
612 result
613}
614
615pub fn full_scan(
617 db: &Database,
618 folders: &[PathBuf],
619 opts: ScanOptions,
620 on_track: Option<&dyn Fn(ScanEvent)>,
621) -> ScanResult {
622 let existing: Vec<PathBuf> = folders
623 .iter()
624 .filter(|folder| {
625 let exists = folder.exists();
626 if !exists {
627 log::warn!("library folder does not exist: {}", folder.display());
628 }
629 exists
630 })
631 .cloned()
632 .collect();
633 let total = scan_folders(db, &existing, opts, None, on_track);
634 db.optimize();
635 total
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641 use crate::db::connection::Database;
642 use crate::db::queries;
643 use crate::test_utils;
644
645 fn test_db(dir: &Path) -> Database {
646 let db_path = dir.join("test.db");
647 Database::open(&db_path).unwrap()
648 }
649
650 #[test]
651 fn cancelling_a_scan_returns_with_readers_still_pending() {
652 let dir = tempfile::tempdir().unwrap();
653 let music_dir = dir.path().join("music");
654 std::fs::create_dir_all(&music_dir).unwrap();
655 for i in 0..CHUNK_SIZE * 12 {
658 test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
659 }
660
661 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
662 let root = dir.path().to_path_buf();
663 std::thread::spawn(move || {
664 let db = test_db(&root);
665 let cancel = Arc::new(AtomicBool::new(false));
666 let opts = ScanOptions {
667 cancel: Some(cancel.clone()),
668 ..Default::default()
669 };
670 let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
671 let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
672 done_tx.send(result).unwrap();
673 });
674
675 let result = done_rx
676 .recv_timeout(std::time::Duration::from_secs(30))
677 .expect("cancelled scan never returned");
678 assert!(result.cancelled);
679 assert!(result.added < CHUNK_SIZE * 12);
680 }
681
682 #[test]
683 fn scan_folder_indexes_new_files() {
684 let dir = tempfile::tempdir().unwrap();
685 let music_dir = dir.path().join("music");
686 std::fs::create_dir_all(&music_dir).unwrap();
687
688 let wav_path = music_dir.join("silence.wav");
690 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
691
692 let db = test_db(dir.path());
693 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
694
695 assert_eq!(result.added, 1, "expected 1 track added");
696 assert_eq!(result.skipped, 0);
697 assert_eq!(result.removed, 0);
698 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
699
700 let stats = queries::library_stats(&db.conn).unwrap();
702 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
703 }
704
705 #[test]
706 fn a_renamed_file_leaves_no_row_at_its_old_path() {
707 let dir = tempfile::tempdir().unwrap();
708 let music = dir.path().join("music");
709 let album = music.join("Squire Of Gothos").join("Album");
710 std::fs::create_dir_all(&album).unwrap();
711 for i in 1..=3 {
712 test_utils::generate_wav(
713 &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
714 44100,
715 1,
716 0.5 + i as f32 * 0.1,
717 16,
718 );
719 }
720 let db = test_db(dir.path());
721 scan_folder(&db, &music, ScanOptions::default(), None);
722
723 let moved = music.join("The Squire of Gothos").join("Album");
726 std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
727 std::fs::rename(&album, &moved).unwrap();
728 std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
729 for i in 1..=3 {
730 std::fs::rename(
731 moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
732 moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
733 )
734 .unwrap();
735 }
736 let result = scan_folder(&db, &music, ScanOptions::default(), None);
737
738 let paths: Vec<String> = db
739 .conn
740 .prepare("SELECT path FROM tracks ORDER BY path")
741 .unwrap()
742 .query_map([], |r| r.get(0))
743 .unwrap()
744 .collect::<Result<_, _>>()
745 .unwrap();
746 assert_eq!((result.added, result.removed), (3, 3));
747 assert_eq!(paths.len(), 3, "{paths:#?}");
748 assert!(
749 paths.iter().all(|p| std::path::Path::new(p).exists()),
750 "{paths:#?}"
751 );
752 }
753
754 fn track_paths(db: &Database) -> Vec<String> {
755 db.conn
756 .prepare("SELECT path FROM tracks ORDER BY path")
757 .unwrap()
758 .query_map([], |r| r.get(0))
759 .unwrap()
760 .collect::<Result<_, _>>()
761 .unwrap()
762 }
763
764 #[test]
765 fn minimal_dirs_keeps_only_the_outermost() {
766 let dirs = [
767 "/m/A/Album 2",
768 "/m/A/Album",
769 "/m/A/Album/CD1",
770 "/m/A/Album",
771 "/m/AB",
772 "/m/B/Album/CD2",
773 ]
774 .map(PathBuf::from)
775 .to_vec();
776 assert_eq!(
777 minimal_dirs(dirs),
778 ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
779 );
780 assert_eq!(
781 minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
782 [PathBuf::from("/m/A")]
783 );
784 }
785
786 #[test]
787 fn scan_dirs_covers_only_the_directories_named() {
788 let dir = tempfile::tempdir().unwrap();
789 let music = dir.path().join("music");
790 let (old, new) = (music.join("Old"), music.join("New"));
791 std::fs::create_dir_all(&old).unwrap();
792 std::fs::create_dir_all(&new).unwrap();
793 test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
794
795 let db = test_db(dir.path());
796 scan_folder(&db, &music, ScanOptions::default(), None);
797 test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
798 test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
800
801 let r = scan_dirs(
802 &db,
803 std::slice::from_ref(&music),
804 std::slice::from_ref(&new),
805 ScanOptions::default(),
806 None,
807 );
808 assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
809 assert_eq!(track_paths(&db).len(), 2);
810 }
811
812 #[test]
813 fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
814 let dir = tempfile::tempdir().unwrap();
815 let music = dir.path().join("music");
816 let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
817 std::fs::create_dir_all(&kept).unwrap();
818 std::fs::create_dir_all(&gone).unwrap();
819 for i in 0..3 {
820 test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
821 test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
822 }
823 let db = test_db(dir.path());
824 assert_eq!(
825 scan_folder(&db, &music, ScanOptions::default(), None).added,
826 6
827 );
828
829 std::fs::remove_file(kept.join("0.wav")).unwrap();
830 std::fs::remove_dir_all(&gone).unwrap();
831 let r = scan_dirs(
832 &db,
833 std::slice::from_ref(&music),
834 &[kept.clone(), gone.clone()],
835 ScanOptions::default(),
836 None,
837 );
838
839 assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
840 let paths = track_paths(&db);
841 assert_eq!(paths.len(), 2, "{paths:#?}");
842 assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
843 }
844
845 #[test]
848 fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
849 let dir = tempfile::tempdir().unwrap();
850 let music = dir.path().join("music");
851 let album = music.join("Album");
852 std::fs::create_dir_all(&album).unwrap();
853 test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
854 let db = test_db(dir.path());
855 scan_folder(&db, &music, ScanOptions::default(), None);
856
857 std::fs::remove_dir_all(&album).unwrap();
858 let r = scan_dirs(
859 &db,
860 std::slice::from_ref(&music),
861 std::slice::from_ref(&album),
862 ScanOptions::default(),
863 None,
864 );
865 assert_eq!(r.removed, 0);
866 assert_eq!(track_paths(&db).len(), 1);
867 }
868
869 #[test]
870 fn scan_dirs_ignores_directories_outside_the_library() {
871 let dir = tempfile::tempdir().unwrap();
872 let music = dir.path().join("music");
873 let elsewhere = dir.path().join("elsewhere");
874 std::fs::create_dir_all(&music).unwrap();
875 std::fs::create_dir_all(&elsewhere).unwrap();
876 test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
877 let db = test_db(dir.path());
878
879 let r = scan_dirs(
880 &db,
881 std::slice::from_ref(&music),
882 std::slice::from_ref(&elsewhere),
883 ScanOptions::default(),
884 None,
885 );
886 assert_eq!(r.added, 0);
887 assert!(track_paths(&db).is_empty());
888 }
889
890 #[test]
891 fn import_paths_indexes_files_where_they_lie() {
892 let dir = tempfile::tempdir().unwrap();
893 let drop = dir.path().join("Downloads/rip");
896 std::fs::create_dir_all(&drop).unwrap();
897 test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
898 test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
899 std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
900
901 let db = test_db(dir.path());
902 let result = import_paths(&db, std::slice::from_ref(&drop));
903
904 assert_eq!(result.added, 2);
905 assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
906 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
907
908 for id in &result.track_ids {
910 let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
911 assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
912 }
913 }
914
915 #[test]
917 fn import_paths_is_idempotent() {
918 let dir = tempfile::tempdir().unwrap();
919 let drop = dir.path().join("rip");
920 std::fs::create_dir_all(&drop).unwrap();
921 test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
922
923 let db = test_db(dir.path());
924 let first = import_paths(&db, std::slice::from_ref(&drop));
925 let second = import_paths(&db, std::slice::from_ref(&drop));
926
927 assert_eq!(first.added, 1);
928 assert_eq!(second.added, 0);
929 assert_eq!(second.updated, 1);
930 assert_eq!(first.track_ids, second.track_ids);
931 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
932 }
933
934 #[test]
936 fn import_paths_deduplicates_overlapping_selections() {
937 let dir = tempfile::tempdir().unwrap();
938 let drop = dir.path().join("rip");
939 std::fs::create_dir_all(&drop).unwrap();
940 let track = drop.join("a.wav");
941 test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
942
943 let db = test_db(dir.path());
944 let result = import_paths(&db, &[drop.clone(), track.clone()]);
945
946 assert_eq!(result.track_ids.len(), 1);
947 }
948
949 #[test]
950 fn scan_folder_skips_unchanged_files() {
951 let dir = tempfile::tempdir().unwrap();
952 let music_dir = dir.path().join("music");
953 std::fs::create_dir_all(&music_dir).unwrap();
954
955 let wav_path = music_dir.join("unchanged.wav");
956 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
957
958 let db = test_db(dir.path());
959
960 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
962 assert_eq!(r1.added, 1);
963
964 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
966 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
967 assert_eq!(r2.added, 0, "no new files should be added");
968 }
969
970 #[test]
971 fn scan_folder_removes_deleted_tracks() {
972 let dir = tempfile::tempdir().unwrap();
973 let music_dir = dir.path().join("music");
974 std::fs::create_dir_all(&music_dir).unwrap();
975
976 let wav_path = music_dir.join("ephemeral.wav");
977 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
978 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
979
980 let db = test_db(dir.path());
981
982 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
984 assert_eq!(r1.added, 2);
985
986 std::fs::remove_file(&wav_path).unwrap();
988
989 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
991 assert_eq!(
992 r2.removed, 1,
993 "expected 1 track removed after file deletion"
994 );
995
996 let stats = queries::library_stats(&db.conn).unwrap();
997 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
998 }
999
1000 #[test]
1001 fn empty_folder_does_not_wipe_the_library() {
1002 let dir = tempfile::tempdir().unwrap();
1003 let music_dir = dir.path().join("music");
1004 std::fs::create_dir_all(&music_dir).unwrap();
1005 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
1006 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
1007
1008 let db = test_db(dir.path());
1009 assert_eq!(
1010 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1011 2
1012 );
1013
1014 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
1017 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
1018
1019 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1020 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
1021 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1022 }
1023
1024 #[cfg(unix)]
1025 #[test]
1026 fn unreadable_folder_is_not_a_deletion() {
1027 use std::os::unix::fs::PermissionsExt;
1028
1029 let dir = tempfile::tempdir().unwrap();
1030 let music_dir = dir.path().join("music");
1031 let locked_dir = music_dir.join("locked");
1032 std::fs::create_dir_all(&locked_dir).unwrap();
1033 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
1034 let locked_file = locked_dir.join("locked.wav");
1035 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
1036
1037 let db = test_db(dir.path());
1038 assert_eq!(
1039 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1040 2
1041 );
1042
1043 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
1044 if locked_file.try_exists().is_ok() {
1045 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1047 return;
1048 }
1049
1050 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1051 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1052
1053 assert!(
1054 r.unreadable >= 1,
1055 "the unreadable subtree should be counted"
1056 );
1057 assert_eq!(r.removed, 0, "an IO error is not a deletion");
1058 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1059 }
1060
1061 #[test]
1062 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
1063 let dir = tempfile::tempdir().unwrap();
1064 let music_dir = dir.path().join("music");
1065 std::fs::create_dir_all(&music_dir).unwrap();
1066 for i in 0..6 {
1067 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
1068 }
1069
1070 let db = test_db(dir.path());
1071
1072 let seen = std::cell::Cell::new(0usize);
1074 let abort = |_: ScanEvent| {
1075 seen.set(seen.get() + 1);
1076 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
1077 };
1078 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1079 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
1080 }));
1081 assert!(panicked.is_err());
1082
1083 assert_eq!(
1085 queries::library_stats(&db.conn).unwrap().total_tracks,
1086 CHUNK_SIZE as i64
1087 );
1088
1089 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1091 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
1092 assert_eq!(r.added, 6 - CHUNK_SIZE);
1093 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
1094 }
1095
1096 #[test]
1097 fn failing_file_is_named_and_the_scan_continues() {
1098 let dir = tempfile::tempdir().unwrap();
1099 let music_dir = dir.path().join("music");
1100 std::fs::create_dir_all(&music_dir).unwrap();
1101 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
1102 let broken = music_dir.join("broken.flac");
1103 std::fs::write(&broken, b"").unwrap();
1104
1105 let db = test_db(dir.path());
1106 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1107
1108 assert_eq!(r.added, 1, "the good file must still be indexed");
1109 assert_eq!(r.errors.len(), 1);
1110 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
1111 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1112 }
1113
1114 #[test]
1115 fn a_panicking_tag_read_becomes_an_error() {
1116 let path = Path::new("/music/hostile.mp3");
1117 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
1118 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
1119 }
1120
1121 #[test]
1122 fn scan_folder_updates_modified_files() {
1123 let dir = tempfile::tempdir().unwrap();
1124 let music_dir = dir.path().join("music");
1125 std::fs::create_dir_all(&music_dir).unwrap();
1126
1127 let wav_path = music_dir.join("modified.wav");
1128 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1129
1130 let db = test_db(dir.path());
1131
1132 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1134 assert_eq!(r1.added, 1);
1135
1136 std::thread::sleep(std::time::Duration::from_millis(1100));
1139 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
1140
1141 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1143 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
1144 assert_eq!(r2.added, 0, "the row already exists");
1145 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
1146 }
1147
1148 #[test]
1149 fn a_drop_and_a_scan_spell_a_file_the_same_way() {
1150 use unicode_normalization::UnicodeNormalization;
1151 let dir = tempfile::tempdir().unwrap();
1152 let nfd: String = "Roman Flügel".nfd().collect();
1153 let nfc: String = "Roman Flügel".nfc().collect();
1154 let music_dir = dir.path().join(&nfd);
1155 std::fs::create_dir_all(&music_dir).unwrap();
1156 test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
1157 let db = test_db(dir.path());
1158
1159 let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
1161 assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
1162
1163 let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1165 assert_eq!(scanned.added, 0, "the same file, not a second one");
1166
1167 let stats = queries::library_stats(&db.conn).unwrap();
1168 assert_eq!(stats.total_tracks, 1);
1169 let stored: String = db
1170 .conn
1171 .query_row("SELECT path FROM tracks", [], |r| r.get(0))
1172 .unwrap();
1173 assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
1174 }
1175}