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 pub incomplete: bool,
43 arrived: Vec<i64>,
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct ScanOptions {
51 pub force: bool,
53 pub cancel: Option<Arc<AtomicBool>>,
59 pub force_remove: bool,
64}
65
66#[cfg(not(test))]
70const CHUNK_SIZE: usize = 1000;
71#[cfg(test)]
72const CHUNK_SIZE: usize = 4;
73
74pub struct ScanEvent<'a> {
76 pub artist: &'a str,
77 pub album: &'a str,
78 pub title: &'a str,
79 pub path: &'a Path,
80 pub is_new: bool,
81}
82
83pub fn scan_folder(
86 db: &Database,
87 path: &Path,
88 opts: ScanOptions,
89 on_track: Option<&dyn Fn(ScanEvent)>,
90) -> ScanResult {
91 scan_folders(
92 db,
93 std::slice::from_ref(&path.to_path_buf()),
94 opts,
95 None,
96 on_track,
97 )
98}
99
100pub fn scan_folders(
105 db: &Database,
106 folders: &[PathBuf],
107 mut opts: ScanOptions,
108 on_started: Option<&dyn Fn(u64)>,
109 on_track: Option<&dyn Fn(ScanEvent)>,
110) -> ScanResult {
111 let _turn = super::lane::enter(folders, &mut opts);
112 scan_folders_in_lane(db, folders, &opts, on_started, on_track)
113}
114
115fn scan_folders_in_lane(
116 db: &Database,
117 folders: &[PathBuf],
118 opts: &ScanOptions,
119 on_started: Option<&dyn Fn(u64)>,
120 on_track: Option<&dyn Fn(ScanEvent)>,
121) -> ScanResult {
122 let walked: Vec<(PathBuf, Walked, ScanResult)> = folders
123 .iter()
124 .map(|folder| {
125 let path = library_folder(db, folder);
129 let mut result = ScanResult::default();
130 let found = walk(&path, &mut result);
131 (path, found, result)
132 })
133 .collect();
134 if let Some(started) = on_started {
135 started(
136 walked
137 .iter()
138 .map(|(_, found, _)| found.audio.len() as u64)
139 .sum(),
140 );
141 }
142
143 let mut indexed = Vec::new();
147 let (mut cancelled, mut stopped) = (false, false);
148 for (path, found, mut result) in walked {
149 let seen = stored_paths(&found.audio);
150 if cancelled || stopped {
151 result.cancelled = cancelled;
154 result.incomplete = stopped;
155 let prune = !found.audio.is_empty();
156 indexed.push((path, Vec::new(), Vec::new(), prune, seen, result));
158 continue;
159 }
160 let settled = if found.audio.is_empty() || result.unreadable > 0 {
163 Vec::new()
164 } else {
165 vec![path.clone()]
166 };
167 let prune = index_folder(db, &path, found.audio, opts, on_track, &mut result);
168 cancelled |= result.cancelled;
169 stopped |= result.incomplete;
172 indexed.push((path, found.playlists, settled, prune, seen, result));
173 }
174
175 let arrived: Vec<i64> = indexed
176 .iter()
177 .flat_map(|(.., result)| result.arrived.iter().copied())
178 .collect();
179 let incomplete = indexed.iter().any(|(.., result)| result.incomplete);
180 let mut total = ScanResult::default();
181 for (path, playlists, settled, prune, seen, mut result) in indexed {
182 if prune {
188 remove_stale(
189 db,
190 &path,
191 opts.force_remove,
192 !cancelled && !incomplete,
193 &arrived,
194 &seen,
195 &mut result,
196 );
197 }
198 if !result.cancelled {
199 result.playlists += super::playlist_files::import(db, &playlists, &settled);
200 }
201 merge(&mut total, result);
202 }
203 total.added = total.added.saturating_sub(total.moved);
205 if total.added > 0 {
206 super::playlist_files::refresh_m3u(db);
207 }
208 total
209}
210
211fn index_folder(
214 db: &Database,
215 path: &Path,
216 audio_files: Vec<PathBuf>,
217 opts: &ScanOptions,
218 on_track: Option<&dyn Fn(ScanEvent)>,
219 result: &mut ScanResult,
220) -> bool {
221 let total_files = audio_files.len();
222 log::info!("found {} audio files in {}", total_files, path.display());
223
224 let scope = [path.to_path_buf()];
225 index_files(db, audio_files, &scope, opts, on_track, result, path);
226
227 if total_files == 0 {
231 log::error!(
232 "{} contains no audio files — skipping stale-track removal. \
233 If this folder should have music in it, it is probably not mounted or not readable.",
234 path.display()
235 );
236 return false;
237 }
238 true
239}
240
241pub fn scan_dirs(
258 db: &Database,
259 library: &[PathBuf],
260 dirs: &[PathBuf],
261 mut opts: ScanOptions,
262 on_track: Option<&dyn Fn(ScanEvent)>,
263) -> ScanResult {
264 let _turn = super::lane::enter(dirs, &mut opts);
265 let mut result = ScanResult::default();
266 let mut spelling = super::spelling::Spelling::default();
267 let library: Vec<PathBuf> = library.iter().map(|f| library_folder(db, f)).collect();
268 let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
269
270 let mut files = Vec::new();
271 let mut playlists = Vec::new();
272 let mut walked = Vec::new();
273 let mut settled = Vec::new();
274 for dir in minimal_dirs(dirs) {
275 let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
276 log::warn!("not in a library folder, not scanning: {}", dir.display());
277 continue;
278 };
279 if dir == *root {
280 merge(
281 &mut result,
282 scan_folders_in_lane(db, std::slice::from_ref(root), &opts, None, on_track),
283 );
284 continue;
285 }
286 if !is_populated(root) {
287 log::warn!(
288 "{} is empty or unreadable — not scanning {}",
289 root.display(),
290 dir.display()
291 );
292 continue;
293 }
294 match dir.try_exists() {
295 Ok(true) => {
296 let before = result.unreadable;
297 let found = walk(&dir, &mut result);
298 files.extend(found.audio);
299 playlists.extend(found.playlists);
300 walked.push(dir.clone());
301 if result.unreadable == before {
302 settled.push(dir);
303 }
304 }
305 Ok(false) if super::known_missing(&dir) => settled.push(dir),
306 Ok(false) => log::warn!(
307 "{} is behind a link that leads nowhere — not scanning it",
308 dir.display()
309 ),
310 Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
311 }
312 }
313
314 let seen = stored_paths(&files);
315 index_files(
316 db,
317 files,
318 &walked,
319 &opts,
320 on_track,
321 &mut result,
322 Path::new(""),
323 );
324 let arrived = std::mem::take(&mut result.arrived);
325 let remove = !result.incomplete && !result.cancelled;
326 let counted = result.moved;
328 for dir in &settled {
329 remove_stale(db, dir, true, remove, &arrived, &seen, &mut result);
330 }
331 result.added = result.added.saturating_sub(result.moved - counted);
332 if result.cancelled {
333 return result;
334 }
335 for dir in &settled {
336 if let Err(e) = queries::evict_art_under(&db.conn, dir) {
338 log::warn!("cover art under {} not refreshed: {e}", dir.display());
339 }
340 }
341 result.playlists += super::playlist_files::import(db, &playlists, &settled);
342 if result.added > 0 {
343 super::playlist_files::refresh_m3u(db);
344 }
345 result
346}
347
348fn stored_paths(files: &[PathBuf]) -> HashSet<String> {
350 files
351 .iter()
352 .map(|f| f.to_string_lossy().into_owned())
353 .collect()
354}
355
356pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
359 dirs.sort();
361 dirs.dedup();
362 let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
363 for dir in dirs {
364 if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
365 kept.push(dir);
366 }
367 }
368 kept
369}
370
371fn library_folder(db: &Database, folder: &Path) -> PathBuf {
375 let path = super::spelling::on_disk(folder);
376 if path != folder {
377 let respelled = queries::write_transaction(&db.conn)
378 .map_err(crate::db::connection::DbError::from)
379 .and_then(|tx| {
380 queries::sources::respell_folder(&tx, folder, &path)?;
381 Ok(tx.commit()?)
382 });
383 if let Err(e) = respelled {
384 log::warn!(
385 "could not move {} to {}: {e}",
386 folder.display(),
387 path.display()
388 );
389 }
390 }
391 path
392}
393
394fn is_populated(root: &Path) -> bool {
396 std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
397}
398
399fn merge(total: &mut ScanResult, r: ScanResult) {
400 total.cancelled |= r.cancelled;
401 total.incomplete |= r.incomplete;
402 total.added += r.added;
403 total.updated += r.updated;
404 total.removed += r.removed;
405 total.moved += r.moved;
406 total.skipped += r.skipped;
407 total.unreadable += r.unreadable;
408 total.playlists += r.playlists;
409 total.removed_paths.extend(r.removed_paths);
410 total.errors.extend(r.errors);
411}
412
413#[derive(Default)]
415struct Walked {
416 audio: Vec<PathBuf>,
417 playlists: Vec<PathBuf>,
419}
420
421fn walk(path: &Path, result: &mut ScanResult) -> Walked {
430 let mut found = Walked::default();
431 let entries = walkdir::WalkDir::new(path)
432 .follow_links(true)
433 .into_iter()
434 .filter_entry(|e| e.depth() == 0 || !super::watch::is_ignored(e.file_name()));
435 for entry in entries {
436 match entry {
437 Ok(e) if e.path().to_str().is_none() => {
438 if e.file_type().is_file() {
439 log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
440 }
441 }
442 Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
443 found.audio.push(e.path().to_path_buf())
444 }
445 Ok(e) if e.file_type().is_file() && is_playlist_file(e.path()) => {
446 found.playlists.push(e.path().to_path_buf())
447 }
448 Ok(_) => {}
449 Err(e) => {
450 result.unreadable += 1;
451 log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
452 }
453 }
454 }
455 found
456}
457
458fn index_files(
466 db: &Database,
467 mut audio_files: Vec<PathBuf>,
468 scope: &[PathBuf],
469 opts: &ScanOptions,
470 on_track: Option<&dyn Fn(ScanEvent)>,
471 result: &mut ScanResult,
472 context: &Path,
473) {
474 let total_files = audio_files.len();
475 if total_files == 0 {
476 return;
477 }
478
479 let files_to_scan: Vec<PathBuf> = if opts.force {
481 std::mem::take(&mut audio_files)
482 } else {
483 let scan_cache = queries::load_scan_cache(&db.conn, scope).unwrap_or_default();
484 audio_files
486 .par_iter()
487 .filter(|file_path| {
488 let Ok(file_meta) = std::fs::metadata(file_path) else {
489 return true;
490 };
491 let mtime = file_meta
492 .modified()
493 .ok()
494 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
495 .map(|d| d.as_secs() as i64)
496 .unwrap_or(0);
497 let size = file_meta.len() as i64;
498 let path_str = file_path.to_string_lossy();
499 match scan_cache.get(path_str.as_ref()) {
500 Some(&(cached_mtime, cached_size)) => {
501 mtime != cached_mtime || size != cached_size
502 }
503 None => true,
504 }
505 })
506 .cloned()
507 .collect()
508 };
509
510 result.skipped += total_files - files_to_scan.len();
511
512 let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
524 CHUNK_SIZE.saturating_mul(2),
525 );
526 let cancel = opts.cancel.clone();
527 let reader = std::thread::Builder::new()
528 .name("koan-scan-read".into())
529 .spawn(move || {
530 files_to_scan
533 .par_iter()
534 .try_for_each(|file_path| {
535 if cancel
536 .as_ref()
537 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
538 {
539 return Err(());
540 }
541 send.send((
542 file_path.clone(),
543 isolate_read(file_path, metadata::read_metadata),
544 ))
545 .map_err(|_| ())
546 })
547 .is_err()
548 });
549 if let Err(e) = &reader {
550 log::error!("failed to spawn scan reader: {}", e);
551 result
552 .errors
553 .push((context.to_path_buf(), format!("scan error: {}", e)));
554 result.incomplete = true;
555 return;
556 }
557
558 let mut stopped = false;
560 loop {
561 let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
563 recv.iter().take(CHUNK_SIZE).collect();
564 if batch.is_empty() {
565 break;
566 }
567 if opts
568 .cancel
569 .as_ref()
570 .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
571 {
572 log::info!("scan cancelled — keeping what was already committed");
573 result.cancelled = true;
574 break;
575 }
576
577 let tx = match crate::db::queries::write_transaction(&db.conn) {
578 Ok(tx) => tx,
579 Err(e) => {
580 log::error!("failed to begin scan transaction: {}", e);
581 result
582 .errors
583 .push((context.to_path_buf(), format!("db error: {}", e)));
584 result.incomplete = true;
585 stopped = true;
586 break;
587 }
588 };
589
590 let (mut added, mut updated) = (0usize, 0usize);
591 let mut arrived = Vec::new();
592 let mut stored = Vec::new();
594 for (file_path, meta_result) in batch {
595 match meta_result {
596 Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
597 Ok((track_id, is_new)) => {
598 if is_new {
599 added += 1;
600 arrived.push(track_id);
601 } else {
602 updated += 1;
603 }
604 if let Err(e) = queries::update_scan_cache(
605 &tx,
606 meta.path.as_deref().unwrap_or(""),
607 meta.mtime.unwrap_or(0),
608 meta.size_bytes.unwrap_or(0),
609 track_id,
610 ) {
611 log::warn!("failed to cache {}: {}", file_path.display(), e);
613 }
614 if on_track.is_some() {
615 stored.push((file_path, meta, is_new));
616 }
617 }
618 Err(e) => {
619 result.errors.push((file_path, format!("db error: {}", e)));
620 }
621 },
622 Err(e) => {
623 result.errors.push((file_path, e));
624 }
625 }
626 }
627
628 match tx.commit() {
629 Ok(()) => {
630 result.added += added;
631 result.updated += updated;
632 result.arrived.extend(arrived);
633 if let Some(cb) = &on_track {
634 for (file_path, meta, is_new) in &stored {
635 cb(ScanEvent {
636 artist: &meta.artist,
637 album: &meta.album,
638 title: &meta.title,
639 path: file_path,
640 is_new: *is_new,
641 });
642 }
643 }
644 }
645 Err(e) => {
646 log::error!("failed to commit scan transaction: {}", e);
647 result
648 .errors
649 .push((context.to_path_buf(), format!("db error: {}", e)));
650 result.incomplete = true;
651 stopped = true;
652 break;
653 }
654 }
655 }
656
657 drop(recv);
660 if let Ok(handle) = reader {
661 match handle.join() {
662 Ok(true) if !stopped => result.cancelled = true,
669 Ok(_) => {}
670 Err(_) => {
671 log::error!("scan reader thread panicked");
672 result.incomplete = true;
673 }
674 }
675 }
676}
677
678fn remove_stale(
687 db: &Database,
688 path: &Path,
689 force_remove: bool,
690 remove: bool,
691 arrived: &[i64],
692 walked: &HashSet<String>,
693 result: &mut ScanResult,
694) {
695 if !remove && arrived.is_empty() {
696 return;
697 }
698 let tx = match crate::db::queries::write_transaction(&db.conn) {
699 Ok(tx) => tx,
700 Err(e) => {
701 log::error!("failed to begin stale-removal transaction: {}", e);
702 result
703 .errors
704 .push((path.to_path_buf(), format!("db error: {}", e)));
705 return;
706 }
707 };
708 let moved = match queries::adopt_moved_files(&tx, path, arrived, Some(walked)) {
709 Ok(moved) => moved,
710 Err(e) => {
711 log::error!("failed to match moved files: {}", e);
712 result.errors.push((path.to_path_buf(), e.to_string()));
713 return;
714 }
715 };
716 result.moved += moved;
717 if !remove {
718 if let Err(e) = tx.commit() {
719 log::error!("failed to commit moved files: {}", e);
720 result
721 .errors
722 .push((path.to_path_buf(), format!("db error: {}", e)));
723 result.moved -= moved;
724 }
725 return;
726 }
727 match queries::remove_stale_tracks_walked(&tx, path, force_remove, Some(walked)) {
728 Ok(removed) => {
729 if let Err(e) = tx.commit() {
730 log::error!("failed to commit stale removals: {}", e);
731 result
732 .errors
733 .push((path.to_path_buf(), format!("db error: {}", e)));
734 result.moved -= moved;
735 return;
736 }
737 result.removed += removed.len();
738 result.removed_paths.extend(removed);
739 }
740 Err(e) => {
741 log::error!("failed to remove stale tracks: {}", e);
742 result.errors.push((path.to_path_buf(), e.to_string()));
743 if matches!(e, crate::db::connection::DbError::UnsafeBulkDelete(_))
746 && let Err(e) = tx.commit()
747 {
748 log::error!("failed to commit moved files: {}", e);
749 result
750 .errors
751 .push((path.to_path_buf(), format!("db error: {}", e)));
752 result.moved -= moved;
753 }
754 }
755 }
756}
757
758fn isolate_read(
763 path: &Path,
764 read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
765) -> Result<TrackMeta, String> {
766 match catch_unwind(AssertUnwindSafe(|| read(path))) {
767 Ok(result) => result.map_err(|e| e.to_string()),
768 Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
769 }
770}
771
772#[derive(Debug, Default)]
774pub struct ImportResult {
775 pub track_ids: Vec<i64>,
778 pub added: usize,
779 pub updated: usize,
780 pub errors: Vec<(PathBuf, String)>,
781}
782
783pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
794 let mut result = ImportResult::default();
795
796 let mut files: Vec<PathBuf> = Vec::new();
797 let mut scope = Vec::new();
798 let mut seen = std::collections::HashSet::new();
799 let mut spelling = super::spelling::Spelling::default();
803 for path in paths {
804 let path = spelling.on_disk(path);
805 let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
806 .follow_links(true)
807 .into_iter()
808 .filter_map(Result::ok)
809 .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
810 .filter(|e| {
811 let utf8 = e.path().to_str().is_some();
812 if !utf8 {
813 log::warn!("skipping a path that is not UTF-8: {}", e.path().display());
814 }
815 utf8
816 })
817 .map(|e| e.path().to_path_buf())
818 .collect();
819 found.sort();
820 scope.push(path);
821 files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
823 }
824
825 if files.is_empty() {
826 return result;
827 }
828
829 let _importing = super::lane::importing();
832 let mut scanned = ScanResult::default();
833 index_files(
834 db,
835 files.clone(),
836 &scope,
837 &ScanOptions::default(),
838 None,
839 &mut scanned,
840 Path::new(""),
841 );
842 result.added = scanned.added;
843 result.updated = scanned.updated;
844 result.errors = scanned.errors;
845
846 let mut track_of = match db
849 .conn
850 .prepare_cached("SELECT track_id FROM local_files WHERE path = ?1")
851 {
852 Ok(stmt) => stmt,
853 Err(e) => {
854 result
855 .errors
856 .push((PathBuf::new(), format!("db error: {e}")));
857 return result;
858 }
859 };
860 for file in &files {
861 if let Ok(id) = track_of.query_row([file.to_string_lossy()], |r| r.get::<_, i64>(0)) {
862 result.track_ids.push(id);
863 }
864 }
865 drop(track_of);
866
867 if result.added > 0 {
868 super::playlist_files::refresh_m3u(db);
869 }
870 result
871}
872
873pub fn full_scan(
875 db: &Database,
876 folders: &[PathBuf],
877 mut opts: ScanOptions,
878 on_track: Option<&dyn Fn(ScanEvent)>,
879) -> ScanResult {
880 let _turn = super::lane::enter(folders, &mut opts);
881 let existing: Vec<PathBuf> = folders
882 .iter()
883 .filter(|folder| {
884 let exists = folder.exists();
885 if !exists {
886 log::warn!("library folder does not exist: {}", folder.display());
887 }
888 exists
889 })
890 .cloned()
891 .collect();
892 let total = scan_folders_in_lane(db, &existing, &opts, None, on_track);
893 db.optimize();
894 total
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::db::connection::Database;
901 use crate::db::queries;
902 use crate::test_utils;
903
904 fn fail_commits_for(db: &Database, pattern: &str) {
908 db.conn
909 .execute_batch(&format!(
910 "CREATE TABLE trap_parent (id INTEGER PRIMARY KEY);
911 CREATE TABLE trap (parent INTEGER
912 REFERENCES trap_parent(id) DEFERRABLE INITIALLY DEFERRED);
913 CREATE TRIGGER spring AFTER INSERT ON scan_cache
914 WHEN NEW.path LIKE '{pattern}'
915 BEGIN INSERT INTO trap VALUES (1); END;"
916 ))
917 .unwrap();
918 }
919
920 fn test_db(dir: &Path) -> Database {
921 let db_path = dir.join("test.db");
922 Database::open(&db_path).unwrap()
923 }
924
925 #[test]
928 fn a_failed_commit_prunes_no_folder() {
929 let dir = tempfile::tempdir().unwrap();
930 let (a, b) = (dir.path().join("a"), dir.path().join("b"));
931 std::fs::create_dir_all(&a).unwrap();
932 std::fs::create_dir_all(&b).unwrap();
933 test_utils::generate_wav(&a.join("kept.wav"), 8000, 1, 0.1, 16);
934 test_utils::generate_wav(&b.join("moved.wav"), 8000, 1, 0.1, 16);
935 test_utils::generate_wav(&b.join("stays.wav"), 8000, 1, 0.1, 16);
937 let db = test_db(dir.path());
938 let folders = [a.clone(), b.clone()];
939 let first = scan_folders(&db, &folders, ScanOptions::default(), None, None);
940 assert_eq!(first.added, 3);
941
942 std::fs::rename(b.join("moved.wav"), a.join("moved.wav")).unwrap();
943 fail_commits_for(&db, "%moved.wav");
945
946 let events = std::sync::Mutex::new(Vec::new());
947 let on_track = |e: ScanEvent| events.lock().unwrap().push(e.path.to_path_buf());
948 let second = scan_folders(&db, &folders, ScanOptions::default(), None, Some(&on_track));
949
950 assert!(second.incomplete);
951 assert!(!second.errors.is_empty());
952 assert_eq!(second.removed, 0, "pruned against a lost batch");
953 let tracks: i64 = db
954 .conn
955 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
956 .unwrap();
957 assert_eq!(tracks, 3);
958 assert!(
959 !events
960 .lock()
961 .unwrap()
962 .iter()
963 .any(|p| p.ends_with("moved.wav")),
964 "a rolled-back track was reported"
965 );
966 }
967
968 #[test]
971 fn an_incomplete_scan_still_adopts_moves() {
972 let dir = tempfile::tempdir().unwrap();
973 let (one, two, three) = (
974 dir.path().join("one"),
975 dir.path().join("two"),
976 dir.path().join("three"),
977 );
978 std::fs::create_dir_all(one.join("Album")).unwrap();
979 std::fs::create_dir_all(&two).unwrap();
980 std::fs::create_dir_all(&three).unwrap();
981 test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
982 test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
983 test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
984 test_utils::generate_wav(&three.join("d.wav"), 44100, 1, 0.5, 16);
985 let db = test_db(dir.path());
986 let folders = [one.clone(), two.clone(), three.clone()];
987 scan_folders(&db, &folders, ScanOptions::default(), None, None);
988 let before = tracks_with_uids(&db);
989
990 std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
991 test_utils::generate_wav(&three.join("fail.wav"), 44100, 1, 0.6, 16);
993 fail_commits_for(&db, "%fail.wav");
994 let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
995
996 assert!(r.incomplete);
997 assert_eq!((r.removed, r.moved), (0, 1), "{:?}", r.errors);
998 let after = tracks_with_uids(&db);
999 assert_eq!(
1000 before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1001 after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1002 );
1003 assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1004 }
1005
1006 #[test]
1009 fn a_failed_transaction_joins_the_readers() {
1010 let dir = tempfile::tempdir().unwrap();
1011 let music = dir.path().join("music");
1012 std::fs::create_dir_all(&music).unwrap();
1013 let files: Vec<PathBuf> = (0..CHUNK_SIZE * 12)
1015 .map(|i| {
1016 let path = music.join(format!("{i:03}.wav"));
1017 test_utils::generate_wav(&path, 8000, 1, 0.01, 16);
1018 path
1019 })
1020 .collect();
1021 let db = test_db(dir.path());
1022 db.conn.execute_batch("BEGIN").unwrap();
1024
1025 let cancel = Arc::new(AtomicBool::new(false));
1027 let opts = ScanOptions {
1028 cancel: Some(cancel.clone()),
1029 force: true,
1030 ..Default::default()
1031 };
1032 let mut result = ScanResult::default();
1033 index_files(
1034 &db,
1035 files,
1036 std::slice::from_ref(&music),
1037 &opts,
1038 None,
1039 &mut result,
1040 &music,
1041 );
1042 drop(opts);
1043
1044 assert!(result.incomplete);
1045 assert!(!result.cancelled);
1046 assert_eq!(result.errors.len(), 1);
1047 assert_eq!(
1048 Arc::strong_count(&cancel),
1049 1,
1050 "returned before the reader ended"
1051 );
1052 }
1053
1054 #[test]
1055 fn cancelling_a_scan_returns_with_readers_still_pending() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let music_dir = dir.path().join("music");
1058 std::fs::create_dir_all(&music_dir).unwrap();
1059 for i in 0..CHUNK_SIZE * 12 {
1062 test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1063 }
1064
1065 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1066 let root = dir.path().to_path_buf();
1067 std::thread::spawn(move || {
1068 let db = test_db(&root);
1069 let cancel = Arc::new(AtomicBool::new(false));
1070 let opts = ScanOptions {
1071 cancel: Some(cancel.clone()),
1072 ..Default::default()
1073 };
1074 let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
1075 let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
1076 done_tx.send(result).unwrap();
1077 });
1078
1079 let result = done_rx
1080 .recv_timeout(std::time::Duration::from_secs(30))
1081 .expect("cancelled scan never returned");
1082 assert!(result.cancelled);
1083 assert!(result.added < CHUNK_SIZE * 12);
1084 }
1085
1086 #[test]
1089 fn a_scan_started_without_a_cancel_flag_can_still_be_cancelled() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let music = dir.path().join("music");
1092 std::fs::create_dir_all(&music).unwrap();
1093 for i in 0..CHUNK_SIZE * 6 {
1094 test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1095 }
1096 let db = test_db(dir.path());
1097 let on_track = |_: ScanEvent| super::super::lane::cancel_all();
1098 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1099 assert!(result.cancelled);
1100 assert!(result.added < CHUNK_SIZE * 6);
1101 }
1102
1103 #[test]
1106 fn a_scan_cancelled_before_any_file_is_read_is_cancelled() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let music = dir.path().join("music");
1109 std::fs::create_dir_all(&music).unwrap();
1110 for i in 0..CHUNK_SIZE * 2 {
1111 test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1112 }
1113 let db = test_db(dir.path());
1114 let opts = ScanOptions {
1115 cancel: Some(Arc::new(AtomicBool::new(true))),
1116 ..Default::default()
1117 };
1118 let result = scan_folder(&db, &music, opts, None);
1119 assert!(result.cancelled);
1120 assert_eq!(result.added, 0);
1121 }
1122
1123 #[test]
1125 fn a_scan_is_cancelled_by_forgetting_its_folder_only() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let music = dir.path().join("music");
1128 std::fs::create_dir_all(&music).unwrap();
1129 for i in 0..CHUNK_SIZE * 6 {
1130 test_utils::generate_wav(&music.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
1131 }
1132 let elsewhere = dir.path().join("elsewhere");
1133 let db = test_db(dir.path());
1134
1135 let on_track = |_: ScanEvent| super::super::lane::cancel_under(&elsewhere);
1136 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1137 assert!(!result.cancelled);
1138 assert_eq!(result.added, CHUNK_SIZE * 6);
1139
1140 let album = music.join("Album");
1141 std::fs::create_dir_all(&album).unwrap();
1142 for i in 0..CHUNK_SIZE * 6 {
1143 test_utils::generate_wav(&album.join(format!("{i:03}.wav")), 8000, 1, 0.02, 16);
1144 }
1145 let on_track = |_: ScanEvent| super::super::lane::cancel_under(&album);
1146 let result = scan_folder(&db, &music, ScanOptions::default(), Some(&on_track));
1147 assert!(result.cancelled, "a scan of the folder it is in");
1148 }
1149
1150 #[test]
1153 fn scans_wait_their_turn() {
1154 let dir = tempfile::tempdir().unwrap();
1155 let music = dir.path().join("music");
1156 std::fs::create_dir_all(&music).unwrap();
1157 test_utils::generate_wav(&music.join("a.wav"), 8000, 1, 0.1, 16);
1158 let db_root = dir.path().to_path_buf();
1159
1160 let held = super::super::lane::wait();
1161 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1162 std::thread::spawn(move || {
1163 let db = test_db(&db_root);
1164 done_tx
1165 .send(scan_folder(&db, &music, ScanOptions::default(), None).added)
1166 .unwrap();
1167 });
1168 assert!(
1169 done_rx
1170 .recv_timeout(std::time::Duration::from_millis(300))
1171 .is_err(),
1172 "ran while the lane was held"
1173 );
1174 drop(held);
1175 let added = done_rx
1176 .recv_timeout(std::time::Duration::from_secs(30))
1177 .expect("never ran once the lane was free");
1178 assert_eq!(added, 1);
1179 }
1180
1181 #[test]
1184 fn an_import_does_not_wait_for_a_scan() {
1185 let dir = tempfile::tempdir().unwrap();
1186 let rip = dir.path().join("rip");
1187 std::fs::create_dir_all(&rip).unwrap();
1188 test_utils::generate_wav(&rip.join("a.wav"), 8000, 1, 0.1, 16);
1189 let db_root = dir.path().to_path_buf();
1190
1191 let mut opts = ScanOptions::default();
1192 let held = super::super::lane::enter(&[dir.path().join("library")], &mut opts);
1193 let (done_tx, done_rx) = crossbeam_channel::bounded(1);
1194 std::thread::spawn(move || {
1195 let db = test_db(&db_root);
1196 done_tx
1197 .send(
1198 import_paths(&db, std::slice::from_ref(&rip))
1199 .track_ids
1200 .len(),
1201 )
1202 .unwrap();
1203 });
1204 let imported = done_rx
1205 .recv_timeout(std::time::Duration::from_secs(30))
1206 .expect("waited for the scan");
1207 assert_eq!(imported, 1);
1208 drop(held);
1209 }
1210
1211 #[test]
1212 fn scan_folder_indexes_new_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("silence.wav");
1219 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1220
1221 let db = test_db(dir.path());
1222 let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1223
1224 assert_eq!(result.added, 1, "expected 1 track added");
1225 assert_eq!(result.skipped, 0);
1226 assert_eq!(result.removed, 0);
1227 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1228
1229 let stats = queries::library_stats(&db.conn).unwrap();
1231 assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
1232 }
1233
1234 #[test]
1235 fn a_renamed_file_keeps_its_track() {
1236 let dir = tempfile::tempdir().unwrap();
1237 let music = dir.path().join("music");
1238 let album = music.join("Squire Of Gothos").join("Album");
1239 std::fs::create_dir_all(&album).unwrap();
1240 for i in 1..=3 {
1241 test_utils::generate_wav(
1242 &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1243 44100,
1244 1,
1245 0.5 + i as f32 * 0.1,
1246 16,
1247 );
1248 }
1249 let db = test_db(dir.path());
1250 scan_folder(&db, &music, ScanOptions::default(), None);
1251 let before = tracks_with_uids(&db);
1252 let first = before[0].0;
1253 queries::record_play(&db.conn, queries::LOCAL_USER, first, Some(1000)).unwrap();
1254 queries::add_favourite(&db.conn, queries::LOCAL_USER, first).unwrap();
1255
1256 let moved = music.join("The Squire of Gothos").join("Album");
1259 std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
1260 std::fs::rename(&album, &moved).unwrap();
1261 std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
1262 for i in 1..=3 {
1263 std::fs::rename(
1264 moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
1265 moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
1266 )
1267 .unwrap();
1268 }
1269 let result = scan_folder(&db, &music, ScanOptions::default(), None);
1270
1271 assert_eq!(
1272 (result.added, result.removed, result.moved),
1273 (0, 0, 3),
1274 "{:?}",
1275 result.errors
1276 );
1277 let paths = track_paths(&db);
1278 assert_eq!(paths.len(), 3, "{paths:#?}");
1279 assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1280 let after = tracks_with_uids(&db);
1281 assert_eq!(
1282 before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1283 after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1284 "same rows, same uids"
1285 );
1286 let kept: (i64, i64) = db
1287 .conn
1288 .query_row(
1289 "SELECT (SELECT COUNT(*) FROM play_history WHERE track_id = ?1),
1290 (SELECT COUNT(*) FROM favourites WHERE track_id = ?1)",
1291 [first],
1292 |r| Ok((r.get(0)?, r.get(1)?)),
1293 )
1294 .unwrap();
1295 assert_eq!(kept, (1, 1), "history and favourite kept");
1296 }
1297
1298 #[cfg(target_os = "macos")]
1305 #[test]
1306 fn a_file_stored_under_two_spellings_is_folded_into_one_track() {
1307 use unicode_normalization::UnicodeNormalization;
1308 let dir = tempfile::tempdir().unwrap();
1309 let music = dir.path().join("music");
1310 let nfd: String = "Björk".nfd().collect();
1311 let nfc: String = "Björk".nfc().collect();
1312 std::fs::create_dir_all(music.join(&nfd)).unwrap();
1313 test_utils::generate_wav(&music.join(&nfd).join("Jóga.wav"), 44100, 1, 0.5, 16);
1314 let on_disk = music
1315 .join(&nfd)
1316 .join("Jóga.wav")
1317 .to_string_lossy()
1318 .into_owned();
1319 let organized = music
1320 .join(&nfc)
1321 .join("Jóga.wav")
1322 .to_string_lossy()
1323 .into_owned();
1324 let db = test_db(dir.path());
1325 scan_folder(&db, &music, ScanOptions::default(), None);
1326 let original = tracks_with_uids(&db)[0].0;
1327 queries::record_play(&db.conn, queries::LOCAL_USER, original, Some(1000)).unwrap();
1328 queries::sources::rename_file(&db.conn, &on_disk, &organized).unwrap();
1331 for table in ["tracks", "scan_cache"] {
1332 db.conn
1333 .execute(
1334 &format!("UPDATE {table} SET path = ?1 WHERE path = ?2"),
1335 [&organized, &on_disk],
1336 )
1337 .unwrap();
1338 }
1339 scan_folder(&db, &music, ScanOptions::default(), None);
1341 scan_folder(&db, &music, ScanOptions::default(), None);
1342
1343 let tracks = tracks_with_uids(&db);
1344 assert_eq!(tracks.len(), 1, "{tracks:?}");
1345 assert_eq!(tracks[0].0, original);
1346 assert_eq!(track_paths(&db), vec![on_disk]);
1347 let plays: i64 = db
1348 .conn
1349 .query_row(
1350 "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
1351 [original],
1352 |r| r.get(0),
1353 )
1354 .unwrap();
1355 assert_eq!(plays, 1);
1356 }
1357
1358 #[cfg(target_os = "macos")]
1363 #[test]
1364 fn rows_stored_under_a_library_folder_in_another_case_move_to_the_disks() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let music = dir.path().join("Music");
1367 let given = dir.path().join("music");
1368 std::fs::create_dir_all(music.join("Artist")).unwrap();
1369 if !given.exists() {
1370 return;
1371 }
1372 for name in ["a.wav", "b.wav"] {
1373 test_utils::generate_wav(&music.join("Artist").join(name), 44100, 1, 0.2, 16);
1374 }
1375 let db = test_db(dir.path());
1376 scan_folder(&db, &music, ScanOptions::default(), None);
1377 let original: Vec<i64> = tracks_with_uids(&db).iter().map(|t| t.0).collect();
1378 let (from, to) = (
1380 format!("{}/", music.display()),
1381 format!("{}/", given.display()),
1382 );
1383 for (table, column) in [
1384 ("tracks", "path"),
1385 ("local_files", "path"),
1386 ("scan_cache", "path"),
1387 ] {
1388 db.conn
1389 .execute(
1390 &format!("UPDATE {table} SET {column} = replace({column}, ?1, ?2)"),
1391 [&from, &to],
1392 )
1393 .unwrap();
1394 }
1395 let twin = music.join("Artist/b.wav");
1397 import_paths(&db, std::slice::from_ref(&twin));
1398 assert_eq!(tracks_with_uids(&db).len(), 3);
1399
1400 scan_folder(&db, &given, ScanOptions::default(), None);
1401
1402 let tracks = tracks_with_uids(&db);
1403 assert_eq!(
1404 tracks.iter().map(|t| t.0).collect::<Vec<_>>(),
1405 original,
1406 "{tracks:?}"
1407 );
1408 assert!(
1409 track_paths(&db).iter().all(|p| p.starts_with(&from)),
1410 "{:?}",
1411 track_paths(&db)
1412 );
1413 let stale: i64 = db
1414 .conn
1415 .query_row(
1416 "SELECT (SELECT COUNT(*) FROM local_files WHERE substr(path, 1, length(?1)) = ?1)
1417 + (SELECT COUNT(*) FROM scan_cache WHERE substr(path, 1, length(?1)) = ?1)",
1418 [&to],
1419 |r| r.get(0),
1420 )
1421 .unwrap();
1422 assert_eq!(stale, 0);
1423 }
1424
1425 #[cfg(target_os = "macos")]
1430 #[test]
1431 fn respelling_a_folder_leaves_nothing_under_the_old_spelling() {
1432 let dir = tempfile::tempdir().unwrap();
1433 let music = dir.path().join("Music");
1434 let given = dir.path().join("music");
1435 std::fs::create_dir_all(&music).unwrap();
1436 if !given.exists() {
1437 return;
1438 }
1439 let file = music.join("b.wav");
1440 test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1441 let db = test_db(dir.path());
1442 let mut tracks = Vec::new();
1443 for (root, remote) in [(&given, "sub-1"), (&music, "sub-2")] {
1444 let mut meta = metadata::read_metadata(&file).unwrap();
1445 meta.path = Some(root.join("b.wav").to_string_lossy().into_owned());
1446 meta.remote_id = Some(remote.into());
1447 tracks.push(queries::upsert_track(&db.conn, &meta).unwrap());
1448 }
1449 let local_files = |db: &Database| -> Vec<(i64, String)> {
1450 db.conn
1451 .prepare("SELECT track_id, path FROM local_files ORDER BY path")
1452 .unwrap()
1453 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1454 .unwrap()
1455 .collect::<Result<_, _>>()
1456 .unwrap()
1457 };
1458 assert_eq!(local_files(&db).len(), 2, "two tracks, one file each");
1459 let playlists: Vec<i64> = [&given, &music]
1460 .iter()
1461 .map(|root| {
1462 let id =
1463 queries::create_playlist(&db.conn, queries::LOCAL_USER, "list", None).unwrap();
1464 db.conn
1465 .execute(
1466 "UPDATE playlists SET source_path = ?1 WHERE id = ?2",
1467 rusqlite::params![root.join("list.nsp").to_string_lossy(), id],
1468 )
1469 .unwrap();
1470 id
1471 })
1472 .collect();
1473
1474 queries::sources::respell_folder(&db.conn, &given, &music).unwrap();
1475
1476 assert_eq!(
1477 local_files(&db),
1478 vec![(tracks[1], file.to_string_lossy().into_owned())]
1479 );
1480 let remaining: i64 = db
1481 .conn
1482 .query_row(
1483 "SELECT COUNT(*) FROM tracks WHERE id IN (?1, ?2)",
1484 [tracks[0], tracks[1]],
1485 |r| r.get(0),
1486 )
1487 .unwrap();
1488 assert_eq!(remaining, 2, "both stay as the server's");
1489 let kept: Vec<(i64, String)> = db
1490 .conn
1491 .prepare("SELECT id, source_path FROM playlists WHERE source_path IS NOT NULL")
1492 .unwrap()
1493 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1494 .unwrap()
1495 .collect::<Result<_, _>>()
1496 .unwrap();
1497 assert_eq!(
1498 kept,
1499 vec![(
1500 playlists[0],
1501 music.join("list.nsp").to_string_lossy().into_owned()
1502 )]
1503 );
1504 }
1505
1506 #[test]
1507 fn a_file_moved_between_library_folders_keeps_its_track() {
1508 let dir = tempfile::tempdir().unwrap();
1509 let (one, two) = (dir.path().join("one"), dir.path().join("two"));
1510 std::fs::create_dir_all(one.join("Album")).unwrap();
1511 std::fs::create_dir_all(&two).unwrap();
1512 test_utils::generate_wav(&one.join("Album/a.wav"), 44100, 1, 0.2, 16);
1513 test_utils::generate_wav(&one.join("b.wav"), 44100, 1, 0.3, 16);
1514 test_utils::generate_wav(&two.join("c.wav"), 44100, 1, 0.4, 16);
1515 let db = test_db(dir.path());
1516 let folders = [one.clone(), two.clone()];
1517 scan_folders(&db, &folders, ScanOptions::default(), None, None);
1518 let before = tracks_with_uids(&db);
1519
1520 std::fs::rename(one.join("Album"), two.join("Album")).unwrap();
1521 let r = scan_folders(&db, &folders, ScanOptions::default(), None, None);
1522 assert_eq!((r.added, r.removed, r.moved), (0, 0, 1), "{:?}", r.errors);
1523 let after = tracks_with_uids(&db);
1524 assert_eq!(
1525 before.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>(),
1526 after.iter().map(|t| (t.0, &t.1)).collect::<Vec<_>>()
1527 );
1528 assert!(after.iter().any(|t| t.2.ends_with("two/Album/a.wav")));
1529 }
1530
1531 fn tracks_with_uids(db: &Database) -> Vec<(i64, String, String)> {
1533 db.conn
1534 .prepare("SELECT id, uid, path FROM tracks ORDER BY id")
1535 .unwrap()
1536 .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
1537 .unwrap()
1538 .collect::<Result<_, _>>()
1539 .unwrap()
1540 }
1541
1542 fn track_paths(db: &Database) -> Vec<String> {
1543 db.conn
1544 .prepare("SELECT path FROM tracks ORDER BY path")
1545 .unwrap()
1546 .query_map([], |r| r.get(0))
1547 .unwrap()
1548 .collect::<Result<_, _>>()
1549 .unwrap()
1550 }
1551
1552 #[test]
1555 fn a_forgotten_folder_added_back_is_read_again() {
1556 let dir = tempfile::tempdir().unwrap();
1557 let music = dir.path().join("music");
1558 std::fs::create_dir_all(&music).unwrap();
1559 let file = music.join("kept.wav");
1560 test_utils::generate_wav(&file, 8000, 1, 0.1, 16);
1561 let db = test_db(dir.path());
1562 let mut both = metadata::read_metadata(&file).unwrap();
1563 both.remote_id = Some("sub-1".into());
1564 let track = queries::upsert_track(&db.conn, &both).unwrap();
1565 let local = |db: &Database| -> Option<String> {
1566 db.conn
1567 .query_row("SELECT path FROM tracks WHERE id = ?1", [track], |r| {
1568 r.get(0)
1569 })
1570 .unwrap()
1571 };
1572 scan_folder(&db, &music, ScanOptions::default(), None);
1573
1574 assert_eq!(crate::helpers::forget_folder(&db, &music).unwrap(), 1);
1575 assert_eq!(local(&db), None, "kept as the server's copy");
1576
1577 let again = scan_folder(&db, &music, ScanOptions::default(), None);
1578 assert_eq!(again.skipped, 0, "{:?}", again.errors);
1579 assert_eq!(local(&db), Some(file.to_string_lossy().into_owned()));
1580 }
1581
1582 #[test]
1583 fn scans_skip_what_the_watcher_ignores() {
1584 let dir = tempfile::tempdir().unwrap();
1585 let music = dir.path().join("music");
1586 let album = music.join("Artist/Album");
1587 let dotted = music.join("Britney Spears/...Baby One More Time");
1588 for sub in [
1589 album.join(".stversions"),
1590 album.join("Disc 2.part"),
1591 dotted.clone(),
1592 ] {
1593 std::fs::create_dir_all(sub).unwrap();
1594 }
1595 let wav = |p: PathBuf| test_utils::generate_wav(&p, 8000, 1, 0.1, 16);
1596 wav(album.join("01.wav"));
1597 wav(dotted.join("01.wav"));
1598 wav(album.join(".stversions/01~20261006-120000.wav"));
1599 wav(album.join("._01.wav"));
1600 wav(album.join("Disc 2.part/02.wav"));
1601 wav(album.join("~syncthing~03.wav"));
1602 let db = test_db(dir.path());
1603
1604 let full = scan_folder(&db, &music, ScanOptions::default(), None);
1605 assert_eq!(full.added, 2, "{:?}", full.errors);
1606 let dirs = scan_dirs(
1607 &db,
1608 std::slice::from_ref(&music),
1609 std::slice::from_ref(&album),
1610 ScanOptions::default(),
1611 None,
1612 );
1613 assert_eq!(dirs.added, 0, "{:?}", dirs.errors);
1614 assert_eq!(
1615 track_paths(&db),
1616 [album.join("01.wav"), dotted.join("01.wav")].map(|p| p.to_string_lossy().into_owned())
1617 );
1618 }
1619
1620 #[test]
1621 fn minimal_dirs_keeps_only_the_outermost() {
1622 let dirs = [
1623 "/m/A/Album 2",
1624 "/m/A/Album",
1625 "/m/A/Album/CD1",
1626 "/m/A/Album",
1627 "/m/AB",
1628 "/m/B/Album/CD2",
1629 ]
1630 .map(PathBuf::from)
1631 .to_vec();
1632 assert_eq!(
1633 minimal_dirs(dirs),
1634 ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
1635 );
1636 assert_eq!(
1637 minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
1638 [PathBuf::from("/m/A")]
1639 );
1640 }
1641
1642 #[test]
1643 fn scan_dirs_covers_only_the_directories_named() {
1644 let dir = tempfile::tempdir().unwrap();
1645 let music = dir.path().join("music");
1646 let (old, new) = (music.join("Old"), music.join("New"));
1647 std::fs::create_dir_all(&old).unwrap();
1648 std::fs::create_dir_all(&new).unwrap();
1649 test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
1650
1651 let db = test_db(dir.path());
1652 scan_folder(&db, &music, ScanOptions::default(), None);
1653 test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
1654 test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
1656
1657 let r = scan_dirs(
1658 &db,
1659 std::slice::from_ref(&music),
1660 std::slice::from_ref(&new),
1661 ScanOptions::default(),
1662 None,
1663 );
1664 assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
1665 assert_eq!(track_paths(&db).len(), 2);
1666 }
1667
1668 #[test]
1669 fn rescanning_a_directory_tells_the_apps_its_art_may_have_changed() {
1670 let dir = tempfile::tempdir().unwrap();
1671 let music = dir.path().join("music");
1672 let (album, other) = (music.join("Album"), music.join("Other"));
1673 std::fs::create_dir_all(album.join("CD1")).unwrap();
1674 std::fs::create_dir_all(&other).unwrap();
1675 test_utils::generate_wav(&album.join("CD1/a.wav"), 44100, 1, 0.2, 16);
1676 test_utils::generate_wav(&other.join("b.wav"), 44100, 1, 0.2, 16);
1677 let db = test_db(dir.path());
1678 scan_folder(&db, &music, ScanOptions::default(), None);
1679 let evicted = |db: &Database| -> Vec<(String, i64)> {
1680 db.conn
1681 .prepare("SELECT kind, id FROM art_evictions ORDER BY seq")
1682 .unwrap()
1683 .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
1684 .unwrap()
1685 .collect::<Result<_, _>>()
1686 .unwrap()
1687 };
1688 assert!(evicted(&db).is_empty());
1689
1690 std::fs::write(album.join("cover.jpg"), b"").unwrap();
1692 scan_dirs(
1693 &db,
1694 std::slice::from_ref(&music),
1695 std::slice::from_ref(&album),
1696 ScanOptions::default(),
1697 None,
1698 );
1699 let (album_id, track_id): (i64, i64) = db
1700 .conn
1701 .query_row(
1702 "SELECT album_id, id FROM tracks WHERE path LIKE '%/CD1/a.wav'",
1703 [],
1704 |r| Ok((r.get(0)?, r.get(1)?)),
1705 )
1706 .unwrap();
1707 assert_eq!(
1708 evicted(&db),
1709 vec![("album".into(), album_id), ("track".into(), track_id)],
1710 "only the records under the folder rescanned"
1711 );
1712 }
1713
1714 #[test]
1715 fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
1716 let dir = tempfile::tempdir().unwrap();
1717 let music = dir.path().join("music");
1718 let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
1719 std::fs::create_dir_all(&kept).unwrap();
1720 std::fs::create_dir_all(&gone).unwrap();
1721 for i in 0..3 {
1722 test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
1723 test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
1724 }
1725 let db = test_db(dir.path());
1726 assert_eq!(
1727 scan_folder(&db, &music, ScanOptions::default(), None).added,
1728 6
1729 );
1730
1731 std::fs::remove_file(kept.join("0.wav")).unwrap();
1732 std::fs::remove_dir_all(&gone).unwrap();
1733 let r = scan_dirs(
1734 &db,
1735 std::slice::from_ref(&music),
1736 &[kept.clone(), gone.clone()],
1737 ScanOptions::default(),
1738 None,
1739 );
1740
1741 assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
1742 let paths = track_paths(&db);
1743 assert_eq!(paths.len(), 2, "{paths:#?}");
1744 assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
1745 }
1746
1747 #[test]
1750 fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
1751 let dir = tempfile::tempdir().unwrap();
1752 let music = dir.path().join("music");
1753 let album = music.join("Album");
1754 std::fs::create_dir_all(&album).unwrap();
1755 test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
1756 let db = test_db(dir.path());
1757 scan_folder(&db, &music, ScanOptions::default(), None);
1758
1759 std::fs::remove_dir_all(&album).unwrap();
1760 let r = scan_dirs(
1761 &db,
1762 std::slice::from_ref(&music),
1763 std::slice::from_ref(&album),
1764 ScanOptions::default(),
1765 None,
1766 );
1767 assert_eq!(r.removed, 0);
1768 assert_eq!(track_paths(&db).len(), 1);
1769 }
1770
1771 #[cfg(unix)]
1775 #[test]
1776 fn a_dangling_symlink_is_not_a_deletion() {
1777 let dir = tempfile::tempdir().unwrap();
1778 let music = dir.path().join("music");
1779 let local = music.join("Local");
1780 let share = dir.path().join("share");
1781 std::fs::create_dir_all(&local).unwrap();
1782 std::fs::create_dir_all(share.join("Album")).unwrap();
1783 test_utils::generate_wav(&local.join("a.wav"), 44100, 1, 0.2, 16);
1784 test_utils::generate_wav(&share.join("Album/b.wav"), 44100, 1, 0.2, 16);
1785 std::os::unix::fs::symlink(&share, music.join("nas")).unwrap();
1786 let db = test_db(dir.path());
1787 assert_eq!(
1788 scan_folder(&db, &music, ScanOptions::default(), None).added,
1789 2
1790 );
1791
1792 std::fs::remove_dir_all(&share).unwrap();
1793 let full = scan_folder(&db, &music, ScanOptions::default(), None);
1794 assert_eq!(full.removed, 0, "{:?}", full.removed_paths);
1795 let watched = scan_dirs(
1796 &db,
1797 std::slice::from_ref(&music),
1798 &[music.join("nas/Album")],
1799 ScanOptions::default(),
1800 None,
1801 );
1802 assert_eq!(watched.removed, 0, "{:?}", watched.removed_paths);
1803 assert_eq!(track_paths(&db).len(), 2);
1804
1805 std::fs::remove_file(music.join("nas")).unwrap();
1807 let removed = scan_folder(&db, &music, ScanOptions::default(), None);
1808 assert_eq!(removed.removed, 1);
1809 }
1810
1811 #[cfg(target_os = "linux")]
1814 #[test]
1815 fn a_path_that_is_not_utf8_is_skipped() {
1816 use std::os::unix::ffi::OsStrExt;
1817 let dir = tempfile::tempdir().unwrap();
1818 let music = dir.path().join("music");
1819 std::fs::create_dir_all(&music).unwrap();
1820 test_utils::generate_wav(&music.join("a.wav"), 44100, 1, 0.2, 16);
1821 let odd = music.join(std::ffi::OsStr::from_bytes(b"caf\xe9.wav"));
1822 test_utils::generate_wav(&odd, 44100, 1, 0.2, 16);
1823 let db = test_db(dir.path());
1824
1825 let r = scan_folder(&db, &music, ScanOptions::default(), None);
1826 assert_eq!((r.added, r.removed), (1, 0));
1827 assert_eq!(track_paths(&db).len(), 1);
1828 }
1829
1830 #[test]
1831 fn scan_dirs_ignores_directories_outside_the_library() {
1832 let dir = tempfile::tempdir().unwrap();
1833 let music = dir.path().join("music");
1834 let elsewhere = dir.path().join("elsewhere");
1835 std::fs::create_dir_all(&music).unwrap();
1836 std::fs::create_dir_all(&elsewhere).unwrap();
1837 test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
1838 let db = test_db(dir.path());
1839
1840 let r = scan_dirs(
1841 &db,
1842 std::slice::from_ref(&music),
1843 std::slice::from_ref(&elsewhere),
1844 ScanOptions::default(),
1845 None,
1846 );
1847 assert_eq!(r.added, 0);
1848 assert!(track_paths(&db).is_empty());
1849 }
1850
1851 #[test]
1852 fn import_paths_indexes_files_where_they_lie() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let drop = dir.path().join("Downloads/rip");
1857 std::fs::create_dir_all(&drop).unwrap();
1858 test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
1859 test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
1860 std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
1861
1862 let db = test_db(dir.path());
1863 let result = import_paths(&db, std::slice::from_ref(&drop));
1864
1865 assert_eq!(result.added, 2);
1866 assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
1867 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1868
1869 for id in &result.track_ids {
1871 let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
1872 assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
1873 }
1874 }
1875
1876 #[test]
1878 fn import_paths_is_idempotent() {
1879 let dir = tempfile::tempdir().unwrap();
1880 let drop = dir.path().join("rip");
1881 std::fs::create_dir_all(&drop).unwrap();
1882 test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
1883
1884 let db = test_db(dir.path());
1885 let first = import_paths(&db, std::slice::from_ref(&drop));
1886 let second = import_paths(&db, std::slice::from_ref(&drop));
1887
1888 assert_eq!(first.added, 1);
1889 assert_eq!((second.added, second.updated), (0, 0));
1891 assert_eq!(first.track_ids, second.track_ids);
1892 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1893 }
1894
1895 #[test]
1897 fn import_paths_deduplicates_overlapping_selections() {
1898 let dir = tempfile::tempdir().unwrap();
1899 let drop = dir.path().join("rip");
1900 std::fs::create_dir_all(&drop).unwrap();
1901 let track = drop.join("a.wav");
1902 test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
1903
1904 let db = test_db(dir.path());
1905 let result = import_paths(&db, &[drop.clone(), track.clone()]);
1906
1907 assert_eq!(result.track_ids.len(), 1);
1908 }
1909
1910 #[test]
1911 fn scan_folder_skips_unchanged_files() {
1912 let dir = tempfile::tempdir().unwrap();
1913 let music_dir = dir.path().join("music");
1914 std::fs::create_dir_all(&music_dir).unwrap();
1915
1916 let wav_path = music_dir.join("unchanged.wav");
1917 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1918
1919 let db = test_db(dir.path());
1920
1921 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1923 assert_eq!(r1.added, 1);
1924
1925 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1927 assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
1928 assert_eq!(r2.added, 0, "no new files should be added");
1929 }
1930
1931 #[test]
1932 fn scan_folder_removes_deleted_tracks() {
1933 let dir = tempfile::tempdir().unwrap();
1934 let music_dir = dir.path().join("music");
1935 std::fs::create_dir_all(&music_dir).unwrap();
1936
1937 let wav_path = music_dir.join("ephemeral.wav");
1938 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1939 test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
1940
1941 let db = test_db(dir.path());
1942
1943 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1945 assert_eq!(r1.added, 2);
1946
1947 std::fs::remove_file(&wav_path).unwrap();
1949
1950 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1952 assert_eq!(
1953 r2.removed, 1,
1954 "expected 1 track removed after file deletion"
1955 );
1956
1957 let stats = queries::library_stats(&db.conn).unwrap();
1958 assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
1959 }
1960
1961 #[test]
1962 fn empty_folder_does_not_wipe_the_library() {
1963 let dir = tempfile::tempdir().unwrap();
1964 let music_dir = dir.path().join("music");
1965 std::fs::create_dir_all(&music_dir).unwrap();
1966 test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
1967 test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
1968
1969 let db = test_db(dir.path());
1970 assert_eq!(
1971 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1972 2
1973 );
1974
1975 std::fs::remove_file(music_dir.join("a.wav")).unwrap();
1978 std::fs::remove_file(music_dir.join("b.wav")).unwrap();
1979
1980 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1981 assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
1982 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1983 }
1984
1985 #[cfg(unix)]
1986 #[test]
1987 fn unreadable_folder_is_not_a_deletion() {
1988 use std::os::unix::fs::PermissionsExt;
1989
1990 let dir = tempfile::tempdir().unwrap();
1991 let music_dir = dir.path().join("music");
1992 let locked_dir = music_dir.join("locked");
1993 std::fs::create_dir_all(&locked_dir).unwrap();
1994 test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
1995 let locked_file = locked_dir.join("locked.wav");
1996 test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
1997
1998 let db = test_db(dir.path());
1999 assert_eq!(
2000 scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
2001 2
2002 );
2003
2004 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
2005 if locked_file.try_exists().is_ok() {
2006 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
2008 return;
2009 }
2010
2011 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2012 std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
2013
2014 assert!(
2015 r.unreadable >= 1,
2016 "the unreadable subtree should be counted"
2017 );
2018 assert_eq!(r.removed, 0, "an IO error is not a deletion");
2019 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
2020 }
2021
2022 #[test]
2023 fn interrupted_scan_keeps_committed_chunks_and_resumes() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let music_dir = dir.path().join("music");
2026 std::fs::create_dir_all(&music_dir).unwrap();
2027 for i in 0..6 {
2028 test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
2029 }
2030
2031 let db = test_db(dir.path());
2032
2033 let abort = |_: ScanEvent| panic!("simulated interrupt");
2036 let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2037 scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
2038 }));
2039 assert!(panicked.is_err());
2040
2041 assert_eq!(
2043 queries::library_stats(&db.conn).unwrap().total_tracks,
2044 CHUNK_SIZE as i64
2045 );
2046
2047 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2049 assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
2050 assert_eq!(r.added, 6 - CHUNK_SIZE);
2051 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
2052 }
2053
2054 #[test]
2055 fn failing_file_is_named_and_the_scan_continues() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let music_dir = dir.path().join("music");
2058 std::fs::create_dir_all(&music_dir).unwrap();
2059 test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
2060 let broken = music_dir.join("broken.flac");
2061 std::fs::write(&broken, b"").unwrap();
2062
2063 let db = test_db(dir.path());
2064 let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2065
2066 assert_eq!(r.added, 1, "the good file must still be indexed");
2067 assert_eq!(r.errors.len(), 1);
2068 assert_eq!(r.errors[0].0, broken, "the failing file must be named");
2069 assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
2070 }
2071
2072 #[test]
2073 fn a_panicking_tag_read_becomes_an_error() {
2074 let path = Path::new("/music/hostile.mp3");
2075 let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
2076 assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
2077 }
2078
2079 #[test]
2080 fn scan_folder_updates_modified_files() {
2081 let dir = tempfile::tempdir().unwrap();
2082 let music_dir = dir.path().join("music");
2083 std::fs::create_dir_all(&music_dir).unwrap();
2084
2085 let wav_path = music_dir.join("modified.wav");
2086 test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
2087
2088 let db = test_db(dir.path());
2089
2090 let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2092 assert_eq!(r1.added, 1);
2093
2094 std::thread::sleep(std::time::Duration::from_millis(1100));
2097 test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
2098
2099 let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2101 assert_eq!(r2.updated, 1, "modified file should be re-indexed");
2102 assert_eq!(r2.added, 0, "the row already exists");
2103 assert_eq!(r2.skipped, 0, "modified file should not be skipped");
2104 }
2105
2106 #[test]
2107 fn a_drop_and_a_scan_spell_a_file_the_same_way() {
2108 use unicode_normalization::UnicodeNormalization;
2109 let dir = tempfile::tempdir().unwrap();
2110 let nfd: String = "Roman Flügel".nfd().collect();
2111 let nfc: String = "Roman Flügel".nfc().collect();
2112 let music_dir = dir.path().join(&nfd);
2113 std::fs::create_dir_all(&music_dir).unwrap();
2114 test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
2115 if !dir.path().join(&nfc).exists() {
2118 return;
2119 }
2120 let db = test_db(dir.path());
2121
2122 let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
2124 assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
2125
2126 let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
2128 assert_eq!(scanned.added, 0, "the same file, not a second one");
2129
2130 let stats = queries::library_stats(&db.conn).unwrap();
2131 assert_eq!(stats.total_tracks, 1);
2132 let stored: String = db
2133 .conn
2134 .query_row("SELECT path FROM tracks", [], |r| r.get(0))
2135 .unwrap();
2136 assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
2137 }
2138}