Skip to main content

koan_core/index/
scanner.rs

1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::path::{Path, PathBuf};
3use std::time::UNIX_EPOCH;
4
5use rayon::prelude::*;
6
7use crate::db::connection::Database;
8use crate::db::queries::{self, TrackMeta};
9
10use super::features;
11use super::metadata::{self, is_audio_file};
12
13/// Result of a folder scan.
14#[derive(Debug, Default)]
15pub struct ScanResult {
16    pub added: usize,
17    pub updated: usize,
18    pub removed: usize,
19    pub skipped: usize,
20    /// Directory entries walkdir could not read — unreadable subtrees, symlink
21    /// loops. Their contents are absent from the scan entirely.
22    pub unreadable: usize,
23    /// Paths of the tracks deleted or demoted to remote-only, so a caller can
24    /// show what a removal actually took.
25    pub removed_paths: Vec<String>,
26    pub errors: Vec<(PathBuf, String)>,
27}
28
29/// How a scan should behave.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct ScanOptions {
32    /// Re-read tags for every file, ignoring `scan_cache`.
33    pub force: bool,
34    /// Delete stale tracks even when the proportion missing looks like a mount
35    /// failure. Lifts the removal-fraction brake only — a folder that yields no
36    /// audio files is still left alone, and an IO error still never counts as
37    /// "file gone".
38    pub force_remove: bool,
39}
40
41/// Files per transaction. Bounds peak memory (only one chunk's metadata is
42/// resident) and caps what an interrupted scan loses; committed chunks land in
43/// `scan_cache`, so the next run resumes rather than restarting.
44#[cfg(not(test))]
45const CHUNK_SIZE: usize = 1000;
46#[cfg(test)]
47const CHUNK_SIZE: usize = 4;
48
49/// Info about a scanned track, passed to the progress callback.
50pub struct ScanEvent<'a> {
51    pub artist: &'a str,
52    pub album: &'a str,
53    pub title: &'a str,
54    pub path: &'a Path,
55    pub is_new: bool,
56}
57
58/// Scan a folder recursively for audio files and index them into the database.
59/// The optional `on_track` callback is invoked for each successfully indexed track.
60pub fn scan_folder(
61    db: &Database,
62    path: &Path,
63    opts: ScanOptions,
64    on_track: Option<&dyn Fn(ScanEvent)>,
65) -> ScanResult {
66    let mut result = ScanResult::default();
67
68    // Collect audio files via walkdir. `follow_links` means a symlink pointing at
69    // a sibling directory inside the library indexes its files under both paths.
70    let mut audio_files: Vec<PathBuf> = Vec::new();
71    for entry in walkdir::WalkDir::new(path).follow_links(true) {
72        match entry {
73            Ok(e) if e.file_type().is_file() && is_audio_file(e.path()) => {
74                audio_files.push(e.path().to_path_buf())
75            }
76            Ok(_) => {}
77            Err(e) => {
78                result.unreadable += 1;
79                log::warn!("skipping unreadable entry under {}: {}", path.display(), e);
80            }
81        }
82    }
83
84    let total_files = audio_files.len();
85    log::info!("found {} audio files in {}", total_files, path.display());
86
87    // Filter to files that need scanning.
88    // Batch-load the entire scan_cache into a HashMap to avoid O(N) individual
89    // DB lookups (one per file). For 100k+ file libraries this is dramatically faster.
90    let files_to_scan: Vec<PathBuf> = if opts.force {
91        std::mem::take(&mut audio_files)
92    } else {
93        let scan_cache = queries::load_scan_cache(&db.conn).unwrap_or_default();
94        audio_files
95            .iter()
96            .filter(|file_path| {
97                let Ok(file_meta) = std::fs::metadata(file_path) else {
98                    return true;
99                };
100                let mtime = file_meta
101                    .modified()
102                    .ok()
103                    .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
104                    .map(|d| d.as_secs() as i64)
105                    .unwrap_or(0);
106                let size = file_meta.len() as i64;
107                let path_str = file_path.to_string_lossy();
108                match scan_cache.get(path_str.as_ref()) {
109                    Some(&(cached_mtime, cached_size)) => {
110                        mtime != cached_mtime || size != cached_size
111                    }
112                    None => true,
113                }
114            })
115            .cloned()
116            .collect()
117    };
118
119    result.skipped = total_files - files_to_scan.len();
120
121    // Tag reads and database writes run at the same time.
122    //
123    // Reading the whole library up front and writing it in one transaction
124    // blocks every other writer for the length of the scan and loses all of it
125    // on interrupt, so writes stay chunked. But doing that as read-chunk,
126    // write-chunk, read-chunk leaves the disk idle for every write and the CPU
127    // idle for every read — on a library of any size that is most of the run.
128    //
129    // Instead the reads stream: a worker pool walks every file and pushes
130    // results down a bounded channel while this thread batches them into
131    // transactions. The bound is what caps memory, in place of the chunking.
132    let (send, recv) = crossbeam_channel::bounded::<(PathBuf, Result<TrackMeta, String>)>(
133        CHUNK_SIZE.saturating_mul(2),
134    );
135    let reader = std::thread::Builder::new()
136        .name("koan-scan-read".into())
137        .spawn(move || {
138            files_to_scan.par_iter().for_each(|file_path| {
139                // A send error means the consumer is gone; nothing left to do.
140                let _ = send.send((
141                    file_path.clone(),
142                    isolate_read(file_path, metadata::read_metadata),
143                ));
144            });
145        });
146    if let Err(e) = &reader {
147        log::error!("failed to spawn scan reader: {}", e);
148        result
149            .errors
150            .push((path.to_path_buf(), format!("scan error: {}", e)));
151        return result;
152    }
153
154    loop {
155        // Blocks until a full batch is ready or the readers have finished.
156        let batch: Vec<(PathBuf, Result<TrackMeta, String>)> =
157            recv.iter().take(CHUNK_SIZE).collect();
158        if batch.is_empty() {
159            break;
160        }
161
162        let tx = match db.conn.unchecked_transaction() {
163            Ok(tx) => tx,
164            Err(e) => {
165                log::error!("failed to begin scan transaction: {}", e);
166                result
167                    .errors
168                    .push((path.to_path_buf(), format!("db error: {}", e)));
169                return result;
170            }
171        };
172
173        let (mut added, mut updated) = (0usize, 0usize);
174        for (file_path, meta_result) in batch {
175            match meta_result {
176                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
177                    Ok((track_id, is_new)) => {
178                        if is_new {
179                            added += 1;
180                        } else {
181                            updated += 1;
182                        }
183                        if let Some(cb) = &on_track {
184                            cb(ScanEvent {
185                                artist: &meta.artist,
186                                album: &meta.album,
187                                title: &meta.title,
188                                path: &file_path,
189                                is_new,
190                            });
191                        }
192                        if let Err(e) = queries::update_scan_cache(
193                            &tx,
194                            meta.path.as_deref().unwrap_or(""),
195                            meta.mtime.unwrap_or(0),
196                            meta.size_bytes.unwrap_or(0),
197                            track_id,
198                        ) {
199                            // Not fatal, but every future scan re-reads this file's tags.
200                            log::warn!("failed to cache {}: {}", file_path.display(), e);
201                        }
202                    }
203                    Err(e) => {
204                        result.errors.push((file_path, format!("db error: {}", e)));
205                    }
206                },
207                Err(e) => {
208                    result.errors.push((file_path, e));
209                }
210            }
211        }
212
213        match tx.commit() {
214            Ok(()) => {
215                result.added += added;
216                result.updated += updated;
217            }
218            Err(e) => {
219                log::error!("failed to commit scan transaction: {}", e);
220                result
221                    .errors
222                    .push((path.to_path_buf(), format!("db error: {}", e)));
223            }
224        }
225    }
226
227    if let Ok(handle) = reader
228        && handle.join().is_err()
229    {
230        log::error!("scan reader thread panicked");
231    }
232
233    // Remove tracks for files that no longer exist. A folder that yielded nothing
234    // is far more likely to be an unmounted volume than a library someone emptied,
235    // and stale rows are recoverable where deleted play history is not.
236    if total_files == 0 {
237        log::error!(
238            "{} contains no audio files — skipping stale-track removal. \
239             If this folder should have music in it, it is probably not mounted or not readable.",
240            path.display()
241        );
242        return result;
243    }
244
245    let tx = match db.conn.unchecked_transaction() {
246        Ok(tx) => tx,
247        Err(e) => {
248            log::error!("failed to begin stale-removal transaction: {}", e);
249            result
250                .errors
251                .push((path.to_path_buf(), format!("db error: {}", e)));
252            return result;
253        }
254    };
255    match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
256        Ok(removed) => {
257            result.removed = removed.len();
258            result.removed_paths = removed;
259            if let Err(e) = tx.commit() {
260                log::error!("failed to commit stale removals: {}", e);
261                result.removed = 0;
262                result.removed_paths.clear();
263                result
264                    .errors
265                    .push((path.to_path_buf(), format!("db error: {}", e)));
266            }
267        }
268        Err(e) => {
269            log::error!("failed to remove stale tracks: {}", e);
270            result.errors.push((path.to_path_buf(), e.to_string()));
271        }
272    }
273
274    result
275}
276
277/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
278/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
279/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
280/// the whole scan over one file and not even name it.
281fn isolate_read(
282    path: &Path,
283    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
284) -> Result<TrackMeta, String> {
285    match catch_unwind(AssertUnwindSafe(|| read(path))) {
286        Ok(result) => result.map_err(|e| e.to_string()),
287        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
288    }
289}
290
291/// Scan all configured library folders.
292pub fn full_scan(
293    db: &Database,
294    folders: &[PathBuf],
295    opts: ScanOptions,
296    on_track: Option<&dyn Fn(ScanEvent)>,
297) -> ScanResult {
298    let mut total = ScanResult::default();
299    for folder in folders {
300        if !folder.exists() {
301            log::warn!("library folder does not exist: {}", folder.display());
302            continue;
303        }
304        let r = scan_folder(db, folder, opts, on_track);
305        total.added += r.added;
306        total.updated += r.updated;
307        total.removed += r.removed;
308        total.skipped += r.skipped;
309        total.unreadable += r.unreadable;
310        total.removed_paths.extend(r.removed_paths);
311        total.errors.extend(r.errors);
312    }
313    total
314}
315
316/// Info about an analyzed track, passed to the progress callback.
317pub struct AnalysisEvent<'a> {
318    pub path: &'a str,
319    pub success: bool,
320    pub current: usize,
321    pub total: usize,
322}
323
324/// Run acoustic analysis on all tracks missing vectors.
325/// Uses rayon for parallel analysis, stores results sequentially.
326pub fn analyze_missing(
327    db: &Database,
328    on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
329) -> (usize, usize) {
330    let missing = match queries::tracks_missing_vectors(&db.conn) {
331        Ok(m) => m,
332        Err(e) => {
333            log::error!("failed to query missing vectors: {}", e);
334            return (0, 0);
335        }
336    };
337
338    if missing.is_empty() {
339        return (0, 0);
340    }
341
342    let total = missing.len();
343    log::info!("analyzing {} tracks for acoustic features", total);
344
345    // Analyze in parallel.
346    let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
347        .par_iter()
348        .enumerate()
349        .map(|(i, (track_id, path))| {
350            let result = match catch_unwind(AssertUnwindSafe(|| {
351                features::analyze_track(Path::new(path))
352            })) {
353                Ok(r) => r,
354                Err(_) => Err(features::AnalysisError::Bliss(format!(
355                    "panicked while analyzing {}",
356                    path
357                ))),
358            };
359            if let Some(cb) = &on_track {
360                cb(AnalysisEvent {
361                    path,
362                    success: result.is_ok(),
363                    current: i + 1,
364                    total,
365                });
366            }
367            (*track_id, path.clone(), result)
368        })
369        .collect();
370
371    // Store sequentially.
372    let mut analyzed = 0usize;
373    let mut errors = 0usize;
374    let tx = match db.conn.unchecked_transaction() {
375        Ok(tx) => tx,
376        Err(e) => {
377            log::error!("failed to begin analysis transaction: {}", e);
378            return (0, 0);
379        }
380    };
381    for (track_id, path, result) in results {
382        match result {
383            Ok(embedding) => {
384                if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
385                    log::warn!("failed to store vector for {}: {}", path, e);
386                    errors += 1;
387                } else {
388                    analyzed += 1;
389                }
390            }
391            Err(e) => {
392                log::warn!("analysis failed for {}: {}", path, e);
393                errors += 1;
394            }
395        }
396    }
397    if let Err(e) = tx.commit() {
398        log::error!("failed to commit analysis transaction: {}", e);
399    }
400
401    log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
402    (analyzed, errors)
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use crate::db::connection::Database;
409    use crate::db::queries;
410    use crate::test_utils;
411
412    fn test_db(dir: &Path) -> Database {
413        let db_path = dir.join("test.db");
414        Database::open(&db_path).unwrap()
415    }
416
417    #[test]
418    fn scan_folder_indexes_new_files() {
419        let dir = tempfile::tempdir().unwrap();
420        let music_dir = dir.path().join("music");
421        std::fs::create_dir_all(&music_dir).unwrap();
422
423        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
424        let wav_path = music_dir.join("silence.wav");
425        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
426
427        let db = test_db(dir.path());
428        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
429
430        assert_eq!(result.added, 1, "expected 1 track added");
431        assert_eq!(result.skipped, 0);
432        assert_eq!(result.removed, 0);
433        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
434
435        // Verify the track exists in the DB.
436        let stats = queries::library_stats(&db.conn).unwrap();
437        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
438    }
439
440    #[test]
441    fn scan_folder_skips_unchanged_files() {
442        let dir = tempfile::tempdir().unwrap();
443        let music_dir = dir.path().join("music");
444        std::fs::create_dir_all(&music_dir).unwrap();
445
446        let wav_path = music_dir.join("unchanged.wav");
447        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
448
449        let db = test_db(dir.path());
450
451        // First scan: adds the file.
452        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
453        assert_eq!(r1.added, 1);
454
455        // Second scan: file unchanged, should be skipped.
456        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
457        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
458        assert_eq!(r2.added, 0, "no new files should be added");
459    }
460
461    #[test]
462    fn scan_folder_removes_deleted_tracks() {
463        let dir = tempfile::tempdir().unwrap();
464        let music_dir = dir.path().join("music");
465        std::fs::create_dir_all(&music_dir).unwrap();
466
467        let wav_path = music_dir.join("ephemeral.wav");
468        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
469        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
470
471        let db = test_db(dir.path());
472
473        // First scan: adds both files.
474        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
475        assert_eq!(r1.added, 2);
476
477        // Delete one of them.
478        std::fs::remove_file(&wav_path).unwrap();
479
480        // Second scan: should detect removal.
481        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
482        assert_eq!(
483            r2.removed, 1,
484            "expected 1 track removed after file deletion"
485        );
486
487        let stats = queries::library_stats(&db.conn).unwrap();
488        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
489    }
490
491    #[test]
492    fn empty_folder_does_not_wipe_the_library() {
493        let dir = tempfile::tempdir().unwrap();
494        let music_dir = dir.path().join("music");
495        std::fs::create_dir_all(&music_dir).unwrap();
496        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
497        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
498
499        let db = test_db(dir.path());
500        assert_eq!(
501            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
502            2
503        );
504
505        // The folder is still there but yields nothing — an unmounted NAS, a
506        // detached volume, a Docker volume that failed to attach.
507        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
508        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
509
510        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
511        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
512        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
513    }
514
515    #[cfg(unix)]
516    #[test]
517    fn unreadable_folder_is_not_a_deletion() {
518        use std::os::unix::fs::PermissionsExt;
519
520        let dir = tempfile::tempdir().unwrap();
521        let music_dir = dir.path().join("music");
522        let locked_dir = music_dir.join("locked");
523        std::fs::create_dir_all(&locked_dir).unwrap();
524        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
525        let locked_file = locked_dir.join("locked.wav");
526        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
527
528        let db = test_db(dir.path());
529        assert_eq!(
530            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
531            2
532        );
533
534        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
535        if locked_file.try_exists().is_ok() {
536            // Running as root — the permission bits mean nothing here.
537            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
538            return;
539        }
540
541        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
542        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
543
544        assert!(
545            r.unreadable >= 1,
546            "the unreadable subtree should be counted"
547        );
548        assert_eq!(r.removed, 0, "an IO error is not a deletion");
549        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
550    }
551
552    #[test]
553    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
554        let dir = tempfile::tempdir().unwrap();
555        let music_dir = dir.path().join("music");
556        std::fs::create_dir_all(&music_dir).unwrap();
557        for i in 0..6 {
558            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
559        }
560
561        let db = test_db(dir.path());
562
563        // Abort partway through the second chunk, the way Ctrl-C would.
564        let seen = std::cell::Cell::new(0usize);
565        let abort = |_: ScanEvent| {
566            seen.set(seen.get() + 1);
567            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
568        };
569        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
570            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
571        }));
572        assert!(panicked.is_err());
573
574        // The first chunk is on disk; the interrupted one is not.
575        assert_eq!(
576            queries::library_stats(&db.conn).unwrap().total_tracks,
577            CHUNK_SIZE as i64
578        );
579
580        // And the next run picks up where it left off instead of restarting.
581        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
582        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
583        assert_eq!(r.added, 6 - CHUNK_SIZE);
584        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
585    }
586
587    #[test]
588    fn failing_file_is_named_and_the_scan_continues() {
589        let dir = tempfile::tempdir().unwrap();
590        let music_dir = dir.path().join("music");
591        std::fs::create_dir_all(&music_dir).unwrap();
592        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
593        let broken = music_dir.join("broken.flac");
594        std::fs::write(&broken, b"").unwrap();
595
596        let db = test_db(dir.path());
597        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
598
599        assert_eq!(r.added, 1, "the good file must still be indexed");
600        assert_eq!(r.errors.len(), 1);
601        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
602        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
603    }
604
605    #[test]
606    fn a_panicking_tag_read_becomes_an_error() {
607        let path = Path::new("/music/hostile.mp3");
608        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
609        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
610    }
611
612    #[test]
613    fn scan_folder_updates_modified_files() {
614        let dir = tempfile::tempdir().unwrap();
615        let music_dir = dir.path().join("music");
616        std::fs::create_dir_all(&music_dir).unwrap();
617
618        let wav_path = music_dir.join("modified.wav");
619        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
620
621        let db = test_db(dir.path());
622
623        // First scan.
624        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
625        assert_eq!(r1.added, 1);
626
627        // Modify the file (rewrite with different duration → different size + mtime).
628        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
629        std::thread::sleep(std::time::Duration::from_millis(1100));
630        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
631
632        // Second scan: should detect the modification.
633        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
634        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
635        assert_eq!(r2.added, 0, "the row already exists");
636        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
637    }
638}