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    // One transaction per chunk. Reading tags for the whole library up front then
122    // writing it in a single transaction blocks every other writer for the length
123    // of the scan and throws away all of it on interrupt.
124    for chunk in files_to_scan.chunks(CHUNK_SIZE) {
125        // Read metadata in parallel (CPU-bound tag parsing — no DB access here).
126        let metadata_results: Vec<(PathBuf, Result<TrackMeta, String>)> = chunk
127            .par_iter()
128            .map(|file_path| {
129                (
130                    file_path.clone(),
131                    isolate_read(file_path, metadata::read_metadata),
132                )
133            })
134            .collect();
135
136        let tx = match db.conn.unchecked_transaction() {
137            Ok(tx) => tx,
138            Err(e) => {
139                log::error!("failed to begin scan transaction: {}", e);
140                result
141                    .errors
142                    .push((path.to_path_buf(), format!("db error: {}", e)));
143                return result;
144            }
145        };
146
147        let (mut added, mut updated) = (0usize, 0usize);
148        for (file_path, meta_result) in metadata_results {
149            match meta_result {
150                Ok(meta) => match queries::upsert_track_status(&tx, &meta) {
151                    Ok((track_id, is_new)) => {
152                        if is_new {
153                            added += 1;
154                        } else {
155                            updated += 1;
156                        }
157                        if let Some(cb) = &on_track {
158                            cb(ScanEvent {
159                                artist: &meta.artist,
160                                album: &meta.album,
161                                title: &meta.title,
162                                path: &file_path,
163                                is_new,
164                            });
165                        }
166                        if let Err(e) = queries::update_scan_cache(
167                            &tx,
168                            meta.path.as_deref().unwrap_or(""),
169                            meta.mtime.unwrap_or(0),
170                            meta.size_bytes.unwrap_or(0),
171                            track_id,
172                        ) {
173                            // Not fatal, but every future scan re-reads this file's tags.
174                            log::warn!("failed to cache {}: {}", file_path.display(), e);
175                        }
176                    }
177                    Err(e) => {
178                        result.errors.push((file_path, format!("db error: {}", e)));
179                    }
180                },
181                Err(e) => {
182                    result.errors.push((file_path, e));
183                }
184            }
185        }
186
187        match tx.commit() {
188            Ok(()) => {
189                result.added += added;
190                result.updated += updated;
191            }
192            Err(e) => {
193                log::error!("failed to commit scan transaction: {}", e);
194                result
195                    .errors
196                    .push((path.to_path_buf(), format!("db error: {}", e)));
197            }
198        }
199    }
200
201    // Remove tracks for files that no longer exist. A folder that yielded nothing
202    // is far more likely to be an unmounted volume than a library someone emptied,
203    // and stale rows are recoverable where deleted play history is not.
204    if total_files == 0 {
205        log::error!(
206            "{} contains no audio files — skipping stale-track removal. \
207             If this folder should have music in it, it is probably not mounted or not readable.",
208            path.display()
209        );
210        return result;
211    }
212
213    let tx = match db.conn.unchecked_transaction() {
214        Ok(tx) => tx,
215        Err(e) => {
216            log::error!("failed to begin stale-removal transaction: {}", e);
217            result
218                .errors
219                .push((path.to_path_buf(), format!("db error: {}", e)));
220            return result;
221        }
222    };
223    match queries::remove_stale_tracks(&tx, path, opts.force_remove) {
224        Ok(removed) => {
225            result.removed = removed.len();
226            result.removed_paths = removed;
227            if let Err(e) = tx.commit() {
228                log::error!("failed to commit stale removals: {}", e);
229                result.removed = 0;
230                result.removed_paths.clear();
231                result
232                    .errors
233                    .push((path.to_path_buf(), format!("db error: {}", e)));
234            }
235        }
236        Err(e) => {
237            log::error!("failed to remove stale tracks: {}", e);
238            result.errors.push((path.to_path_buf(), e.to_string()));
239        }
240    }
241
242    result
243}
244
245/// Run a tag read, containing a panic from the parsers. Hostile input (a bogus
246/// ID3v2 frame size, a pathological MP4 atom tree) can panic inside lofty or
247/// symphonia; rayon re-raises that at `collect()`, which would otherwise abort
248/// the whole scan over one file and not even name it.
249fn isolate_read(
250    path: &Path,
251    read: impl FnOnce(&Path) -> Result<TrackMeta, metadata::MetadataError>,
252) -> Result<TrackMeta, String> {
253    match catch_unwind(AssertUnwindSafe(|| read(path))) {
254        Ok(result) => result.map_err(|e| e.to_string()),
255        Err(_) => Err(format!("panicked while reading tags: {}", path.display())),
256    }
257}
258
259/// Scan all configured library folders.
260pub fn full_scan(
261    db: &Database,
262    folders: &[PathBuf],
263    opts: ScanOptions,
264    on_track: Option<&dyn Fn(ScanEvent)>,
265) -> ScanResult {
266    let mut total = ScanResult::default();
267    for folder in folders {
268        if !folder.exists() {
269            log::warn!("library folder does not exist: {}", folder.display());
270            continue;
271        }
272        let r = scan_folder(db, folder, opts, on_track);
273        total.added += r.added;
274        total.updated += r.updated;
275        total.removed += r.removed;
276        total.skipped += r.skipped;
277        total.unreadable += r.unreadable;
278        total.removed_paths.extend(r.removed_paths);
279        total.errors.extend(r.errors);
280    }
281    total
282}
283
284/// Info about an analyzed track, passed to the progress callback.
285pub struct AnalysisEvent<'a> {
286    pub path: &'a str,
287    pub success: bool,
288    pub current: usize,
289    pub total: usize,
290}
291
292/// Run acoustic analysis on all tracks missing vectors.
293/// Uses rayon for parallel analysis, stores results sequentially.
294pub fn analyze_missing(
295    db: &Database,
296    on_track: Option<&(dyn Fn(AnalysisEvent) + Sync)>,
297) -> (usize, usize) {
298    let missing = match queries::tracks_missing_vectors(&db.conn) {
299        Ok(m) => m,
300        Err(e) => {
301            log::error!("failed to query missing vectors: {}", e);
302            return (0, 0);
303        }
304    };
305
306    if missing.is_empty() {
307        return (0, 0);
308    }
309
310    let total = missing.len();
311    log::info!("analyzing {} tracks for acoustic features", total);
312
313    // Analyze in parallel.
314    let results: Vec<(i64, String, Result<Vec<f32>, features::AnalysisError>)> = missing
315        .par_iter()
316        .enumerate()
317        .map(|(i, (track_id, path))| {
318            let result = match catch_unwind(AssertUnwindSafe(|| {
319                features::analyze_track(Path::new(path))
320            })) {
321                Ok(r) => r,
322                Err(_) => Err(features::AnalysisError::Bliss(format!(
323                    "panicked while analyzing {}",
324                    path
325                ))),
326            };
327            if let Some(cb) = &on_track {
328                cb(AnalysisEvent {
329                    path,
330                    success: result.is_ok(),
331                    current: i + 1,
332                    total,
333                });
334            }
335            (*track_id, path.clone(), result)
336        })
337        .collect();
338
339    // Store sequentially.
340    let mut analyzed = 0usize;
341    let mut errors = 0usize;
342    let tx = match db.conn.unchecked_transaction() {
343        Ok(tx) => tx,
344        Err(e) => {
345            log::error!("failed to begin analysis transaction: {}", e);
346            return (0, 0);
347        }
348    };
349    for (track_id, path, result) in results {
350        match result {
351            Ok(embedding) => {
352                if let Err(e) = queries::store_vector(&tx, track_id, &embedding) {
353                    log::warn!("failed to store vector for {}: {}", path, e);
354                    errors += 1;
355                } else {
356                    analyzed += 1;
357                }
358            }
359            Err(e) => {
360                log::warn!("analysis failed for {}: {}", path, e);
361                errors += 1;
362            }
363        }
364    }
365    if let Err(e) = tx.commit() {
366        log::error!("failed to commit analysis transaction: {}", e);
367    }
368
369    log::info!("analysis complete: {} ok, {} errors", analyzed, errors);
370    (analyzed, errors)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::db::connection::Database;
377    use crate::db::queries;
378    use crate::test_utils;
379
380    fn test_db(dir: &Path) -> Database {
381        let db_path = dir.join("test.db");
382        Database::open(&db_path).unwrap()
383    }
384
385    #[test]
386    fn scan_folder_indexes_new_files() {
387        let dir = tempfile::tempdir().unwrap();
388        let music_dir = dir.path().join("music");
389        std::fs::create_dir_all(&music_dir).unwrap();
390
391        // Generate a valid WAV file (1 second, 44100 Hz, mono, 16-bit).
392        let wav_path = music_dir.join("silence.wav");
393        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
394
395        let db = test_db(dir.path());
396        let result = scan_folder(&db, &music_dir, ScanOptions::default(), None);
397
398        assert_eq!(result.added, 1, "expected 1 track added");
399        assert_eq!(result.skipped, 0);
400        assert_eq!(result.removed, 0);
401        assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
402
403        // Verify the track exists in the DB.
404        let stats = queries::library_stats(&db.conn).unwrap();
405        assert_eq!(stats.total_tracks, 1, "expected 1 track in DB");
406    }
407
408    #[test]
409    fn scan_folder_skips_unchanged_files() {
410        let dir = tempfile::tempdir().unwrap();
411        let music_dir = dir.path().join("music");
412        std::fs::create_dir_all(&music_dir).unwrap();
413
414        let wav_path = music_dir.join("unchanged.wav");
415        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
416
417        let db = test_db(dir.path());
418
419        // First scan: adds the file.
420        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
421        assert_eq!(r1.added, 1);
422
423        // Second scan: file unchanged, should be skipped.
424        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
425        assert_eq!(r2.skipped, 1, "expected unchanged file to be skipped");
426        assert_eq!(r2.added, 0, "no new files should be added");
427    }
428
429    #[test]
430    fn scan_folder_removes_deleted_tracks() {
431        let dir = tempfile::tempdir().unwrap();
432        let music_dir = dir.path().join("music");
433        std::fs::create_dir_all(&music_dir).unwrap();
434
435        let wav_path = music_dir.join("ephemeral.wav");
436        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
437        test_utils::generate_wav(&music_dir.join("keeper.wav"), 44100, 1, 1.0, 16);
438
439        let db = test_db(dir.path());
440
441        // First scan: adds both files.
442        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
443        assert_eq!(r1.added, 2);
444
445        // Delete one of them.
446        std::fs::remove_file(&wav_path).unwrap();
447
448        // Second scan: should detect removal.
449        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
450        assert_eq!(
451            r2.removed, 1,
452            "expected 1 track removed after file deletion"
453        );
454
455        let stats = queries::library_stats(&db.conn).unwrap();
456        assert_eq!(stats.total_tracks, 1, "the surviving file must be kept");
457    }
458
459    #[test]
460    fn empty_folder_does_not_wipe_the_library() {
461        let dir = tempfile::tempdir().unwrap();
462        let music_dir = dir.path().join("music");
463        std::fs::create_dir_all(&music_dir).unwrap();
464        test_utils::generate_wav(&music_dir.join("a.wav"), 44100, 1, 1.0, 16);
465        test_utils::generate_wav(&music_dir.join("b.wav"), 44100, 1, 1.0, 16);
466
467        let db = test_db(dir.path());
468        assert_eq!(
469            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
470            2
471        );
472
473        // The folder is still there but yields nothing — an unmounted NAS, a
474        // detached volume, a Docker volume that failed to attach.
475        std::fs::remove_file(music_dir.join("a.wav")).unwrap();
476        std::fs::remove_file(music_dir.join("b.wav")).unwrap();
477
478        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
479        assert_eq!(r.removed, 0, "stale removal must be skipped entirely");
480        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
481    }
482
483    #[cfg(unix)]
484    #[test]
485    fn unreadable_folder_is_not_a_deletion() {
486        use std::os::unix::fs::PermissionsExt;
487
488        let dir = tempfile::tempdir().unwrap();
489        let music_dir = dir.path().join("music");
490        let locked_dir = music_dir.join("locked");
491        std::fs::create_dir_all(&locked_dir).unwrap();
492        test_utils::generate_wav(&music_dir.join("keep.wav"), 44100, 1, 1.0, 16);
493        let locked_file = locked_dir.join("locked.wav");
494        test_utils::generate_wav(&locked_file, 44100, 1, 1.0, 16);
495
496        let db = test_db(dir.path());
497        assert_eq!(
498            scan_folder(&db, &music_dir, ScanOptions::default(), None).added,
499            2
500        );
501
502        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o000)).unwrap();
503        if locked_file.try_exists().is_ok() {
504            // Running as root — the permission bits mean nothing here.
505            std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
506            return;
507        }
508
509        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
510        std::fs::set_permissions(&locked_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
511
512        assert!(
513            r.unreadable >= 1,
514            "the unreadable subtree should be counted"
515        );
516        assert_eq!(r.removed, 0, "an IO error is not a deletion");
517        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 2);
518    }
519
520    #[test]
521    fn interrupted_scan_keeps_committed_chunks_and_resumes() {
522        let dir = tempfile::tempdir().unwrap();
523        let music_dir = dir.path().join("music");
524        std::fs::create_dir_all(&music_dir).unwrap();
525        for i in 0..6 {
526            test_utils::generate_wav(&music_dir.join(format!("{}.wav", i)), 44100, 1, 1.0, 16);
527        }
528
529        let db = test_db(dir.path());
530
531        // Abort partway through the second chunk, the way Ctrl-C would.
532        let seen = std::cell::Cell::new(0usize);
533        let abort = |_: ScanEvent| {
534            seen.set(seen.get() + 1);
535            assert!(seen.get() <= CHUNK_SIZE, "simulated interrupt");
536        };
537        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
538            scan_folder(&db, &music_dir, ScanOptions::default(), Some(&abort));
539        }));
540        assert!(panicked.is_err());
541
542        // The first chunk is on disk; the interrupted one is not.
543        assert_eq!(
544            queries::library_stats(&db.conn).unwrap().total_tracks,
545            CHUNK_SIZE as i64
546        );
547
548        // And the next run picks up where it left off instead of restarting.
549        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
550        assert_eq!(r.skipped, CHUNK_SIZE, "committed files should be cached");
551        assert_eq!(r.added, 6 - CHUNK_SIZE);
552        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 6);
553    }
554
555    #[test]
556    fn failing_file_is_named_and_the_scan_continues() {
557        let dir = tempfile::tempdir().unwrap();
558        let music_dir = dir.path().join("music");
559        std::fs::create_dir_all(&music_dir).unwrap();
560        test_utils::generate_wav(&music_dir.join("good.wav"), 44100, 1, 1.0, 16);
561        let broken = music_dir.join("broken.flac");
562        std::fs::write(&broken, b"").unwrap();
563
564        let db = test_db(dir.path());
565        let r = scan_folder(&db, &music_dir, ScanOptions::default(), None);
566
567        assert_eq!(r.added, 1, "the good file must still be indexed");
568        assert_eq!(r.errors.len(), 1);
569        assert_eq!(r.errors[0].0, broken, "the failing file must be named");
570        assert_eq!(queries::library_stats(&db.conn).unwrap().total_tracks, 1);
571    }
572
573    #[test]
574    fn a_panicking_tag_read_becomes_an_error() {
575        let path = Path::new("/music/hostile.mp3");
576        let err = isolate_read(path, |_| panic!("bogus ID3v2 frame size")).unwrap_err();
577        assert!(err.contains("hostile.mp3"), "should name the file: {}", err);
578    }
579
580    #[test]
581    fn scan_folder_updates_modified_files() {
582        let dir = tempfile::tempdir().unwrap();
583        let music_dir = dir.path().join("music");
584        std::fs::create_dir_all(&music_dir).unwrap();
585
586        let wav_path = music_dir.join("modified.wav");
587        test_utils::generate_wav(&wav_path, 44100, 1, 1.0, 16);
588
589        let db = test_db(dir.path());
590
591        // First scan.
592        let r1 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
593        assert_eq!(r1.added, 1);
594
595        // Modify the file (rewrite with different duration → different size + mtime).
596        // Sleep briefly to ensure mtime changes (some FS have 1s resolution).
597        std::thread::sleep(std::time::Duration::from_millis(1100));
598        test_utils::generate_wav(&wav_path, 44100, 1, 2.0, 16);
599
600        // Second scan: should detect the modification.
601        let r2 = scan_folder(&db, &music_dir, ScanOptions::default(), None);
602        assert_eq!(r2.updated, 1, "modified file should be re-indexed");
603        assert_eq!(r2.added, 0, "the row already exists");
604        assert_eq!(r2.skipped, 0, "modified file should not be skipped");
605    }
606}