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