Skip to main content

koan_core/index/
scanner.rs

1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::AtomicBool;
5use std::time::UNIX_EPOCH;
6
7use rayon::prelude::*;
8
9use crate::db::connection::Database;
10use crate::db::queries::{self, TrackMeta};
11
12use super::metadata::{self, is_audio_file};
13
14/// Result of a folder scan.
15#[derive(Debug, Default)]
16pub struct ScanResult {
17    pub added: usize,
18    pub updated: usize,
19    pub removed: usize,
20    pub skipped: usize,
21    /// Directory entries walkdir could not read — unreadable subtrees, symlink
22    /// loops. Their contents are absent from the scan entirely.
23    pub unreadable: usize,
24    /// Paths of the tracks deleted or demoted to remote-only, so a caller can
25    /// show what a removal actually took.
26    pub removed_paths: Vec<String>,
27    pub errors: Vec<(PathBuf, String)>,
28    /// Stopped early because someone asked. What it had done is still done.
29    pub cancelled: bool,
30}
31
32/// How a scan should behave.
33#[derive(Debug, Clone, Default)]
34pub struct ScanOptions {
35    /// Re-read tags for every file, ignoring `scan_cache`.
36    pub force: bool,
37    /// Set from another thread to stop early.
38    ///
39    /// Checked between transactions, so a cancelled scan keeps everything it
40    /// had already committed rather than throwing the work away — stopping is
41    /// "stop here", not "undo".
42    pub cancel: Option<Arc<AtomicBool>>,
43    /// Delete stale tracks even when the proportion missing looks like a mount
44    /// failure. Lifts the removal-fraction brake only — a folder that yields no
45    /// audio files is still left alone, and an IO error still never counts as
46    /// "file gone".
47    pub force_remove: bool,
48}
49
50/// Files per transaction. Bounds peak memory (only one chunk's metadata is
51/// resident) and caps what an interrupted scan loses; committed chunks land in
52/// `scan_cache`, so the next run resumes rather than restarting.
53#[cfg(not(test))]
54const CHUNK_SIZE: usize = 1000;
55#[cfg(test)]
56const CHUNK_SIZE: usize = 4;
57
58/// Info about a scanned track, passed to the progress callback.
59pub struct ScanEvent<'a> {
60    pub artist: &'a str,
61    pub album: &'a str,
62    pub title: &'a str,
63    pub path: &'a Path,
64    pub is_new: bool,
65}
66
67/// Scan a folder recursively for audio files and index them into the database.
68/// The optional `on_track` callback is invoked for each successfully indexed track.
69pub fn scan_folder(
70    db: &Database,
71    path: &Path,
72    opts: ScanOptions,
73    on_track: Option<&dyn Fn(ScanEvent)>,
74) -> ScanResult {
75    scan_folders(
76        db,
77        std::slice::from_ref(&path.to_path_buf()),
78        opts,
79        None,
80        on_track,
81    )
82}
83
84/// [`scan_folder`] over several folders, telling `on_started` how many audio
85/// files there are once every folder has been walked. The count comes from the
86/// same walk the scan uses: a separate counting walk would double the directory
87/// traversal, which on a network mount is most of the cost of a rescan.
88pub fn scan_folders(
89    db: &Database,
90    folders: &[PathBuf],
91    opts: ScanOptions,
92    on_started: Option<&dyn Fn(u64)>,
93    on_track: Option<&dyn Fn(ScanEvent)>,
94) -> ScanResult {
95    let walked: Vec<(PathBuf, Vec<PathBuf>, ScanResult)> = folders
96        .iter()
97        .map(|folder| {
98            // The files under it are stored as the directory spells them; the
99            // root has to agree, or nothing under it matches a path an earlier
100            // scan stored.
101            let path = super::spelling::on_disk(folder);
102            let mut result = ScanResult::default();
103            let files = walk_audio(&path, &mut result);
104            (path, files, result)
105        })
106        .collect();
107    if let Some(started) = on_started {
108        started(walked.iter().map(|(_, files, _)| files.len() as u64).sum());
109    }
110
111    let mut total = ScanResult::default();
112    for (path, files, mut result) in walked {
113        if total.cancelled {
114            break;
115        }
116        index_folder(db, &path, files, &opts, on_track, &mut result);
117        merge(&mut total, result);
118    }
119    total
120}
121
122fn index_folder(
123    db: &Database,
124    path: &Path,
125    audio_files: Vec<PathBuf>,
126    opts: &ScanOptions,
127    on_track: Option<&dyn Fn(ScanEvent)>,
128    result: &mut ScanResult,
129) {
130    let total_files = audio_files.len();
131    log::info!("found {} audio files in {}", total_files, path.display());
132
133    if !index_files(db, audio_files, opts, on_track, result, path) {
134        return;
135    }
136
137    if result.cancelled {
138        // Stale removal decides what is missing by what the scan did *not* see.
139        // After a cancellation that is most of the folder, so it would delete a
140        // library rather than tidy one.
141        return;
142    }
143
144    // Remove tracks for files that no longer exist. A folder that yielded nothing
145    // is far more likely to be an unmounted volume than a library someone emptied,
146    // and stale rows are recoverable where deleted play history is not.
147    if total_files == 0 {
148        log::error!(
149            "{} contains no audio files — skipping stale-track removal. \
150             If this folder should have music in it, it is probably not mounted or not readable.",
151            path.display()
152        );
153        return;
154    }
155
156    remove_stale(db, path, opts.force_remove, result);
157}
158
159/// Rescan directories inside the library folders, and nothing else.
160///
161/// What the folder watcher runs: a change touches a handful of directories,
162/// and walking the whole library to find them costs a spinning disk minutes.
163/// Each directory is authoritative for what lies under it — files found are
164/// indexed, rows for files no longer there are removed — which is what
165/// separates this from `import_paths`. A directory that no longer exists
166/// removes everything that was under it.
167///
168/// Removal is only trusted while the library folder holding the directory is
169/// itself readable and non-empty: an unmounted volume leaves an empty mount
170/// point, and every directory under it would otherwise read as deleted. A
171/// directory that could not be read in full removes nothing. Directories
172/// outside every library folder are ignored; one that is a library folder
173/// gets the same treatment as `scan_folder`.
174pub fn scan_dirs(
175    db: &Database,
176    library: &[PathBuf],
177    dirs: &[PathBuf],
178    opts: ScanOptions,
179    on_track: Option<&dyn Fn(ScanEvent)>,
180) -> ScanResult {
181    let mut result = ScanResult::default();
182    let mut spelling = super::spelling::Spelling::default();
183    let library: Vec<PathBuf> = library.iter().map(|f| spelling.on_disk(f)).collect();
184    let dirs: Vec<PathBuf> = dirs.iter().map(|d| spelling.on_disk(d)).collect();
185
186    let mut files = Vec::new();
187    let mut settled = Vec::new();
188    for dir in minimal_dirs(dirs) {
189        let Some(root) = library.iter().find(|root| dir.starts_with(root)) else {
190            log::warn!("not in a library folder, not scanning: {}", dir.display());
191            continue;
192        };
193        if dir == *root {
194            merge(&mut result, scan_folder(db, root, opts.clone(), on_track));
195            continue;
196        }
197        if !is_populated(root) {
198            log::warn!(
199                "{} is empty or unreadable — not scanning {}",
200                root.display(),
201                dir.display()
202            );
203            continue;
204        }
205        match dir.try_exists() {
206            Ok(true) => {
207                let before = result.unreadable;
208                files.extend(walk_audio(&dir, &mut result));
209                if result.unreadable == before {
210                    settled.push(dir);
211                }
212            }
213            Ok(false) => settled.push(dir),
214            Err(e) => log::warn!("cannot tell whether {} exists: {e}", dir.display()),
215        }
216    }
217
218    if !index_files(db, files, &opts, on_track, &mut result, Path::new("")) || result.cancelled {
219        return result;
220    }
221    for dir in &settled {
222        remove_stale(db, dir, true, &mut result);
223    }
224    result
225}
226
227/// The fewest directories that cover every one given: duplicates dropped, and
228/// any directory inside another dropped, since a scan of the outer one walks it.
229pub fn minimal_dirs(mut dirs: Vec<PathBuf>) -> Vec<PathBuf> {
230    // Sorted by component, everything inside a directory comes straight after it.
231    dirs.sort();
232    dirs.dedup();
233    let mut kept: Vec<PathBuf> = Vec::with_capacity(dirs.len());
234    for dir in dirs {
235        if !kept.last().is_some_and(|outer| dir.starts_with(outer)) {
236            kept.push(dir);
237        }
238    }
239    kept
240}
241
242/// Whether a library folder is there to scan: readable, with something in it.
243fn is_populated(root: &Path) -> bool {
244    std::fs::read_dir(root).is_ok_and(|mut entries| entries.next().is_some())
245}
246
247fn merge(total: &mut ScanResult, r: ScanResult) {
248    total.cancelled |= r.cancelled;
249    total.added += r.added;
250    total.updated += r.updated;
251    total.removed += r.removed;
252    total.skipped += r.skipped;
253    total.unreadable += r.unreadable;
254    total.removed_paths.extend(r.removed_paths);
255    total.errors.extend(r.errors);
256}
257
258/// Every audio file under `path`. `follow_links` means a symlink pointing at a
259/// sibling directory inside the library indexes its files under both paths.
260fn walk_audio(path: &Path, result: &mut ScanResult) -> Vec<PathBuf> {
261    let mut audio_files = Vec::new();
262    for entry in walkdir::WalkDir::new(path).follow_links(true) {
263        match entry {
264            Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
265                audio_files.push(e.path().to_path_buf())
266            }
267            Ok(_) => {}
268            Err(e) => {
269                result.unreadable += 1;
270                log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
271            }
272        }
273    }
274    audio_files
275}
276
277/// Read and store the files that changed since they were last indexed.
278///
279/// Returns false when the scan could not run at all, with the reason in
280/// `result.errors` against `context`.
281fn index_files(
282    db: &Database,
283    mut audio_files: Vec<PathBuf>,
284    opts: &ScanOptions,
285    on_track: Option<&dyn Fn(ScanEvent)>,
286    result: &mut ScanResult,
287    context: &Path,
288) -> bool {
289    let total_files = audio_files.len();
290    if total_files == 0 {
291        return true;
292    }
293
294    // Filter to files that need scanning.
295    // Batch-load the entire scan_cache into a HashMap to avoid O(N) individual
296    // DB lookups (one per file). For 100k+ file libraries this is dramatically faster.
297    let files_to_scan: Vec<PathBuf> = if opts.force {
298        std::mem::take(&mut audio_files)
299    } else {
300        let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
301        // One stat per file; in parallel, since on a network mount each is a round trip.
302        audio_files
303            .par_iter()
304            .filter(|file_path| {
305                let Ok(file_meta) = std::fs::metadata(file_path) else {
306                    return true;
307                };
308                let mtime = file_meta
309                    .modified()
310                    .ok()
311                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
312                    .map(|d| d.as_secs() as i64)
313                    .unwrap_or(0);
314                let size = file_meta.len() as i64;
315                let path_str = file_path.to_string_lossy();
316                match scan_cache.get(path_str.as_ref()) {
317                    Some(&(cached_mtime, cached_size)) => {
318                        mtime != cached_mtime || size != cached_size
319                    }
320                    None => true,
321                }
322            })
323            .cloned()
324            .collect()
325    };
326
327    result.skipped += total_files - files_to_scan.len();
328
329    // Tag reads and database writes run at the same time.
330    //
331    // Reading the whole library up front and writing it in one transaction
332    // blocks every other writer for the length of the scan and loses all of it
333    // on interrupt, so writes stay chunked. But doing that as read-chunk,
334    // write-chunk, read-chunk leaves the disk idle for every write and the CPU
335    // idle for every read — on a library of any size that is most of the run.
336    //
337    // Instead the reads stream: a worker pool walks every file and pushes
338    // results down a bounded channel while this thread batches them into
339    // transactions. The bound is what caps memory, in place of the chunking.
340    let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
341        CHUNK_SIZE.saturating_mul(2),
342    );
343    let cancel = opts.cancel.clone();
344    let reader = std::thread::Builder::new()
345        .name("koan-scan-read".into())
346        .spawn(move || {
347            // Stops at the first failed send (the consumer is gone) or cancel.
348            let _ = files_to_scan.par_iter().try_for_each(|file_path| {
349                if cancel
350                    .as_ref()
351                    .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
352                {
353                    return Err(());
354                }
355                send.send((
356                    file_path.clone(),
357                    isolate_read(file_path, metadata::read_metadata),
358                ))
359                .map_err(|_| ())
360            });
361        });
362    if let Err(e) = &reader {
363        log::error!("failed to spawn scan reader: {}", e);
364        result
365            .errors
366            .push((context.to_path_buf(), format!("scan error: {}", e)));
367        return false;
368    }
369
370    loop {
371        // Blocks until a full batch is ready or the readers have finished.
372        let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
373            recv.iter().take(CHUNK_SIZE).collect();
374        if batch.is_empty() {
375            break;
376        }
377        if opts
378            .cancel
379            .as_ref()
380            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
381        {
382            log::info!("scan cancelled — keeping what was already committed");
383            result.cancelled = true;
384            break;
385        }
386
387        let tx = match crate::db::queries::write_transaction(&db.conn) {
388            Ok(tx) => tx,
389            Err(e) => {
390                log::error!("failed to begin scan transaction: {}", e);
391                result
392                    .errors
393                    .push((context.to_path_buf(), format!("db error: {}", e)));
394                return false;
395            }
396        };
397
398        let (mut added, mut updated) = (0usize, 0usize);
399        for (file_path, meta_result) in batch {
400            match meta_result {
401                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
402                    Ok((track_id, is_new)) => {
403                        if is_new {
404                            added += 1;
405                        } else {
406                            updated += 1;
407                        }
408                        if let Some(cb) = &on_track {
409                            cb(ScanEvent {
410                                artist: &meta.artist,
411                                album: &meta.album,
412                                title: &meta.title,
413                                path: &file_path,
414                                is_new,
415                            });
416                        }
417                        if let Err(e) = queries::update_scan_cache(
418                            &tx,
419                            meta.path.as_deref().unwrap_or(""),
420                            meta.mtime.unwrap_or(0),
421                            meta.size_bytes.unwrap_or(0),
422                            track_id,
423                        ) {
424                            // Not fatal, but every future scan re-reads this file's tags.
425                            log::warn!("failed to cache {}: {}", file_path.display(), e);
426                        }
427                    }
428                    Err(e) => {
429                        result.errors.push((file_path, format!("db error: {}", e)));
430                    }
431                },
432                Err(e) => {
433                    result.errors.push((file_path, e));
434                }
435            }
436        }
437
438        match tx.commit() {
439            Ok(()) => {
440                result.added += added;
441                result.updated += updated;
442            }
443            Err(e) => {
444                log::error!("failed to commit scan transaction: {}", e);
445                result
446                    .errors
447                    .push((context.to_path_buf(), format!("db error: {}", e)));
448            }
449        }
450    }
451
452    // Dropped before the join: readers parked on a full channel only wake
453    // when their send fails.
454    drop(recv);
455    if let Ok(handle) = reader
456        && handle.join().is_err()
457    {
458        log::error!("scan reader thread panicked");
459    }
460    true
461}
462
463/// Remove the rows under `path` whose files are gone, in one transaction.
464fn remove_stale(db: &Database, path: &Path, force_remove: bool, result: &mut ScanResult) {
465    let tx = match crate::db::queries::write_transaction(&db.conn) {
466        Ok(tx) => tx,
467        Err(e) => {
468            log::error!("failed to begin stale-removal transaction: {}", e);
469            result
470                .errors
471                .push((path.to_path_buf(), format!("db error: {}", e)));
472            return;
473        }
474    };
475    match queries::remove_stale_tracks(&tx, path, force_remove) {
476        Ok(removed) => {
477            if let Err(e) = tx.commit() {
478                log::error!("failed to commit stale removals: {}", e);
479                result
480                    .errors
481                    .push((path.to_path_buf(), format!("db error: {}", e)));
482                return;
483            }
484            result.removed += removed.len();
485            result.removed_paths.extend(removed);
486        }
487        Err(e) => {
488            log::error!("failed to remove stale tracks: {}", e);
489            result.errors.push((path.to_path_buf(), e.to_string()));
490        }
491    }
492}
493
494/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
495/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
496/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
497/// the whole scan over one file and not even name it.
498fn isolate_read(
499    path: &Path,
500    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
501) -> Result<TrackMeta, String> {
502    match catch_unwind(AssertUnwindSafe(|| read(path))) {
503        Ok(result) => result.map_err(|e| e.to_string()),
504        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
505    }
506}
507
508/// What an import of specific files produced.
509#[derive(Debug, Default)]
510pub struct ImportResult {
511    /// Library rows for the imported files, in the order their paths were
512    /// walked. This is what a caller queues.
513    pub track_ids: Vec<i64>,
514    pub added: usize,
515    pub updated: usize,
516    pub errors: Vec<(PathBuf, String)>,
517}
518
519/// Index specific files into the library, wherever they live.
520///
521/// This is the drop-a-folder-on-the-queue path: the files named here are not
522/// under a configured library folder, and organize is what moves them there
523/// afterwards. Nothing is ever removed — the caller named these paths, so there
524/// is no directory listing to reconcile against and nothing to prune, which is
525/// what separates this from `scan_folder`.
526///
527/// Directories are walked recursively. Order is by path, so an album lands in
528/// the order its files are numbered.
529pub fn import_paths(db: &Database, paths: &[PathBuf]) -> ImportResult {
530    let mut result = ImportResult::default();
531
532    let mut files: Vec<PathBuf> = Vec::new();
533    let mut seen = std::collections::HashSet::new();
534    // Dropped paths are spelled by whoever dropped them, and Foundation spells
535    // accents differently from the disk. Walked children come from the
536    // directory itself and need nothing.
537    let mut spelling = super::spelling::Spelling::default();
538    for path in paths {
539        let path = spelling.on_disk(path);
540        let mut found: Vec<PathBuf> = walkdir::WalkDir::new(&path)
541            .follow_links(true)
542            .into_iter()
543            .filter_map(Result::ok)
544            .filter(|e| e.file_type().is_file() && is_audio_file(e.path()))
545            .map(|e| e.path().to_path_buf())
546            .collect();
547        found.sort();
548        // A drop can name both a folder and a file inside it.
549        files.extend(found.into_iter().filter(|f| seen.insert(f.clone())));
550    }
551
552    if files.is_empty() {
553        return result;
554    }
555
556    // Tag reads are the slow part and independent per file; the writes are not.
557    let read: Vec<(PathBuf, Result<TrackMeta, String>)> = files
558        .par_iter()
559        .map(|path| (path.clone(), isolate_read(path, metadata::read_metadata)))
560        .collect();
561
562    let tx = match crate::db::queries::write_transaction(&db.conn) {
563        Ok(tx) => tx,
564        Err(e) => {
565            result
566                .errors
567                .push((PathBuf::new(), format!("db error: {e}")));
568            return result;
569        }
570    };
571
572    for (path, meta_result) in read {
573        let meta = match meta_result {
574            Ok(meta) => meta,
575            Err(e) => {
576                result.errors.push((path, e));
577                continue;
578            }
579        };
580        match queries::upsert_track_status(&tx, &meta) {
581            Ok((track_id, is_new)) => {
582                if is_new {
583                    result.added += 1;
584                } else {
585                    result.updated += 1;
586                }
587                result.track_ids.push(track_id);
588                if let Err(e) = queries::update_scan_cache(
589                    &tx,
590                    meta.path.as_deref().unwrap_or(""),
591                    meta.mtime.unwrap_or(0),
592                    meta.size_bytes.unwrap_or(0),
593                    track_id,
594                ) {
595                    // Not fatal, but every future scan re-reads this file's tags.
596                    log::warn!("failed to cache {}: {}", path.display(), e);
597                }
598            }
599            Err(e) => result.errors.push((path, format!("db error: {e}"))),
600        }
601    }
602
603    if let Err(e) = tx.commit() {
604        result.track_ids.clear();
605        result.added = 0;
606        result.updated = 0;
607        result
608            .errors
609            .push((PathBuf::new(), format!("db error: {e}")));
610    }
611
612    result
613}
614
615/// Scan all configured library folders.
616pub fn full_scan(
617    db: &Database,
618    folders: &[PathBuf],
619    opts: ScanOptions,
620    on_track: Option<&dyn Fn(ScanEvent)>,
621) -> ScanResult {
622    let existing: Vec<PathBuf> = folders
623        .iter()
624        .filter(|folder| {
625            let exists = folder.exists();
626            if !exists {
627                log::warn!("library folder does not exist: {}", folder.display());
628            }
629            exists
630        })
631        .cloned()
632        .collect();
633    let total = scan_folders(db, &existing, opts, None, on_track);
634    db.optimize();
635    total
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641    use crate::db::connection::Database;
642    use crate::db::queries;
643    use crate::test_utils;
644
645    fn test_db(dir: &Path) -> Database {
646        let db_path = dir.join("test.db");
647        Database::open(&db_path).unwrap()
648    }
649
650    #[test]
651    fn cancelling_a_scan_returns_with_readers_still_pending() {
652        let dir = tempfile::tempdir().unwrap();
653        let music_dir = dir.path().join("music");
654        std::fs::create_dir_all(&music_dir).unwrap();
655        // Far more than the channel holds (2 * CHUNK_SIZE), so readers are
656        // parked on a full channel when the consumer stops.
657        for i in 0..CHUNK_SIZE * 12 {
658            test_utils::generate_wav(&music_dir.join(format!("{i:03}.wav")), 8000, 1, 0.01, 16);
659        }
660
661        let (done_tx, done_rx) = crossbeam_channel::bounded(1);
662        let root = dir.path().to_path_buf();
663        std::thread::spawn(move || {
664            let db = test_db(&root);
665            let cancel = Arc::new(AtomicBool::new(false));
666            let opts = ScanOptions {
667                cancel: Some(cancel.clone()),
668                ..Default::default()
669            };
670            let on_track = |_: ScanEvent| cancel.store(true, std::sync::atomic::Ordering::Relaxed);
671            let result = scan_folder(&db, &music_dir, opts, Some(&on_track));
672            done_tx.send(result).unwrap();
673        });
674
675        let result = done_rx
676            .recv_timeout(std::time::Duration::from_secs(30))
677            .expect("cancelled scan never returned");
678        assert!(result.cancelled);
679        assert!(result.added < CHUNK_SIZE * 12);
680    }
681
682    #[test]
683    fn scan_folder_indexes_new_files() {
684        let dir = tempfile::tempdir().unwrap();
685        let music_dir = dir.path().join("music");
686        std::fs::create_dir_all(&music_dir).unwrap();
687
688        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
689        let wav_path = music_dir.join("silence.wav");
690        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
691
692        let db = test_db(dir.path());
693        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
694
695        assert_eq!(result.added, 1, "expected 1 track added");
696        assert_eq!(result.skipped, 0);
697        assert_eq!(result.removed, 0);
698        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
699
700        // Verify the track exists in the DB.
701        let stats = queries::library_stats(&db.conn).unwrap();
702        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
703    }
704
705    #[test]
706    fn a_renamed_file_leaves_no_row_at_its_old_path() {
707        let dir = tempfile::tempdir().unwrap();
708        let music = dir.path().join("music");
709        let album = music.join("Squire Of Gothos").join("Album");
710        std::fs::create_dir_all(&album).unwrap();
711        for i in 1..=3 {
712            test_utils::generate_wav(
713                &album.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
714                44100,
715                1,
716                0.5 + i as f32 * 0.1,
717                16,
718            );
719        }
720        let db = test_db(dir.path());
721        scan_folder(&db, &music, ScanOptions::default(), None);
722
723        // What a retag that re-files does: the artist folder and file names
724        // change case.
725        let moved = music.join("The Squire of Gothos").join("Album");
726        std::fs::create_dir_all(moved.parent().unwrap()).unwrap();
727        std::fs::rename(&album, &moved).unwrap();
728        std::fs::remove_dir(music.join("Squire Of Gothos")).unwrap();
729        for i in 1..=3 {
730            std::fs::rename(
731                moved.join(format!("0{i}. Squire Of Gothos - T{i}.wav")),
732                moved.join(format!("0{i}. The Squire of Gothos - T{i}.wav")),
733            )
734            .unwrap();
735        }
736        let result = scan_folder(&db, &music, ScanOptions::default(), None);
737
738        let paths: Vec<String> = db
739            .conn
740            .prepare("SELECT path FROM tracks ORDER BY path")
741            .unwrap()
742            .query_map([], |r| r.get(0))
743            .unwrap()
744            .collect::<Result<_, _>>()
745            .unwrap();
746        assert_eq!((result.added, result.removed), (3, 3));
747        assert_eq!(paths.len(), 3, "{paths:#?}");
748        assert!(
749            paths.iter().all(|p| std::path::Path::new(p).exists()),
750            "{paths:#?}"
751        );
752    }
753
754    fn track_paths(db: &Database) -> Vec<String> {
755        db.conn
756            .prepare("SELECT path FROM tracks ORDER BY path")
757            .unwrap()
758            .query_map([], |r| r.get(0))
759            .unwrap()
760            .collect::<Result<_, _>>()
761            .unwrap()
762    }
763
764    #[test]
765    fn minimal_dirs_keeps_only_the_outermost() {
766        let dirs = [
767            "/m/A/Album 2",
768            "/m/A/Album",
769            "/m/A/Album/CD1",
770            "/m/A/Album",
771            "/m/AB",
772            "/m/B/Album/CD2",
773        ]
774        .map(PathBuf::from)
775        .to_vec();
776        assert_eq!(
777            minimal_dirs(dirs),
778            ["/m/A/Album", "/m/A/Album 2", "/m/AB", "/m/B/Album/CD2"].map(PathBuf::from)
779        );
780        assert_eq!(
781            minimal_dirs(["/m/A/x", "/m/A"].map(PathBuf::from).to_vec()),
782            [PathBuf::from("/m/A")]
783        );
784    }
785
786    #[test]
787    fn scan_dirs_covers_only_the_directories_named() {
788        let dir = tempfile::tempdir().unwrap();
789        let music = dir.path().join("music");
790        let (old, new) = (music.join("Old"), music.join("New"));
791        std::fs::create_dir_all(&old).unwrap();
792        std::fs::create_dir_all(&new).unwrap();
793        test_utils::generate_wav(&old.join("a.wav"), 44100, 1, 0.2, 16);
794
795        let db = test_db(dir.path());
796        scan_folder(&db, &music, ScanOptions::default(), None);
797        test_utils::generate_wav(&new.join("b.wav"), 44100, 1, 0.2, 16);
798        // Not named, so not seen: this scan is not a walk of the library.
799        test_utils::generate_wav(&old.join("c.wav"), 44100, 1, 0.3, 16);
800
801        let r = scan_dirs(
802            &db,
803            std::slice::from_ref(&music),
804            std::slice::from_ref(&new),
805            ScanOptions::default(),
806            None,
807        );
808        assert_eq!((r.added, r.skipped, r.removed), (1, 0, 0), "{:?}", r.errors);
809        assert_eq!(track_paths(&db).len(), 2);
810    }
811
812    #[test]
813    fn scan_dirs_removes_what_left_a_directory_and_a_directory_that_left() {
814        let dir = tempfile::tempdir().unwrap();
815        let music = dir.path().join("music");
816        let (kept, gone) = (music.join("Artist/Kept"), music.join("Artist/Gone"));
817        std::fs::create_dir_all(&kept).unwrap();
818        std::fs::create_dir_all(&gone).unwrap();
819        for i in 0..3 {
820            test_utils::generate_wav(&kept.join(format!("{i}.wav")), 44100, 1, 0.2, 16);
821            test_utils::generate_wav(&gone.join(format!("{i}.wav")), 44100, 1, 0.3, 16);
822        }
823        let db = test_db(dir.path());
824        assert_eq!(
825            scan_folder(&db, &music, ScanOptions::default(), None).added,
826            6
827        );
828
829        std::fs::remove_file(kept.join("0.wav")).unwrap();
830        std::fs::remove_dir_all(&gone).unwrap();
831        let r = scan_dirs(
832            &db,
833            std::slice::from_ref(&music),
834            &[kept.clone(), gone.clone()],
835            ScanOptions::default(),
836            None,
837        );
838
839        assert_eq!((r.removed, r.skipped), (4, 2), "{:?}", r.errors);
840        let paths = track_paths(&db);
841        assert_eq!(paths.len(), 2, "{paths:#?}");
842        assert!(paths.iter().all(|p| Path::new(p).exists()), "{paths:#?}");
843    }
844
845    /// An unmounted volume leaves an empty mount point, under which every
846    /// directory reads as deleted.
847    #[test]
848    fn scan_dirs_removes_nothing_under_an_empty_library_folder() {
849        let dir = tempfile::tempdir().unwrap();
850        let music = dir.path().join("music");
851        let album = music.join("Album");
852        std::fs::create_dir_all(&album).unwrap();
853        test_utils::generate_wav(&album.join("a.wav"), 44100, 1, 0.2, 16);
854        let db = test_db(dir.path());
855        scan_folder(&db, &music, ScanOptions::default(), None);
856
857        std::fs::remove_dir_all(&album).unwrap();
858        let r = scan_dirs(
859            &db,
860            std::slice::from_ref(&music),
861            std::slice::from_ref(&album),
862            ScanOptions::default(),
863            None,
864        );
865        assert_eq!(r.removed, 0);
866        assert_eq!(track_paths(&db).len(), 1);
867    }
868
869    #[test]
870    fn scan_dirs_ignores_directories_outside_the_library() {
871        let dir = tempfile::tempdir().unwrap();
872        let music = dir.path().join("music");
873        let elsewhere = dir.path().join("elsewhere");
874        std::fs::create_dir_all(&music).unwrap();
875        std::fs::create_dir_all(&elsewhere).unwrap();
876        test_utils::generate_wav(&elsewhere.join("a.wav"), 44100, 1, 0.2, 16);
877        let db = test_db(dir.path());
878
879        let r = scan_dirs(
880            &db,
881            std::slice::from_ref(&music),
882            std::slice::from_ref(&elsewhere),
883            ScanOptions::default(),
884            None,
885        );
886        assert_eq!(r.added, 0);
887        assert!(track_paths(&db).is_empty());
888    }
889
890    #[test]
891    fn import_paths_indexes_files_where_they_lie() {
892        let dir = tempfile::tempdir().unwrap();
893        // Deliberately nothing to do with a library folder — this is the
894        // drag-a-rip-onto-the-queue case.
895        let drop = dir.path().join("Downloads/rip");
896        std::fs::create_dir_all(&drop).unwrap();
897        test_utils::generate_wav(&drop.join("01.wav"), 44100, 1, 0.2, 16);
898        test_utils::generate_wav(&drop.join("02.wav"), 44100, 1, 0.2, 16);
899        std::fs::write(drop.join("notes.txt"), b"not music").unwrap();
900
901        let db = test_db(dir.path());
902        let result = import_paths(&db, std::slice::from_ref(&drop));
903
904        assert_eq!(result.added, 2);
905        assert_eq!(result.track_ids.len(), 2, "errors: {:?}", result.errors);
906        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
907
908        // The rows point at where the files still are; organize is what moves them.
909        for id in &result.track_ids {
910            let row = queries::get_track_row(&db.conn, *id).unwrap().unwrap();
911            assert!(row.path.unwrap().starts_with(drop.to_str().unwrap()));
912        }
913    }
914
915    /// Dropping the same rip twice queues it again without duplicating rows.
916    #[test]
917    fn import_paths_is_idempotent() {
918        let dir = tempfile::tempdir().unwrap();
919        let drop = dir.path().join("rip");
920        std::fs::create_dir_all(&drop).unwrap();
921        test_utils::generate_wav(&drop.join("a.wav"), 44100, 1, 0.2, 16);
922
923        let db = test_db(dir.path());
924        let first = import_paths(&db, std::slice::from_ref(&drop));
925        let second = import_paths(&db, std::slice::from_ref(&drop));
926
927        assert_eq!(first.added, 1);
928        assert_eq!(second.added, 0);
929        assert_eq!(second.updated, 1);
930        assert_eq!(first.track_ids, second.track_ids);
931        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
932    }
933
934    /// A drop can name a folder and a file inside it; the file is imported once.
935    #[test]
936    fn import_paths_deduplicates_overlapping_selections() {
937        let dir = tempfile::tempdir().unwrap();
938        let drop = dir.path().join("rip");
939        std::fs::create_dir_all(&drop).unwrap();
940        let track = drop.join("a.wav");
941        test_utils::generate_wav(&track, 44100, 1, 0.2, 16);
942
943        let db = test_db(dir.path());
944        let result = import_paths(&db, &[drop.clone(), track.clone()]);
945
946        assert_eq!(result.track_ids.len(), 1);
947    }
948
949    #[test]
950    fn scan_folder_skips_unchanged_files() {
951        let dir = tempfile::tempdir().unwrap();
952        let music_dir = dir.path().join("music");
953        std::fs::create_dir_all(&music_dir).unwrap();
954
955        let wav_path = music_dir.join("unchanged.wav");
956        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
957
958        let db = test_db(dir.path());
959
960        // First scan: adds the file.
961        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
962        assert_eq!(r1.added, 1);
963
964        // Second scan: file unchanged, should be skipped.
965        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
966        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
967        assert_eq!(r2.added, 0, "no new files should be added");
968    }
969
970    #[test]
971    fn scan_folder_removes_deleted_tracks() {
972        let dir = tempfile::tempdir().unwrap();
973        let music_dir = dir.path().join("music");
974        std::fs::create_dir_all(&music_dir).unwrap();
975
976        let wav_path = music_dir.join("ephemeral.wav");
977        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
978        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
979
980        let db = test_db(dir.path());
981
982        // First scan: adds both files.
983        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
984        assert_eq!(r1.added, 2);
985
986        // Delete one of them.
987        std::fs::remove_file(&wav_path).unwrap();
988
989        // Second scan: should detect removal.
990        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
991        assert_eq!(
992            r2.removed, 1,
993            "expected 1 track removed after file deletion"
994        );
995
996        let stats = queries::library_stats(&db.conn).unwrap();
997        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
998    }
999
1000    #[test]
1001    fn empty_folder_does_not_wipe_the_library() {
1002        let dir = tempfile::tempdir().unwrap();
1003        let music_dir = dir.path().join("music");
1004        std::fs::create_dir_all(&music_dir).unwrap();
1005        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
1006        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
1007
1008        let db = test_db(dir.path());
1009        assert_eq!(
1010            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1011            2
1012        );
1013
1014        // The folder is still there but yields nothing — an unmounted NAS, a
1015        // detached volume, a Docker volume that failed to attach.
1016        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
1017        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
1018
1019        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1020        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
1021        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1022    }
1023
1024    #[cfg(unix)]
1025    #[test]
1026    fn unreadable_folder_is_not_a_deletion() {
1027        use std::os::unix::fs::PermissionsExt;
1028
1029        let dir = tempfile::tempdir().unwrap();
1030        let music_dir = dir.path().join("music");
1031        let locked_dir = music_dir.join("locked");
1032        std::fs::create_dir_all(&locked_dir).unwrap();
1033        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
1034        let locked_file = locked_dir.join("locked.wav");
1035        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
1036
1037        let db = test_db(dir.path());
1038        assert_eq!(
1039            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
1040            2
1041        );
1042
1043        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
1044        if locked_file.try_exists().is_ok() {
1045            // Running as root — the permission bits mean nothing here.
1046            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1047            return;
1048        }
1049
1050        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1051        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
1052
1053        assert!(
1054            r.unreadable >= 1,
1055            "the unreadable subtree should be counted"
1056        );
1057        assert_eq!(r.removed, 0, "an IO error is not a deletion");
1058        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
1059    }
1060
1061    #[test]
1062    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
1063        let dir = tempfile::tempdir().unwrap();
1064        let music_dir = dir.path().join("music");
1065        std::fs::create_dir_all(&music_dir).unwrap();
1066        for i in 0..6 {
1067            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
1068        }
1069
1070        let db = test_db(dir.path());
1071
1072        // Abort partway through the second chunk, the way Ctrl-C would.
1073        let seen = std::cell::Cell::new(0usize);
1074        let abort = |_: ScanEvent| {
1075            seen.set(seen.get() + 1);
1076            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
1077        };
1078        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1079            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
1080        }));
1081        assert!(panicked.is_err());
1082
1083        // The first chunk is on disk; the interrupted one is not.
1084        assert_eq!(
1085            queries::library_stats(&db.conn).unwrap().total_tracks,
1086            CHUNK_SIZE as i64
1087        );
1088
1089        // And the next run picks up where it left off instead of restarting.
1090        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1091        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
1092        assert_eq!(r.added, 6 - CHUNK_SIZE);
1093        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
1094    }
1095
1096    #[test]
1097    fn failing_file_is_named_and_the_scan_continues() {
1098        let dir = tempfile::tempdir().unwrap();
1099        let music_dir = dir.path().join("music");
1100        std::fs::create_dir_all(&music_dir).unwrap();
1101        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
1102        let broken = music_dir.join("broken.flac");
1103        std::fs::write(&broken, b"").unwrap();
1104
1105        let db = test_db(dir.path());
1106        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1107
1108        assert_eq!(r.added, 1, "the good file must still be indexed");
1109        assert_eq!(r.errors.len(), 1);
1110        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
1111        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
1112    }
1113
1114    #[test]
1115    fn a_panicking_tag_read_becomes_an_error() {
1116        let path = Path::new("/music/hostile.mp3");
1117        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
1118        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
1119    }
1120
1121    #[test]
1122    fn scan_folder_updates_modified_files() {
1123        let dir = tempfile::tempdir().unwrap();
1124        let music_dir = dir.path().join("music");
1125        std::fs::create_dir_all(&music_dir).unwrap();
1126
1127        let wav_path = music_dir.join("modified.wav");
1128        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
1129
1130        let db = test_db(dir.path());
1131
1132        // First scan.
1133        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1134        assert_eq!(r1.added, 1);
1135
1136        // Modify the file (rewrite with different duration → different size + mtime).
1137        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
1138        std::thread::sleep(std::time::Duration::from_millis(1100));
1139        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
1140
1141        // Second scan: should detect the modification.
1142        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1143        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
1144        assert_eq!(r2.added, 0, "the row already exists");
1145        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
1146    }
1147
1148    #[test]
1149    fn a_drop_and_a_scan_spell_a_file_the_same_way() {
1150        use unicode_normalization::UnicodeNormalization;
1151        let dir = tempfile::tempdir().unwrap();
1152        let nfd: String = "Roman Flügel".nfd().collect();
1153        let nfc: String = "Roman Flügel".nfc().collect();
1154        let music_dir = dir.path().join(&nfd);
1155        std::fs::create_dir_all(&music_dir).unwrap();
1156        test_utils::generate_wav(&music_dir.join("softice.wav"), 44100, 1, 0.2, 16);
1157        let db = test_db(dir.path());
1158
1159        // Dropped from Finder: the path arrives precomposed.
1160        let dropped = import_paths(&db, &[dir.path().join(&nfc).join("softice.wav")]);
1161        assert_eq!(dropped.added, 1, "errors: {:?}", dropped.errors);
1162
1163        // Rescanned from the folder: the walker reads the directory's own bytes.
1164        let scanned = scan_folder(&db, &music_dir, ScanOptions::default(), None);
1165        assert_eq!(scanned.added, 0, "the same file, not a second one");
1166
1167        let stats = queries::library_stats(&db.conn).unwrap();
1168        assert_eq!(stats.total_tracks, 1);
1169        let stored: String = db
1170            .conn
1171            .query_row("SELECT path FROM tracks", [], |r| r.get(0))
1172            .unwrap();
1173        assert_eq!(stored, music_dir.join("softice.wav").to_string_lossy());
1174    }
1175}