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