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