1use std::collections::HashSet;
2use std::panic::{AssertUnwindSafe, catch_unwind};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::AtomicBool;
6use std::time::UNIX_EPOCH;
7
8use rayon::prelude::*;
9
10use crate::db::connection::Database;
11use crate::db::queries::{self, TrackMeta};
12
13use super::metadata::{self, is_audio_file};
14use super::playlist_files::is_playlist_file;
15
16#[derive(Debug, Default)]
18pub struct ScanResult {
19 pub added: usize,
20 pub updated: usize,
21 pub removed: usize,
22 pub moved: usize,
25 pub skipped: usize,
26 pub unreadable: usize,
29 pub playlists: usize,
31 pub removed_paths: Vec<String>,
34 pub errors: Vec<(PathBuf, String)>,
35 pub cancelled: bool,
37 arrived: Vec<i64>,
40}
41
42#[derive(Debug, Clone, Default)]
44pub struct ScanOptions {
45 pub force: bool,
47 pub cancel: Option<Arc<AtomicBool>>,
53 pub force_remove: bool,
58}
59
60#[cfg(not(test))]
64const CHUNK_SIZE: usize = 1000;
65#[cfg(test)]
66const CHUNK_SIZE: usize = 4;
67
68pub struct ScanEvent<'a> {
70 pub artist: &'a str,
71 pub album: &'a str,
72 pub title: &'a str,
73 pub path: &'a Path,
74 pub is_new: bool,
75}
76
77pub fn scan_folder(
80 db: &Database,
81 path: &Path,
82 opts: ScanOptions,
83 on_track: Option<&dyn Fn(ScanEvent)>,
84) -> ScanResult {
85 scan_folders(
86 db,
87 std::slice::from_ref(&path.to_path_buf()),
88 opts,
89 None,
90 on_track,
91 )
92}
93
94pub fn scan_folders(
99 db: &Database,
100 folders: &[PathBuf],
101 mut opts: ScanOptions,
102 on_started: Option<&dyn Fn(u64)>,
103 on_track: Option<&dyn Fn(ScanEvent)>,
104) -> ScanResult {
105 let _turn = super::lane::enter(folders, &mut opts);
106 scan_folders_in_lane(db, folders, &opts, on_started, on_track)
107}
108
109fn scan_folders_in_lane(
110 db: &Database,
111 folders: &[PathBuf],
112 opts: &ScanOptions,
113 on_started: Option<&dyn Fn(u64)>,
114 on_track: Option<&dyn Fn(ScanEvent)>,
115) -> ScanResult {
116 let walked: Vec<(PathBuf, Walked, ScanResult)> = folders
117 .iter()
118 .map(|folder| {
119 let path = library_folder(db, folder);
123 let mut result = ScanResult::default();
124 let found = walk(&path, &mut result);
125 (path, found, result)
126 })
127 .collect();
128 if let Some(started) = on_started {
129 started(
130 walked
131 .iter()
132 .map(|(_, found, _)| found.audio.len() as u64)
133 .sum(),
134 );
135 }
136
137 let mut indexed = Vec::new();
141 let mut cancelled = false;
142 for (path, found, mut result) in walked {
143 if cancelled {
144 break;
145 }
146 let settled = if found.audio.is_empty() || result.unreadable > 0 {
149 Vec::new()
150 } else {
151 vec![path.clone()]
152 };
153 let seen = stored_paths(&found.audio);
154 let prune = index_folder(db, &path, found.audio, opts, on_track, &mut result);
155 cancelled |= result.cancelled;
156 indexed.push((path, found.playlists, settled, prune, seen, result));
157 }
158
159 let arrived: Vec<i64> = indexed
160 .iter()
161 .flat_map(|(.., result)| result.arrived.iter().copied())
162 .collect();
163 let mut total = ScanResult::default();
164 for (path, playlists, settled, prune, seen, mut result) in indexed {
165 if prune && !cancelled {
166 remove_stale(db, &path, opts.force_remove, &arrived, &seen, &mut result);
167 }
168 if !result.cancelled {
169 result.playlists += super::playlist_files::import(db, &playlists, &settled);
170 }
171 merge(&mut total, result);
172 }
173 total.added = total.added.saturating_sub(total.moved);
175 if total.added > 0 {
176 super::playlist_files::refresh_m3u(db);
177 }
178 total
179}
180
181fn index_folder(
184 db: &Database,
185 path: &Path,
186 audio_files: Vec<PathBuf>,
187 opts: &ScanOptions,
188 on_track: Option<&dyn Fn(ScanEvent)>,
189 result: &mut ScanResult,
190) -> bool {
191 let total_files = audio_files.len();
192 log::info!("found {} audio files in {}", total_files, path.display());
193
194 let scope = [path.to_path_buf()];
195 if !index_files(db, audio_files, &scope, opts, on_track, result, path) {
196 return false;
197 }
198
199 if result.cancelled {
200 return false;
204 }
205
206 if total_files == 0 {
210 log::error!(
211 "{} contains no audio files — skipping stale-track removal. \
212 If this folder should have music in it, it is probably not mounted or not readable.",
213 path.display()
214 );
215 return false;
216 }
217 true
218}
219
220pub fn scan_dirs(
237 db: &Database,
238 library: &[PathBuf],
239 dirs: &[PathBuf],
240 mut opts: ScanOptions,
241 on_track: Option<&dyn Fn(ScanEvent)>,
242) -> ScanResult {
243 let _turn = super::lane::enter(dirs, &mut opts);
244 let mut result = ScanResult::default();
245 let mut spelling = super::spelling::Spelling::default();
246 let library: Vec<PathBuf> = library.iter().map(|f| library_folder(db, f)).collect();
247 let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
248
249 let mut files = Vec::new();
250 let mut playlists = Vec::new();
251 let mut walked = Vec::new();
252 let mut settled = Vec::new();
253 for dir in minimal_dirs(dirs) {
254 let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
255 log::warn!("not in a library folder, not scanning: {}", dir.display());
256 continue;
257 };
258 if dir == *root {
259 merge(
260 &mut result,
261 scan_folders_in_lane(db, std::slice::from_ref(root), &opts, None, on_track),
262 );
263 continue;
264 }
265 if !is_populated(root) {
266 log::warn!(
267 "{} is empty or unreadable — not scanning {}",
268 root.display(),
269 dir.display()
270 );
271 continue;
272 }
273 match dir.try_exists() {
274 Ok(true) => {
275 let before = result.unreadable;
276 let found = walk(&dir, &mut result);
277 files.extend(found.audio);
278 playlists.extend(found.playlists);
279 walked.push(dir.clone());
280 if result.unreadable == before {
281 settled.push(dir);
282 }
283 }
284 Ok(false) if super::known_missing(&dir) => settled.push(dir),
285 Ok(false) => log::warn!(
286 "{} is behind a link that leads nowhere — not scanning it",
287 dir.display()
288 ),
289 Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
290 }
291 }
292
293 let seen = stored_paths(&files);
294 let indexed = index_files(
295 db,
296 files,
297 &walked,
298 &opts,
299 on_track,
300 &mut result,
301 Path::new(""),
302 );
303 if !indexed || result.cancelled {
304 return result;
305 }
306 let arrived = std::mem::take(&mut result.arrived);
307 for dir in &settled {
308 remove_stale(db, dir, true, &arrived, &seen, &mut result);
309 if let Err(e) = queries::evict_art_under(&db.conn, dir) {
311 log::warn!("cover art under {} not refreshed: {e}", dir.display());
312 }
313 }
314 result.playlists += super::playlist_files::import(db, &playlists, &settled);
315 result.added = result.added.saturating_sub(result.moved);
316 if result.added > 0 {
317 super::playlist_files::refresh_m3u(db);
318 }
319 result
320}
321
322fn stored_paths(files: &[PathBuf]) -> HashSet<String> {
324 files
325 .iter()
326 .map(|f| f.to_string_lossy().into_owned())
327 .collect()
328}
329
330pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
333 dirs.sort();
335 dirs.dedup();
336 let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
337 for dir in dirs {
338 if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
339 kept.push(dir);
340 }
341 }
342 kept
343}
344
345fn library_folder(db: &Database, folder: &Path) -> PathBuf {
349 let path = super::spelling::on_disk(folder);
350 if path != folder {
351 let respelled = queries::write_transaction(&db.conn)
352 .map_err(crate::db::connection::DbError::from)
353 .and_then(|tx| {
354 queries::sources::respell_folder(&tx, folder, &path)?;
355 Ok(tx.commit()?)
356 });
357 if let Err(e) = respelled {
358 log::warn!(
359 "could not move {} to {}: {e}",
360 folder.display(),
361 path.display()
362 );
363 }
364 }
365 path
366}
367
368fn is_populated(root: &Path) -> bool {
370 std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
371}
372
373fn merge(total: &mut ScanResult, r: ScanResult) {
374 total.cancelled |= r.cancelled;
375 total.added += r.added;
376 total.updated += r.updated;
377 total.removed += r.removed;
378 total.moved += r.moved;
379 total.skipped += r.skipped;
380 total.unreadable += r.unreadable;
381 total.playlists += r.playlists;
382 total.removed_paths.extend(r.removed_paths);
383 total.errors.extend(r.errors);
384}
385
386#[derive(Default)]
388struct Walked {
389 audio: Vec<PathBuf>,
390 playlists: Vec<PathBuf>,
392}
393
394fn walk(path: &Path, result: &mut ScanResult) -> Walked {
403 let mut found = Walked::default();
404 let entries = walkdir::WalkDir::new(path)
405 .follow_links(true)
406 .into_iter()
407 .filter_entry(|e| e.depth() == 0 || !super::watch::is_ignored(e.file_name()));
408 for entry in entries {
409 match entry {
410 Ok(e) if e.path().to_str().is_none() => {
411 if e.file_type().is_file() {
412 log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
413 }
414 }
415 Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
416 found.audio.push(e.path().to_path_buf())
417 }
418 Ok(e) if e.file_type().is_file() && is_playlist_file(e.path()) => {
419 found.playlists.push(e.path().to_path_buf())
420 }
421 Ok(_) => {}
422 Err(e) => {
423 result.unreadable += 1;
424 log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
425 }
426 }
427 }
428 found
429}
430
431fn index_files(
438 db: &Database,
439 mut audio_files: Vec<PathBuf>,
440 scope: &[PathBuf],
441 opts: &ScanOptions,
442 on_track: Option<&dyn Fn(ScanEvent)>,
443 result: &mut ScanResult,
444 context: &Path,
445) -> bool {
446 let total_files = audio_files.len();
447 if total_files == 0 {
448 return true;
449 }
450
451 let files_to_scan: Vec<PathBuf> = if opts.force {
453 std::mem::take(&mut audio_files)
454 } else {
455 let scan_cache = queries::load_scan_cache(&db.conn, scope).unwrap_or_default();
456 audio_files
458 .par_iter()
459 .filter(|file_path| {
460 let Ok(file_meta) = std::fs::metadata(file_path) else {
461 return true;
462 };
463 let mtime = file_meta
464 .modified()
465 .ok()
466 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
467 .map(|d| d.as_secs() as i64)
468 .unwrap_or(0);
469 let size = file_meta.len() as i64;
470 let path_str = file_path.to_string_lossy();
471 match scan_cache.get(path_str.as_ref()) {
472 Some(&(cached_mtime, cached_size)) => {
473 mtime != cached_mtime || size != cached_size
474 }
475 None => true,
476 }
477 })
478 .cloned()
479 .collect()
480 };
481
482 result.skipped += total_files - files_to_scan.len();
483
484 let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
496 CHUNK_SIZE.saturating_mul(2),
497 );
498 let cancel = opts.cancel.clone();
499 let reader = std::thread::Builder::new()
500 .name("koan-scan-read".into())
501 .spawn(move || {
502 let _ = files_to_scan.par_iter().try_for_each(|file_path| {
504 if cancel
505 .as_ref()
506 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
507 {
508 return Err(());
509 }
510 send.send((
511 file_path.clone(),
512 isolate_read(file_path, metadata::read_metadata),
513 ))
514 .map_err(|_| ())
515 });
516 });
517 if let Err(e) = &reader {
518 log::error!("failed to spawn scan reader: {}", e);
519 result
520 .errors
521 .push((context.to_path_buf(), format!("scan error: {}", e)));
522 return false;
523 }
524
525 loop {
526 let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
528 recv.iter().take(CHUNK_SIZE).collect();
529 if batch.is_empty() {
530 break;
531 }
532 if opts
533 .cancel
534 .as_ref()
535 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
536 {
537 log::info!("scan cancelled — keeping what was already committed");
538 result.cancelled = true;
539 break;
540 }
541
542 let tx = match crate::db::queries::write_transaction(&db.conn) {
543 Ok(tx) => tx,
544 Err(e) => {
545 log::error!("failed to begin scan transaction: {}", e);
546 result
547 .errors
548 .push((context.to_path_buf(), format!("db error: {}", e)));
549 return false;
550 }
551 };
552
553 let (mut added, mut updated) = (0usize, 0usize);
554 let mut arrived = Vec::new();
555 for (file_path, meta_result) in batch {
556 match meta_result {
557 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
558 Ok((track_id, is_new)) => {
559 if is_new {
560 added += 1;
561 arrived.push(track_id);
562 } else {
563 updated += 1;
564 }
565 if let Some(cb) = &on_track {
566 cb(ScanEvent {
567 artist: &meta.artist,
568 album: &meta.album,
569 title: &meta.title,
570 path: &file_path,
571 is_new,
572 });
573 }
574 if let Err(e) = queries::update_scan_cache(
575 &tx,
576 meta.path.as_deref().unwrap_or(""),
577 meta.mtime.unwrap_or(0),
578 meta.size_bytes.unwrap_or(0),
579 track_id,
580 ) {
581 log::warn!("failed to cache {}: {}", file_path.display(), e);
583 }
584 }
585 Err(e) => {
586 result.errors.push((file_path, format!("db error: {}", e)));
587 }
588 },
589 Err(e) => {
590 result.errors.push((file_path, e));
591 }
592 }
593 }
594
595 match tx.commit() {
596 Ok(()) => {
597 result.added += added;
598 result.updated += updated;
599 result.arrived.extend(arrived);
600 }
601 Err(e) => {
602 log::error!("failed to commit scan transaction: {}", e);
603 result
604 .errors
605 .push((context.to_path_buf(), format!("db error: {}", e)));
606 }
607 }
608 }
609
610 drop(recv);
613 if let Ok(handle) = reader
614 && handle.join().is_err()
615 {
616 log::error!("scan reader thread panicked");
617 }
618 true
619}
620
621fn remove_stale(
626 db: &Database,
627 path: &Path,
628 force_remove: bool,
629 arrived: &[i64],
630 walked: &HashSet<String>,
631 result: &mut ScanResult,
632) {
633 let tx = match crate::db::queries::write_transaction(&db.conn) {
634 Ok(tx) => tx,
635 Err(e) => {
636 log::error!("failed to begin stale-removal transaction: {}", e);
637 result
638 .errors
639 .push((path.to_path_buf(), format!("db error: {}", e)));
640 return;
641 }
642 };
643 let moved = match queries::adopt_moved_files(&tx, path, arrived, Some(walked)) {
644 Ok(moved) => moved,
645 Err(e) => {
646 log::error!("failed to match moved files: {}", e);
647 result.errors.push((path.to_path_buf(), e.to_string()));
648 return;
649 }
650 };
651 result.moved += moved;
652 match queries::remove_stale_tracks_walked(&tx, path, force_remove, Some(walked)) {
653 Ok(removed) => {
654 if let Err(e) = tx.commit() {
655 log::error!("failed to commit stale removals: {}", e);
656 result
657 .errors
658 .push((path.to_path_buf(), format!("db error: {}", e)));
659 return;
660 }
661 result.removed += removed.len();
662 result.removed_paths.extend(removed);
663 }
664 Err(e) => {
665 log::error!("failed to remove stale tracks: {}", e);
666 result.errors.push((path.to_path_buf(), e.to_string()));
667 if matches!(e, crate::db::connection::DbError::UnsafeBulkDelete(_))
670 && let Err(e) = tx.commit()
671 {
672 log::error!("failed to commit moved files: {}", e);
673 }
674 }
675 }
676}
677
678fn isolate_read(
683 path: &Path,
684 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
685) -> Result<TrackMeta, String> {
686 match catch_unwind(AssertUnwindSafe(|| read(path))) {
687 Ok(result) => result.map_err(|e| e.to_string()),
688 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
689 }
690}
691
692#[derive(Debug, Default)]
694pub struct ImportResult {
695 pub track_ids: Vec<i64>,
698 pub added: usize,
699 pub updated: usize,
700 pub errors: Vec<(PathBuf, String)>,
701}
702
703pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
714 let mut result = ImportResult::default();
715
716 let mut files: Vec<PathBuf> = Vec::new();
717 let mut scope = Vec::new();
718 let mut seen = std::collections::HashSet::new();
719 let mut spelling = super::spelling::Spelling::default();
723 for path in paths {
724 let path = spelling.on_disk(path);
725 let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
726 .follow_links(true)
727 .into_iter()
728 .filter_map(Result::ok)
729 .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
730 .filter(|e| {
731 let utf8 = e.path().to_str().is_some();
732 if !utf8 {
733 log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
734 }
735 utf8
736 })
737 .map(|e| e.path().to_path_buf())
738 .collect();
739 found.sort();
740 scope.push(path);
741 files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
743 }
744
745 if files.is_empty() {
746 return result;
747 }
748
749 let _importing = super::lane::importing();
752 let mut scanned = ScanResult::default();
753 index_files(
754 db,
755 files.clone(),
756 &scope,
757 &ScanOptions::default(),
758 None,
759 &mut scanned,
760 Path::new(""),
761 );
762 result.added = scanned.added;
763 result.updated = scanned.updated;
764 result.errors = scanned.errors;
765
766 let mut track_of = match db
769 .conn
770 .prepare_cached("SELECT track_id FROM local_files WHERE path = ?1")
771 {
772 Ok(stmt) => stmt,
773 Err(e) => {
774 result
775 .errors
776 .push((PathBuf::new(), format!("db error: {e}")));
777 return result;
778 }
779 };
780 for file in &files {
781 if let Ok(id) = track_of.query_row([file.to_string_lossy()], |r| r.get::<_, i64>(0)) {
782 result.track_ids.push(id);
783 }
784 }
785 drop(track_of);
786
787 if result.added > 0 {
788 super::playlist_files::refresh_m3u(db);
789 }
790 result
791}
792
793pub fn full_scan(
795 db: &Database,
796 folders: &[PathBuf],
797 mut opts: ScanOptions,
798 on_track: Option<&dyn Fn(ScanEvent)>,
799) -> ScanResult {
800 let _turn = super::lane::enter(folders, &mut opts);
801 let existing: Vec<PathBuf> = folders
802 .iter()
803 .filter(|folder| {
804 let exists = folder.exists();
805 if !exists {
806 log::warn!("library folder does not exist: {}", folder.display());
807 }
808 exists
809 })
810 .cloned()
811 .collect();
812 let total = scan_folders_in_lane(db, &existing, &opts, None, on_track);
813 db.optimize();
814 total
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use crate::db::connection::Database;
821 use crate::db::queries;
822 use crate::test_utils;
823
824 fn test_db(dir: &Path) -> Database {
825 let db_path = dir.join("test.db");
826 Database::open(&db_path).unwrap()
827 }
828
829 #[test]
830 fn cancelling_a_scan_returns_with_readers_still_pending() {
831 let dir = tempfile::tempdir().unwrap();
832 let music_dir = dir.path().join("music");
833 std::fs::create_dir_all(&music_dir).unwrap();
834 for i in 0..CHUNK_SIZE * 12 {
837 test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
838 }
839
840 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
841 let root = dir.path().to_path_buf();
842 std::thread::spawn(move || {
843 let db = test_db(&root);
844 let cancel = Arc::new(AtomicBool::new(false));
845 let opts = ScanOptions {
846 cancel: Some(cancel.clone()),
847 ..Default::default()
848 };
849 let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
850 let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
851 done_tx.send(result).unwrap();
852 });
853
854 let result = done_rx
855 .recv_timeout(std::time::Duration::from_secs(30))
856 .expect("cancelled scan never returned");
857 assert!(result.cancelled);
858 assert!(result.added < CHUNK_SIZE * 12);
859 }
860
861 #[test]
864 fn a_scan_started_without_a_cancel_flag_can_still_be_cancelled() {
865 let dir = tempfile::tempdir().unwrap();
866 let music = dir.path().join("music");
867 std::fs::create_dir_all(&music).unwrap();
868 for i in 0..CHUNK_SIZE * 6 {
869 test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
870 }
871 let db = test_db(dir.path());
872 let on_track = |_: ScanEvent| super::super::lane::cancel_all();
873 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
874 assert!(result.cancelled);
875 assert!(result.added < CHUNK_SIZE * 6);
876 }
877
878 #[test]
880 fn a_scan_is_cancelled_by_forgetting_its_folder_only() {
881 let dir = tempfile::tempdir().unwrap();
882 let music = dir.path().join("music");
883 std::fs::create_dir_all(&music).unwrap();
884 for i in 0..CHUNK_SIZE * 6 {
885 test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
886 }
887 let elsewhere = dir.path().join("elsewhere");
888 let db = test_db(dir.path());
889
890 let on_track = |_: ScanEvent| super::super::lane::cancel_under(&elsewhere);
891 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
892 assert!(!result.cancelled);
893 assert_eq!(result.added, CHUNK_SIZE * 6);
894
895 let album = music.join("Album");
896 std::fs::create_dir_all(&album).unwrap();
897 for i in 0..CHUNK_SIZE * 6 {
898 test_utils::generate_wav(&album.join(format!("{i:03}.wav")), 8000, 1, 0.02, 16);
899 }
900 let on_track = |_: ScanEvent| super::super::lane::cancel_under(&album);
901 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
902 assert!(result.cancelled, "a scan of the folder it is in");
903 }
904
905 #[test]
908 fn scans_wait_their_turn() {
909 let dir = tempfile::tempdir().unwrap();
910 let music = dir.path().join("music");
911 std::fs::create_dir_all(&music).unwrap();
912 test_utils::generate_wav(&music.join("a.wav"), 8000, 1, 0.1, 16);
913 let db_root = dir.path().to_path_buf();
914
915 let held = super::super::lane::wait();
916 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
917 std::thread::spawn(move || {
918 let db = test_db(&db_root);
919 done_tx
920 .send(scan_folder(&db, &music, ScanOptions::default(), None).added)
921 .unwrap();
922 });
923 assert!(
924 done_rx
925 .recv_timeout(std::time::Duration::from_millis(300))
926 .is_err(),
927 "ran while the lane was held"
928 );
929 drop(held);
930 let added = done_rx
931 .recv_timeout(std::time::Duration::from_secs(30))
932 .expect("never ran once the lane was free");
933 assert_eq!(added, 1);
934 }
935
936 #[test]
939 fn an_import_does_not_wait_for_a_scan() {
940 let dir = tempfile::tempdir().unwrap();
941 let rip = dir.path().join("rip");
942 std::fs::create_dir_all(&rip).unwrap();
943 test_utils::generate_wav(&rip.join("a.wav"), 8000, 1, 0.1, 16);
944 let db_root = dir.path().to_path_buf();
945
946 let mut opts = ScanOptions::default();
947 let held = super::super::lane::enter(&[dir.path().join("library")], &mut opts);
948 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
949 std::thread::spawn(move || {
950 let db = test_db(&db_root);
951 done_tx
952 .send(
953 import_paths(&db, std::slice::from_ref(&rip))
954 .track_ids
955 .len(),
956 )
957 .unwrap();
958 });
959 let imported = done_rx
960 .recv_timeout(std::time::Duration::from_secs(30))
961 .expect("waited for the scan");
962 assert_eq!(imported, 1);
963 drop(held);
964 }
965
966 #[test]
967 fn scan_folder_indexes_new_files() {
968 let dir = tempfile::tempdir().unwrap();
969 let music_dir = dir.path().join("music");
970 std::fs::create_dir_all(&music_dir).unwrap();
971
972 let wav_path = music_dir.join("silence.wav");
974 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
975
976 let db = test_db(dir.path());
977 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
978
979 assert_eq!(result.added, 1, "expected 1 track added");
980 assert_eq!(result.skipped, 0);
981 assert_eq!(result.removed, 0);
982 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
983
984 let stats = queries::library_stats(&db.conn).unwrap();
986 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
987 }
988
989 #[test]
990 fn a_renamed_file_keeps_its_track() {
991 let dir = tempfile::tempdir().unwrap();
992 let music = dir.path().join("music");
993 let album = music.join("Squire Of Gothos").join("Album");
994 std::fs::create_dir_all(&album).unwrap();
995 for i in 1..=3 {
996 test_utils::generate_wav(
997 &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
998 44100,
999 1,
1000 0.5 + i as f32 * 0.1,
1001 16,
1002 );
1003 }
1004 let db = test_db(dir.path());
1005 scan_folder(&db, &music, ScanOptions::default(), None);
1006 let before = tracks_with_uids(&db);
1007 let first = before[0].0;
1008 queries::record_play(&db.conn, queries::LOCAL_USER, first, Some(1000)).unwrap();
1009 queries::add_favourite(&db.conn, queries::LOCAL_USER, first).unwrap();
1010
1011 let moved = music.join("The Squire of Gothos").join("Album");
1014 std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
1015 std::fs::rename(&album, &moved).unwrap();
1016 std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
1017 for i in 1..=3 {
1018 std::fs::rename(
1019 moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1020 moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
1021 )
1022 .unwrap();
1023 }
1024 let result = scan_folder(&db, &music, ScanOptions::default(), None);
1025
1026 assert_eq!(
1027 (result.added, result.removed, result.moved),
1028 (0, 0, 3),
1029 "{:?}",
1030 result.errors
1031 );
1032 let paths = track_paths(&db);
1033 assert_eq!(paths.len(), 3, "{paths:#?}");
1034 assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1035 let after = tracks_with_uids(&db);
1036 assert_eq!(
1037 before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1038 after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1039 "same rows, same uids"
1040 );
1041 let kept: (i64, i64) = db
1042 .conn
1043 .query_row(
1044 "SELECT (SELECT COUNT(*) FROM play_history WHERE track_id = ?1),
1045 (SELECT COUNT(*) FROM favourites WHERE track_id = ?1)",
1046 [first],
1047 |r| Ok((r.get(0)?, r.get(1)?)),
1048 )
1049 .unwrap();
1050 assert_eq!(kept, (1, 1), "history and favourite kept");
1051 }
1052
1053 #[cfg(target_os = "macos")]
1060 #[test]
1061 fn a_file_stored_under_two_spellings_is_folded_into_one_track() {
1062 use unicode_normalization::UnicodeNormalization;
1063 let dir = tempfile::tempdir().unwrap();
1064 let music = dir.path().join("music");
1065 let nfd: String = "Björk".nfd().collect();
1066 let nfc: String = "Björk".nfc().collect();
1067 std::fs::create_dir_all(music.join(&nfd)).unwrap();
1068 test_utils::generate_wav(&music.join(&nfd).join("Jóga.wav"), 44100, 1, 0.5, 16);
1069 let on_disk = music
1070 .join(&nfd)
1071 .join("Jóga.wav")
1072 .to_string_lossy()
1073 .into_owned();
1074 let organized = music
1075 .join(&nfc)
1076 .join("Jóga.wav")
1077 .to_string_lossy()
1078 .into_owned();
1079 let db = test_db(dir.path());
1080 scan_folder(&db, &music, ScanOptions::default(), None);
1081 let original = tracks_with_uids(&db)[0].0;
1082 queries::record_play(&db.conn, queries::LOCAL_USER, original, Some(1000)).unwrap();
1083 queries::sources::rename_file(&db.conn, &on_disk, &organized).unwrap();
1086 for table in ["tracks", "scan_cache"] {
1087 db.conn
1088 .execute(
1089 &format!("UPDATE {table} SET path = ?1 WHERE path = ?2"),
1090 [&organized, &on_disk],
1091 )
1092 .unwrap();
1093 }
1094 scan_folder(&db, &music, ScanOptions::default(), None);
1096 scan_folder(&db, &music, ScanOptions::default(), None);
1097
1098 let tracks = tracks_with_uids(&db);
1099 assert_eq!(tracks.len(), 1, "{tracks:?}");
1100 assert_eq!(tracks[0].0, original);
1101 assert_eq!(track_paths(&db), vec![on_disk]);
1102 let plays: i64 = db
1103 .conn
1104 .query_row(
1105 "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
1106 [original],
1107 |r| r.get(0),
1108 )
1109 .unwrap();
1110 assert_eq!(plays, 1);
1111 }
1112
1113 #[cfg(target_os = "macos")]
1118 #[test]
1119 fn rows_stored_under_a_library_folder_in_another_case_move_to_the_disks() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let music = dir.path().join("Music");
1122 let given = dir.path().join("music");
1123 std::fs::create_dir_all(music.join("Artist")).unwrap();
1124 if !given.exists() {
1125 return;
1126 }
1127 for name in ["a.wav", "b.wav"] {
1128 test_utils::generate_wav(&music.join("Artist").join(name), 44100, 1, 0.2, 16);
1129 }
1130 let db = test_db(dir.path());
1131 scan_folder(&db, &music, ScanOptions::default(), None);
1132 let original: Vec<i64> = tracks_with_uids(&db).iter().map(|t| t.0).collect();
1133 let (from, to) = (
1135 format!("{}/", music.display()),
1136 format!("{}/", given.display()),
1137 );
1138 for (table, column) in [
1139 ("tracks", "path"),
1140 ("local_files", "path"),
1141 ("scan_cache", "path"),
1142 ] {
1143 db.conn
1144 .execute(
1145 &format!("UPDATE {table} SET {column} = replace({column}, ?1, ?2)"),
1146 [&from, &to],
1147 )
1148 .unwrap();
1149 }
1150 let twin = music.join("Artist/b.wav");
1152 import_paths(&db, std::slice::from_ref(&twin));
1153 assert_eq!(tracks_with_uids(&db).len(), 3);
1154
1155 scan_folder(&db, &given, ScanOptions::default(), None);
1156
1157 let tracks = tracks_with_uids(&db);
1158 assert_eq!(
1159 tracks.iter().map(|t| t.0).collect::<Vec<_>>(),
1160 original,
1161 "{tracks:?}"
1162 );
1163 assert!(
1164 track_paths(&db).iter().all(|p| p.starts_with(&from)),
1165 "{:?}",
1166 track_paths(&db)
1167 );
1168 let stale: i64 = db
1169 .conn
1170 .query_row(
1171 "SELECT (SELECT COUNT(*) FROM local_files WHERE substr(path, 1, length(?1)) = ?1)
1172 + (SELECT COUNT(*) FROM scan_cache WHERE substr(path, 1, length(?1)) = ?1)",
1173 [&to],
1174 |r| r.get(0),
1175 )
1176 .unwrap();
1177 assert_eq!(stale, 0);
1178 }
1179
1180 #[cfg(target_os = "macos")]
1185 #[test]
1186 fn respelling_a_folder_leaves_nothing_under_the_old_spelling() {
1187 let dir = tempfile::tempdir().unwrap();
1188 let music = dir.path().join("Music");
1189 let given = dir.path().join("music");
1190 std::fs::create_dir_all(&music).unwrap();
1191 if !given.exists() {
1192 return;
1193 }
1194 let file = music.join("b.wav");
1195 test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1196 let db = test_db(dir.path());
1197 let mut tracks = Vec::new();
1198 for (root, remote) in [(&given, "sub-1"), (&music, "sub-2")] {
1199 let mut meta = metadata::read_metadata(&file).unwrap();
1200 meta.path = Some(root.join("b.wav").to_string_lossy().into_owned());
1201 meta.remote_id = Some(remote.into());
1202 tracks.push(queries::upsert_track(&db.conn, &meta).unwrap());
1203 }
1204 let local_files = |db: &Database| -> Vec<(i64, String)> {
1205 db.conn
1206 .prepare("SELECT track_id, path FROM local_files ORDER BY path")
1207 .unwrap()
1208 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1209 .unwrap()
1210 .collect::<Result<_, _>>()
1211 .unwrap()
1212 };
1213 assert_eq!(local_files(&db).len(), 2, "two tracks, one file each");
1214 let playlists: Vec<i64> = [&given, &music]
1215 .iter()
1216 .map(|root| {
1217 let id =
1218 queries::create_playlist(&db.conn, queries::LOCAL_USER, "list", None).unwrap();
1219 db.conn
1220 .execute(
1221 "UPDATE playlists SET source_path = ?1 WHERE id = ?2",
1222 rusqlite::params![root.join("list.nsp").to_string_lossy(), id],
1223 )
1224 .unwrap();
1225 id
1226 })
1227 .collect();
1228
1229 queries::sources::respell_folder(&db.conn, &given, &music).unwrap();
1230
1231 assert_eq!(
1232 local_files(&db),
1233 vec![(tracks[1], file.to_string_lossy().into_owned())]
1234 );
1235 let remaining: i64 = db
1236 .conn
1237 .query_row(
1238 "SELECT COUNT(*) FROM tracks WHERE id IN (?1, ?2)",
1239 [tracks[0], tracks[1]],
1240 |r| r.get(0),
1241 )
1242 .unwrap();
1243 assert_eq!(remaining, 2, "both stay as the server's");
1244 let kept: Vec<(i64, String)> = db
1245 .conn
1246 .prepare("SELECT id, source_path FROM playlists WHERE source_path IS NOT NULL")
1247 .unwrap()
1248 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1249 .unwrap()
1250 .collect::<Result<_, _>>()
1251 .unwrap();
1252 assert_eq!(
1253 kept,
1254 vec![(
1255 playlists[0],
1256 music.join("list.nsp").to_string_lossy().into_owned()
1257 )]
1258 );
1259 }
1260
1261 #[test]
1262 fn a_file_moved_between_library_folders_keeps_its_track() {
1263 let dir = tempfile::tempdir().unwrap();
1264 let (one, two) = (dir.path().join("one"), dir.path().join("two"));
1265 std::fs::create_dir_all(one.join("Album")).unwrap();
1266 std::fs::create_dir_all(&two).unwrap();
1267 test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
1268 test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
1269 test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
1270 let db = test_db(dir.path());
1271 let folders = [one.clone(), two.clone()];
1272 scan_folders(&db, &folders, ScanOptions::default(), None, None);
1273 let before = tracks_with_uids(&db);
1274
1275 std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
1276 let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
1277 assert_eq!((r.added, r.removed, r.moved), (0, 0, 1), "{:?}", r.errors);
1278 let after = tracks_with_uids(&db);
1279 assert_eq!(
1280 before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1281 after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1282 );
1283 assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1284 }
1285
1286 fn tracks_with_uids(db: &Database) -> Vec<(i64, String, String)> {
1288 db.conn
1289 .prepare("SELECT id, uid, path FROM tracks ORDER BY id")
1290 .unwrap()
1291 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1292 .unwrap()
1293 .collect::<Result<_, _>>()
1294 .unwrap()
1295 }
1296
1297 fn track_paths(db: &Database) -> Vec<String> {
1298 db.conn
1299 .prepare("SELECT path FROM tracks ORDER BY path")
1300 .unwrap()
1301 .query_map([], |r| r.get(0))
1302 .unwrap()
1303 .collect::<Result<_, _>>()
1304 .unwrap()
1305 }
1306
1307 #[test]
1310 fn a_forgotten_folder_added_back_is_read_again() {
1311 let dir = tempfile::tempdir().unwrap();
1312 let music = dir.path().join("music");
1313 std::fs::create_dir_all(&music).unwrap();
1314 let file = music.join("kept.wav");
1315 test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1316 let db = test_db(dir.path());
1317 let mut both = metadata::read_metadata(&file).unwrap();
1318 both.remote_id = Some("sub-1".into());
1319 let track = queries::upsert_track(&db.conn, &both).unwrap();
1320 let local = |db: &Database| -> Option<String> {
1321 db.conn
1322 .query_row("SELECT path FROM tracks WHERE id = ?1", [track], |r| {
1323 r.get(0)
1324 })
1325 .unwrap()
1326 };
1327 scan_folder(&db, &music, ScanOptions::default(), None);
1328
1329 assert_eq!(crate::helpers::forget_folder(&db, &music).unwrap(), 1);
1330 assert_eq!(local(&db), None, "kept as the server's copy");
1331
1332 let again = scan_folder(&db, &music, ScanOptions::default(), None);
1333 assert_eq!(again.skipped, 0, "{:?}", again.errors);
1334 assert_eq!(local(&db), Some(file.to_string_lossy().into_owned()));
1335 }
1336
1337 #[test]
1338 fn scans_skip_what_the_watcher_ignores() {
1339 let dir = tempfile::tempdir().unwrap();
1340 let music = dir.path().join("music");
1341 let album = music.join("Artist/Album");
1342 let dotted = music.join("Britney Spears/...Baby One More Time");
1343 for sub in [
1344 album.join(".stversions"),
1345 album.join("Disc 2.part"),
1346 dotted.clone(),
1347 ] {
1348 std::fs::create_dir_all(sub).unwrap();
1349 }
1350 let wav = |p: PathBuf| test_utils::generate_wav(&p, 8000, 1, 0.1, 16);
1351 wav(album.join("01.wav"));
1352 wav(dotted.join("01.wav"));
1353 wav(album.join(".stversions/01~20261006-120000.wav"));
1354 wav(album.join("._01.wav"));
1355 wav(album.join("Disc 2.part/02.wav"));
1356 wav(album.join("~syncthing~03.wav"));
1357 let db = test_db(dir.path());
1358
1359 let full = scan_folder(&db, &music, ScanOptions::default(), None);
1360 assert_eq!(full.added, 2, "{:?}", full.errors);
1361 let dirs = scan_dirs(
1362 &db,
1363 std::slice::from_ref(&music),
1364 std::slice::from_ref(&album),
1365 ScanOptions::default(),
1366 None,
1367 );
1368 assert_eq!(dirs.added, 0, "{:?}", dirs.errors);
1369 assert_eq!(
1370 track_paths(&db),
1371 [album.join("01.wav"), dotted.join("01.wav")].map(|p| p.to_string_lossy().into_owned())
1372 );
1373 }
1374
1375 #[test]
1376 fn minimal_dirs_keeps_only_the_outermost() {
1377 let dirs = [
1378 "/m/A/Album 2",
1379 "/m/A/Album",
1380 "/m/A/Album/CD1",
1381 "/m/A/Album",
1382 "/m/AB",
1383 "/m/B/Album/CD2",
1384 ]
1385 .map(PathBuf::from)
1386 .to_vec();
1387 assert_eq!(
1388 minimal_dirs(dirs),
1389 ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
1390 );
1391 assert_eq!(
1392 minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
1393 [PathBuf::from("/m/A")]
1394 );
1395 }
1396
1397 #[test]
1398 fn scan_dirs_covers_only_the_directories_named() {
1399 let dir = tempfile::tempdir().unwrap();
1400 let music = dir.path().join("music");
1401 let (old, new) = (music.join("Old"), music.join("New"));
1402 std::fs::create_dir_all(&old).unwrap();
1403 std::fs::create_dir_all(&new).unwrap();
1404 test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
1405
1406 let db = test_db(dir.path());
1407 scan_folder(&db, &music, ScanOptions::default(), None);
1408 test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
1409 test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
1411
1412 let r = scan_dirs(
1413 &db,
1414 std::slice::from_ref(&music),
1415 std::slice::from_ref(&new),
1416 ScanOptions::default(),
1417 None,
1418 );
1419 assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
1420 assert_eq!(track_paths(&db).len(), 2);
1421 }
1422
1423 #[test]
1424 fn rescanning_a_directory_tells_the_apps_its_art_may_have_changed() {
1425 let dir = tempfile::tempdir().unwrap();
1426 let music = dir.path().join("music");
1427 let (album, other) = (music.join("Album"), music.join("Other"));
1428 std::fs::create_dir_all(album.join("CD1")).unwrap();
1429 std::fs::create_dir_all(&other).unwrap();
1430 test_utils::generate_wav(&album.join("CD1/a.wav"), 44100, 1, 0.2, 16);
1431 test_utils::generate_wav(&other.join("b.wav"), 44100, 1, 0.2, 16);
1432 let db = test_db(dir.path());
1433 scan_folder(&db, &music, ScanOptions::default(), None);
1434 let evicted = |db: &Database| -> Vec<(String, i64)> {
1435 db.conn
1436 .prepare("SELECT kind, id FROM art_evictions ORDER BY seq")
1437 .unwrap()
1438 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1439 .unwrap()
1440 .collect::<Result<_, _>>()
1441 .unwrap()
1442 };
1443 assert!(evicted(&db).is_empty());
1444
1445 std::fs::write(album.join("cover.jpg"), b"").unwrap();
1447 scan_dirs(
1448 &db,
1449 std::slice::from_ref(&music),
1450 std::slice::from_ref(&album),
1451 ScanOptions::default(),
1452 None,
1453 );
1454 let (album_id, track_id): (i64, i64) = db
1455 .conn
1456 .query_row(
1457 "SELECT album_id, id FROM tracks WHERE path LIKE '%/CD1/a.wav'",
1458 [],
1459 |r| Ok((r.get(0)?, r.get(1)?)),
1460 )
1461 .unwrap();
1462 assert_eq!(
1463 evicted(&db),
1464 vec![("album".into(), album_id), ("track".into(), track_id)],
1465 "only the records under the folder rescanned"
1466 );
1467 }
1468
1469 #[test]
1470 fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
1471 let dir = tempfile::tempdir().unwrap();
1472 let music = dir.path().join("music");
1473 let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
1474 std::fs::create_dir_all(&kept).unwrap();
1475 std::fs::create_dir_all(&gone).unwrap();
1476 for i in 0..3 {
1477 test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
1478 test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
1479 }
1480 let db = test_db(dir.path());
1481 assert_eq!(
1482 scan_folder(&db, &music, ScanOptions::default(), None).added,
1483 6
1484 );
1485
1486 std::fs::remove_file(kept.join("0.wav")).unwrap();
1487 std::fs::remove_dir_all(&gone).unwrap();
1488 let r = scan_dirs(
1489 &db,
1490 std::slice::from_ref(&music),
1491 &[kept.clone(), gone.clone()],
1492 ScanOptions::default(),
1493 None,
1494 );
1495
1496 assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
1497 let paths = track_paths(&db);
1498 assert_eq!(paths.len(), 2, "{paths:#?}");
1499 assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1500 }
1501
1502 #[test]
1505 fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
1506 let dir = tempfile::tempdir().unwrap();
1507 let music = dir.path().join("music");
1508 let album = music.join("Album");
1509 std::fs::create_dir_all(&album).unwrap();
1510 test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
1511 let db = test_db(dir.path());
1512 scan_folder(&db, &music, ScanOptions::default(), None);
1513
1514 std::fs::remove_dir_all(&album).unwrap();
1515 let r = scan_dirs(
1516 &db,
1517 std::slice::from_ref(&music),
1518 std::slice::from_ref(&album),
1519 ScanOptions::default(),
1520 None,
1521 );
1522 assert_eq!(r.removed, 0);
1523 assert_eq!(track_paths(&db).len(), 1);
1524 }
1525
1526 #[cfg(unix)]
1530 #[test]
1531 fn a_dangling_symlink_is_not_a_deletion() {
1532 let dir = tempfile::tempdir().unwrap();
1533 let music = dir.path().join("music");
1534 let local = music.join("Local");
1535 let share = dir.path().join("share");
1536 std::fs::create_dir_all(&local).unwrap();
1537 std::fs::create_dir_all(share.join("Album")).unwrap();
1538 test_utils::generate_wav(&local.join("a.wav"), 44100, 1, 0.2, 16);
1539 test_utils::generate_wav(&share.join("Album/b.wav"), 44100, 1, 0.2, 16);
1540 std::os::unix::fs::symlink(&share, music.join("nas")).unwrap();
1541 let db = test_db(dir.path());
1542 assert_eq!(
1543 scan_folder(&db, &music, ScanOptions::default(), None).added,
1544 2
1545 );
1546
1547 std::fs::remove_dir_all(&share).unwrap();
1548 let full = scan_folder(&db, &music, ScanOptions::default(), None);
1549 assert_eq!(full.removed, 0, "{:?}", full.removed_paths);
1550 let watched = scan_dirs(
1551 &db,
1552 std::slice::from_ref(&music),
1553 &[music.join("nas/Album")],
1554 ScanOptions::default(),
1555 None,
1556 );
1557 assert_eq!(watched.removed, 0, "{:?}", watched.removed_paths);
1558 assert_eq!(track_paths(&db).len(), 2);
1559
1560 std::fs::remove_file(music.join("nas")).unwrap();
1562 let removed = scan_folder(&db, &music, ScanOptions::default(), None);
1563 assert_eq!(removed.removed, 1);
1564 }
1565
1566 #[cfg(target_os = "linux")]
1569 #[test]
1570 fn a_path_that_is_not_utf8_is_skipped() {
1571 use std::os::unix::ffi::OsStrExt;
1572 let dir = tempfile::tempdir().unwrap();
1573 let music = dir.path().join("music");
1574 std::fs::create_dir_all(&music).unwrap();
1575 test_utils::generate_wav(&music.join("a.wav"), 44100, 1, 0.2, 16);
1576 let odd = music.join(std::ffi::OsStr::from_bytes(b"caf\xe9.wav"));
1577 test_utils::generate_wav(&odd, 44100, 1, 0.2, 16);
1578 let db = test_db(dir.path());
1579
1580 let r = scan_folder(&db, &music, ScanOptions::default(), None);
1581 assert_eq!((r.added, r.removed), (1, 0));
1582 assert_eq!(track_paths(&db).len(), 1);
1583 }
1584
1585 #[test]
1586 fn scan_dirs_ignores_directories_outside_the_library() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let music = dir.path().join("music");
1589 let elsewhere = dir.path().join("elsewhere");
1590 std::fs::create_dir_all(&music).unwrap();
1591 std::fs::create_dir_all(&elsewhere).unwrap();
1592 test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
1593 let db = test_db(dir.path());
1594
1595 let r = scan_dirs(
1596 &db,
1597 std::slice::from_ref(&music),
1598 std::slice::from_ref(&elsewhere),
1599 ScanOptions::default(),
1600 None,
1601 );
1602 assert_eq!(r.added, 0);
1603 assert!(track_paths(&db).is_empty());
1604 }
1605
1606 #[test]
1607 fn import_paths_indexes_files_where_they_lie() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let drop = dir.path().join("Downloads/rip");
1612 std::fs::create_dir_all(&drop).unwrap();
1613 test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
1614 test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
1615 std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
1616
1617 let db = test_db(dir.path());
1618 let result = import_paths(&db, std::slice::from_ref(&drop));
1619
1620 assert_eq!(result.added, 2);
1621 assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
1622 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1623
1624 for id in &result.track_ids {
1626 let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
1627 assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
1628 }
1629 }
1630
1631 #[test]
1633 fn import_paths_is_idempotent() {
1634 let dir = tempfile::tempdir().unwrap();
1635 let drop = dir.path().join("rip");
1636 std::fs::create_dir_all(&drop).unwrap();
1637 test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
1638
1639 let db = test_db(dir.path());
1640 let first = import_paths(&db, std::slice::from_ref(&drop));
1641 let second = import_paths(&db, std::slice::from_ref(&drop));
1642
1643 assert_eq!(first.added, 1);
1644 assert_eq!((second.added, second.updated), (0, 0));
1646 assert_eq!(first.track_ids, second.track_ids);
1647 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1648 }
1649
1650 #[test]
1652 fn import_paths_deduplicates_overlapping_selections() {
1653 let dir = tempfile::tempdir().unwrap();
1654 let drop = dir.path().join("rip");
1655 std::fs::create_dir_all(&drop).unwrap();
1656 let track = drop.join("a.wav");
1657 test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
1658
1659 let db = test_db(dir.path());
1660 let result = import_paths(&db, &[drop.clone(), track.clone()]);
1661
1662 assert_eq!(result.track_ids.len(), 1);
1663 }
1664
1665 #[test]
1666 fn scan_folder_skips_unchanged_files() {
1667 let dir = tempfile::tempdir().unwrap();
1668 let music_dir = dir.path().join("music");
1669 std::fs::create_dir_all(&music_dir).unwrap();
1670
1671 let wav_path = music_dir.join("unchanged.wav");
1672 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1673
1674 let db = test_db(dir.path());
1675
1676 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1678 assert_eq!(r1.added, 1);
1679
1680 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1682 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
1683 assert_eq!(r2.added, 0, "no new files should be added");
1684 }
1685
1686 #[test]
1687 fn scan_folder_removes_deleted_tracks() {
1688 let dir = tempfile::tempdir().unwrap();
1689 let music_dir = dir.path().join("music");
1690 std::fs::create_dir_all(&music_dir).unwrap();
1691
1692 let wav_path = music_dir.join("ephemeral.wav");
1693 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1694 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
1695
1696 let db = test_db(dir.path());
1697
1698 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1700 assert_eq!(r1.added, 2);
1701
1702 std::fs::remove_file(&wav_path).unwrap();
1704
1705 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1707 assert_eq!(
1708 r2.removed, 1,
1709 "expected 1 track removed after file deletion"
1710 );
1711
1712 let stats = queries::library_stats(&db.conn).unwrap();
1713 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
1714 }
1715
1716 #[test]
1717 fn empty_folder_does_not_wipe_the_library() {
1718 let dir = tempfile::tempdir().unwrap();
1719 let music_dir = dir.path().join("music");
1720 std::fs::create_dir_all(&music_dir).unwrap();
1721 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
1722 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
1723
1724 let db = test_db(dir.path());
1725 assert_eq!(
1726 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1727 2
1728 );
1729
1730 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
1733 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
1734
1735 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1736 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
1737 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1738 }
1739
1740 #[cfg(unix)]
1741 #[test]
1742 fn unreadable_folder_is_not_a_deletion() {
1743 use std::os::unix::fs::PermissionsExt;
1744
1745 let dir = tempfile::tempdir().unwrap();
1746 let music_dir = dir.path().join("music");
1747 let locked_dir = music_dir.join("locked");
1748 std::fs::create_dir_all(&locked_dir).unwrap();
1749 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
1750 let locked_file = locked_dir.join("locked.wav");
1751 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
1752
1753 let db = test_db(dir.path());
1754 assert_eq!(
1755 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1756 2
1757 );
1758
1759 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
1760 if locked_file.try_exists().is_ok() {
1761 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1763 return;
1764 }
1765
1766 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1767 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1768
1769 assert!(
1770 r.unreadable >= 1,
1771 "the unreadable subtree should be counted"
1772 );
1773 assert_eq!(r.removed, 0, "an IO error is not a deletion");
1774 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1775 }
1776
1777 #[test]
1778 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let music_dir = dir.path().join("music");
1781 std::fs::create_dir_all(&music_dir).unwrap();
1782 for i in 0..6 {
1783 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
1784 }
1785
1786 let db = test_db(dir.path());
1787
1788 let seen = std::cell::Cell::new(0usize);
1790 let abort = |_: ScanEvent| {
1791 seen.set(seen.get() + 1);
1792 assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
1793 };
1794 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1795 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
1796 }));
1797 assert!(panicked.is_err());
1798
1799 assert_eq!(
1801 queries::library_stats(&db.conn).unwrap().total_tracks,
1802 CHUNK_SIZE as i64
1803 );
1804
1805 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1807 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
1808 assert_eq!(r.added, 6 - CHUNK_SIZE);
1809 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
1810 }
1811
1812 #[test]
1813 fn failing_file_is_named_and_the_scan_continues() {
1814 let dir = tempfile::tempdir().unwrap();
1815 let music_dir = dir.path().join("music");
1816 std::fs::create_dir_all(&music_dir).unwrap();
1817 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
1818 let broken = music_dir.join("broken.flac");
1819 std::fs::write(&broken, b"").unwrap();
1820
1821 let db = test_db(dir.path());
1822 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1823
1824 assert_eq!(r.added, 1, "the good file must still be indexed");
1825 assert_eq!(r.errors.len(), 1);
1826 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
1827 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1828 }
1829
1830 #[test]
1831 fn a_panicking_tag_read_becomes_an_error() {
1832 let path = Path::new("/music/hostile.mp3");
1833 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
1834 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
1835 }
1836
1837 #[test]
1838 fn scan_folder_updates_modified_files() {
1839 let dir = tempfile::tempdir().unwrap();
1840 let music_dir = dir.path().join("music");
1841 std::fs::create_dir_all(&music_dir).unwrap();
1842
1843 let wav_path = music_dir.join("modified.wav");
1844 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1845
1846 let db = test_db(dir.path());
1847
1848 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1850 assert_eq!(r1.added, 1);
1851
1852 std::thread::sleep(std::time::Duration::from_millis(1100));
1855 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
1856
1857 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1859 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
1860 assert_eq!(r2.added, 0, "the row already exists");
1861 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
1862 }
1863
1864 #[test]
1865 fn a_drop_and_a_scan_spell_a_file_the_same_way() {
1866 use unicode_normalization::UnicodeNormalization;
1867 let dir = tempfile::tempdir().unwrap();
1868 let nfd: String = "Roman Flügel".nfd().collect();
1869 let nfc: String = "Roman Flügel".nfc().collect();
1870 let music_dir = dir.path().join(&nfd);
1871 std::fs::create_dir_all(&music_dir).unwrap();
1872 test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
1873 if !dir.path().join(&nfc).exists() {
1876 return;
1877 }
1878 let db = test_db(dir.path());
1879
1880 let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
1882 assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
1883
1884 let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1886 assert_eq!(scanned.added, 0, "the same file, not a second one");
1887
1888 let stats = queries::library_stats(&db.conn).unwrap();
1889 assert_eq!(stats.total_tracks, 1);
1890 let stored: String = db
1891 .conn
1892 .query_row("SELECT path FROM tracks", [], |r| r.get(0))
1893 .unwrap();
1894 assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
1895 }
1896}